Files
LocalNetMsg/AGENTS.md
T
xiaoshuai a79086e4e8 feat: 网盘浏览下载 + 远程功能修复 + 连接状态同步 + Linux 图标打包
新增网盘功能
- FileServer 增加 GET /drive/meta|list|download, 每机长期 token 校验 + 目录穿越防护
- 新增 drive.ts 客户端: listDrive / getDriveRoot / downloadDriveFile / collectDriveFiles
- ipc 暴露 drive:list|root|download|openFolder|pickDir, DB 增 drive_token 列 + ALTER 迁移
- 新增 DrivePanel.vue, 聊天框加"网盘" tab, 设置页加网盘分区
- 支持整目录递归下载并保持层级

修复远程功能
- 终端: 控制端收到的 output 帧被直接丢弃 (case 'output': return), 导致 xterm 全空白
- ChatServer: ws 构造即 listen, 'listening' 可能早于 start() 挂监听, 导致永久挂起
- USB: serverAttach 自行生成 sessionId 与控制端不一致 (数据帧查表落空);
  libusb 枚举误用 getDevices() (WebUSB 语义, 只返回已授权设备) 改 loadDevices();
  control transfer 传数字 bmRequestType (node-usb v3 要字符串枚举);
  detachKernelDriver 挪到 selectConfiguration 之前 (否则 EBUSY)
- 转发 UI: store 的 onOpened 是 no-op 致对方发起的转发不显示、无轮询致流量/TTL 不刷新、
  对方发起的会话关不掉 (新增 closeSession, server 侧关闭需通知对端否则端口泄漏)、
  入口/目标补归属标注、方向标签语义统一、监听端口输入框加宽

修复"在线却显示等待重连"
- 根因: 启动时主进程先连上对端 (connectTo 早于 createMainWindow), device:wsState
  在渲染层订阅前发出去被丢弃; 之后连接一直保持, 再无 open 事件 -> UI 永久停在等待重连
- 改为推全量快照 + 渲染层 mount 时主动拉取 (app:state); 渲染层先注册监听再 await
- 通道语义改为 outgoing 或 inbound 任一 WS 活着即可用 (原来只看 outgoing)

修复 Linux 安装后没图标
- linux.icon 指向单张 PNG 时 app-builder 解析不出尺寸 (size=0), 图标被装进
  hicolor/0x0/ 非法目录, 桌面环境找不到; 改为指向 resources/icons 目录 (8 个标准尺寸)
- StartupWMClass 填的是 productName, 与窗口真实 WM_CLASS (可执行文件名) 对不上,
  任务栏找不到启动器; 改为 local-net-msg

其他
- 新增 scripts/selftest 自测框架 (两个虚拟节点 + 真实 ChatServer/ChatClient), 30 条用例全过
- 重写 scripts/make-icon.js 生成多尺寸图标集 (原脚本引用的源图已不存在, sharp 依赖也缺)
- 新增 docs/drive.md, docs/connection-status.md
2026-10-20 16:43:56 +08:00

7.0 KiB
Raw Blame History

LocalNetMsg Agent Guide

LAN instant-messaging desktop app. Stack: Electron 32 + Vue 3 + TypeScript + Vite + Pinia + Element Plus + SQLite (better-sqlite3). Three independent processes built by electron-vite.

Commands

Task Command
Dev (Electron + HMR) npm run dev
Build (no installer) npm run build
Typecheck main+preload npm run typecheck:node
Typecheck renderer npm run typecheck:web
Typecheck both npm run typecheck
Remote feature self-test (terminal / forward / USB) npm run selftest
Windows installer npm run package:win → dist-Electron\LocalNetMsg-0.1.0-Setup.exe
macOS / Linux npm run package:mac / npm run package:linux
Unpacked dir only npm run package:dir

All package:* scripts bake in ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR (npmmirror.com). Do not remove unless on the open internet.

npm run selftest runs the remote (terminal / port-forward / USB) self-test: one process, two virtual nodes, real ChatServer + ChatClient + remote managers. See scripts/selftest/.

Verify changes via npm run typecheck + npm run selftest + manual npm run dev.

Layout

src/
  main/       Electron main process (Node) — entry: index.ts
    discovery.ts    UDP broadcast beacon (multi-NIC, per-interface subnet bcast)
    chat-server.ts  WebSocketServer on 0.0.0.0:47900
    chat-client.ts  Outgoing WS with backoff reconnect
    file-server.ts  HTTP /upload + /file, default cap 100GB
    db.ts           SQLite via better-sqlite3 (data/app.db)
    settings.ts     electron-store wrapper (~/AppData/Roaming/local-net-msg/config.json)
    notify.ts       System Notification + Windows overlay badge
    ipc.ts          All ipcMain.handle() wiring
    protocol.ts     MessageEnvelope, WsFrame, DeviceInfo types
  preload/    contextBridge exposing typed API to renderer
  renderer/   Vue 3 SPA (Vite root)
    src/
      main.ts        createApp + Pinia + ElementPlus (full import) + zhCn
      App.vue        Global event listeners (device:*, message:*, settings:*)
      components/    Sidebar, ChatView, MessageInput, MessageItem, SettingsView, MarkdownView
      stores/        Pinia: device.ts, message.ts, session.ts
      api.ts         IPC typings (mirrors preload)
      utils/         format.ts (colorFor, initialsOf, formatSize, ...)
