新增网盘功能 - 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
7.0 KiB
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)
-
chat-clientMUST have anon('error')listener. Node EventEmitter throwsERR_UNHANDLED_ERRORand 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; justconsole.warnand move on. -
Drag-drop file path. Electron 32 removed
File.path. UsewebUtils.getPathForFile(file)fromelectron— exposed insrc/preload/index.tsasgetPathForFile(file). Callers must read it viawindow.api.getPathForFile(f)BEFORE the await boundary. -
Native rebuild.
postinstallrunselectron-builder install-app-depsto rebuildbetter-sqlite3against the bundled Electron ABI. Afternpm ior upgrading Electron, this must run. If you forget, sqlite open will throw on launch. -
mkdirfor Windows reserved names. Output dir isdist-Electron\(escaped;dist\electronchokes on Windows). Don't rename without verifying the build still produces a valid path. -
nsisisoneClick: false. Installer asks for install path + creates Desktop/Start-Menu shortcuts. Permissions are user-scoped. -
Renderer global drag highlight. Track
dragenter/dragleavewith a depth counter (not a boolean) — child elements fire leave/enter pairs. SeeMessageInput.vue'sdragDepth. -
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。 -
File conflict naming. Append
_Nbefore the first.:archive.tar.gz→archive_1.tar.gz,.bashrc→.bashrc_1. Logic infile-server.tstargetPath().
Configuration knobs
LNM_MAX_FILE_SIZE=<bytes>env var overrides the 100 GB default infile-server.ts. Useful for testing the limit path.deviceName,downloadDir,notifications,autoStartlive in~/AppData/Roaming/local-net-msg/config.jsonviaelectron-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 装完"没图标"是个坑,有两个独立原因:
-
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;生成结果已提交,平时不用跑)。 -
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(那里不按尺寸分目录)。