electron.vite.config.ts   Three builds → out/{main,preload,renderer}/

Renderer alias: @ → src/renderer/src.

Network protocol

UDP discovery on 47800, chat WS on 47900, file HTTP on 47901. All three bind 0.0.0.0 and auto-shift on conflict.

DeviceInfo carries address (chosen from the first non-internal IPv4 of the responding NIC) plus chatPort/filePort. The chat server sends no broadcast of its own — discovery comes purely from UDP. Receiver uses rinfo.address (real source IP), never the payload's self.address.

Hard-won pitfalls (don't repeat these)

  1. chat-client MUST have an on('error') listener. Node EventEmitter throws ERR_UNHANDLED_ERROR and kills the main process if 'error' fires with no listener. The WS 'error' event is followed by 'close' which triggers reconnect. Don't try to "fix" by emitting on the client; just console.warn and move on.

  2. Drag-drop file path. Electron 32 removed File.path. Use webUtils.getPathForFile(file) from electron — exposed in src/preload/index.ts as getPathForFile(file). Callers must read it via window.api.getPathForFile(f) BEFORE the await boundary.

  3. Native rebuild. postinstall runs electron-builder install-app-deps to rebuild better-sqlite3 against the bundled Electron ABI. After npm i or upgrading Electron, this must run. If you forget, sqlite open will throw on launch.

  4. mkdir for Windows reserved names. Output dir is dist-Electron\ (escaped; dist\electron chokes on Windows). Don't rename without verifying the build still produces a valid path.

  5. nsis is oneClick: false. Installer asks for install path + creates Desktop/Start-Menu shortcuts. Permissions are user-scoped.

  6. Renderer global drag highlight. Track dragenter/dragleave with a depth counter (not a boolean) — child elements fire leave/enter pairs. See MessageInput.vue's dragDepth.

  7. WS 通道状态要"拉取式"同步, 不能只靠事件推送. 聊天框顶部显示的是"通道可用"(outgoing 或 inbound 任一 WS 活着),跟侧边栏的"设备在线"(UDP 发现)是两回事。启动时主进程往往先连上对端(index.ts 里 connectTo 早于 createMainWindow),那条 device:wsState 事件会在渲染层订阅之前就发出去被丢掉 —— 早期实现只有事件推送,UI 因此永久停在"等待重连…",而连接其实一直是好的。规矩:(1) device:wsState 推全量快照而不是增量;(2) 渲染层 mount 时主动 invoke('app:state') 拉一次;(3) 渲染层先注册监听再 await,否则 await 期间的事件也会丢。详见 docs/connection-status.md。

  8. File conflict naming. Append _N before the first .: archive.tar.gz → archive_1.tar.gz, .bashrc → .bashrc_1. Logic in file-server.ts targetPath().

Configuration knobs

  • LNM_MAX_FILE_SIZE=<bytes> env var overrides the 100 GB default in file-server.ts. Useful for testing the limit path.
  • deviceName, downloadDir, notifications, autoStart live in ~/AppData/Roaming/local-net-msg/config.json via electron-store. Edit the file directly while dev is stopped.
  • data/app.db — SQLite DB. DELETE to reset all devices/messages. Auto-migrates on next launch.

Packaging gotcha

release/ contains an app.asar Windows cannot delete while the previous app instance holds a file handle. Build now writes to dist-Electron\ to dodge this. If you ever switch back, kill the running app first or you'll get EPERM.

Linux packaging (icon + taskbar)

Ubuntu 装完"没图标"是个坑,有两个独立原因:

  1. linux.icon 必须指向目录,不能是单张 PNG。 electron-builder 把图标装进 /usr/share/icons/hicolor/<size>x<size>/apps/<exe>.png,而单张 PNG 交给 app-builder 时它解析不出尺寸(返回 size: 0),于是装进 hicolor/0x0/ 这种非法目录,桌面环境根本找不到 → 启动器没图标。正确做法是给一个目录,里面按 <size>x<size>.png 命名:resources/icons/{16,24,32,48,64,128,256,512}x{...}.png,然后 linux.icon = "resources/icons"。图标集用 node scripts/make-icon.js 生成(需要 ImageMagick 的 convert;生成结果已提交,平时不用跑)。

  2. StartupWMClass 要跟窗口真实的 WM_CLASS 一致。 electron-builder 默认填 productName(LocalNetMsg),但 Electron 实际设的是可执行文件名(local-net-msg)—— 对不上,任务栏就找不到对应启动器,显示成通用图标。用 xprop -id <wid> WM_CLASS 确认真实值,然后在 build.linux.desktop.StartupWMClass 里写它。

Windows / macOS 仍用单张 resources/icon.png(那里不按尺寸分目录)。