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
This commit is contained in:
+161
-9
@@ -2,10 +2,10 @@
|
||||
import { ipcMain, dialog, shell, BrowserWindow, app } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, statSync, readFileSync } from 'node:fs'
|
||||
import { basename } from 'node:path'
|
||||
import { basename, join, dirname } from 'node:path'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
|
||||
import { getSettings, updateSettings, ensureDownloadDir } from './settings'
|
||||
import { getSettings, updateSettings, ensureDownloadDir, ensureDriveDir } from './settings'
|
||||
import {
|
||||
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
|
||||
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
|
||||
@@ -17,6 +17,7 @@ import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
|
||||
import { ChatServer } from './chat-server'
|
||||
import { ChatClient } from './chat-client'
|
||||
import { FileServer, uploadFile, uploadBuffer } from './file-server'
|
||||
import { listDrive, getDriveRoot, downloadDriveFile, collectDriveFiles } from './drive'
|
||||
import { broadcastToRenderer, focusMainWindow } from './window'
|
||||
import { rebuildMenu } from './tray'
|
||||
import { notify, setBadgeCount } from './notify'
|
||||
@@ -24,7 +25,9 @@ import { TerminalManager } from './remote/terminal'
|
||||
import { ForwardManager } from './remote/forward'
|
||||
import { UsbBridgeManager } from './remote/usb'
|
||||
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
|
||||
import { computeWsState } from './ws-state'
|
||||
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
|
||||
import { DEFAULT_PORTS } from './protocol'
|
||||
|
||||
interface NetCtx {
|
||||
self: DeviceInfo
|
||||
@@ -39,6 +42,32 @@ interface NetCtx {
|
||||
|
||||
let ctx: NetCtx | null = null
|
||||
|
||||
// 收集所有对端的"消息通道"状态
|
||||
export function collectWsState(): Record<string, boolean> {
|
||||
if (!ctx) return {}
|
||||
return computeWsState(
|
||||
ctx.discovery.getPeers().map(p => p.deviceId),
|
||||
ctx.chatClient.getStatus(),
|
||||
ctx.chatServer,
|
||||
)
|
||||
}
|
||||
|
||||
// 对端是否还"在线": discovery 能看到它, 或还有任一条 WS 通道活着
|
||||
function isPeerReachable(deviceId: string): boolean {
|
||||
if (!ctx) return false
|
||||
if (ctx.chatServer.isConnected(deviceId)) return true
|
||||
if (ctx.chatClient.getStatus()[deviceId] === 'open') return true
|
||||
return ctx.discovery.getPeers().some(p => p.deviceId === deviceId)
|
||||
}
|
||||
|
||||
// 广播 WS 通道状态全量快照
|
||||
// 用"全量快照"而不是"单个设备的增量事件": 增量事件在渲染层还没订阅时会被丢弃,
|
||||
// 启动阶段主进程往往先连上对端 (index.ts 里 connectTo 早于 createMainWindow),
|
||||
// 渲染层 mount 后就再也收不到那条 open, UI 会永久停在"等待重连"。
|
||||
function broadcastWsState() {
|
||||
broadcastToRenderer('device:wsState', { ws: collectWsState() })
|
||||
}
|
||||
|
||||
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
|
||||
// 任一进度事件或 received 完成事件取消; 到了 -> 只通知 sender, 不自动删消息
|
||||
// 气泡留在 receiving 状态, 让用户右键手动删
|
||||
@@ -72,7 +101,8 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
if (getDevice(d.deviceId)?.ignored) return
|
||||
upsertDevice({
|
||||
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
||||
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0,
|
||||
drive_token: d.driveToken || null,
|
||||
})
|
||||
broadcastToRenderer('device:found', d)
|
||||
c.chatClient.connectTo(d)
|
||||
@@ -81,7 +111,8 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
if (getDevice(d.deviceId)?.ignored) return
|
||||
upsertDevice({
|
||||
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
||||
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0,
|
||||
drive_token: d.driveToken || null,
|
||||
})
|
||||
broadcastToRenderer('device:updated', d)
|
||||
c.chatClient.connectTo(d)
|
||||
@@ -205,6 +236,9 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
})
|
||||
|
||||
c.chatServer.on('connect', (_ws, peer) => {
|
||||
// 先推通道状态再推在线事件: 渲染层收到 "offline" 时会查通道状态决定要不要真标离线,
|
||||
// 顺序反了它读到的是旧快照, 会把刚断开的设备继续留在"在线"
|
||||
broadcastWsState()
|
||||
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
|
||||
// 对端上线 -> 触发离线消息 flush
|
||||
flushPendingFor(peer.deviceId)
|
||||
@@ -221,14 +255,20 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
// "discovery 在线" 和 "WS 在线"; 同时 open 时主动 flush 一次待发消息 (双端 WS 都建好后必走 chat-server.on('connect') 那条路,
|
||||
// 但如果对方先来认我们这边, 我们 outgoing 还没通, flush 在自己这条线路上反而能立刻推)
|
||||
c.chatClient.on('open', (peer) => {
|
||||
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: true })
|
||||
broadcastWsState()
|
||||
flushPendingFor(peer.deviceId)
|
||||
})
|
||||
c.chatClient.on('close', (peer) => {
|
||||
broadcastToRenderer('device:wsState', { deviceId: peer.deviceId, wsOpen: false })
|
||||
broadcastWsState()
|
||||
// 两条腿都断了, discovery 也不认识它了 -> 确实离线, 不用等 discovery 的 15s 超时
|
||||
if (!isPeerReachable(peer.deviceId)) {
|
||||
broadcastToRenderer('device:offline', { deviceId: peer.deviceId })
|
||||
}
|
||||
})
|
||||
|
||||
c.chatServer.on('disconnect', (id) => {
|
||||
// 先推通道状态再推离线事件 (理由同 'connect')
|
||||
broadcastWsState()
|
||||
broadcastToRenderer('device:offline', { deviceId: id })
|
||||
})
|
||||
|
||||
@@ -246,6 +286,18 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
disarmPendingMetaTimer(info.fileId)
|
||||
})
|
||||
|
||||
// 对端从我的网盘下载了文件 -> 本机提示 + 审计
|
||||
c.fileServer.on('driveDownloaded', (info: { name: string; size: number; relPath: string; fromAddr: string }) => {
|
||||
const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr)
|
||||
const who = peer ? peer.name : info.fromAddr || '某台设备'
|
||||
recordAudit({
|
||||
action: 'drive.download', source: peer?.deviceId || info.fromAddr,
|
||||
result: 'ok',
|
||||
payload: { name: info.name, size: info.size, path: info.relPath },
|
||||
})
|
||||
broadcastToRenderer('drive:inbound', { name: info.name, size: info.size, peerId: peer?.deviceId, peerName: who })
|
||||
})
|
||||
|
||||
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
|
||||
c.chatServer.on('recall', (from, messageId) => {
|
||||
const row = getMessage(messageId)
|
||||
@@ -443,6 +495,11 @@ export function registerIpc() {
|
||||
// ---- 设备 / 自身 ----
|
||||
ipcMain.handle('self:info', () => ctx?.self)
|
||||
|
||||
// 渲染层启动时拉一次当前状态 (补上启动阶段被丢弃的事件)
|
||||
ipcMain.handle('app:state', () => ({
|
||||
ws: collectWsState(),
|
||||
}))
|
||||
|
||||
ipcMain.handle('device:list', () => {
|
||||
const rows = listDevices()
|
||||
const online = new Set(ctx?.discovery.getPeers().map(p => p.deviceId) || [])
|
||||
@@ -974,6 +1031,102 @@ export function registerIpc() {
|
||||
return { path: p, name: basename(p), size: st.size }
|
||||
})
|
||||
|
||||
// ===== 网盘 (浏览对方共享目录 + 选文件下载) =====
|
||||
|
||||
// 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录
|
||||
function findPeer(peerId: string): DeviceInfo | null {
|
||||
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
|
||||
if (p) return p as DeviceInfo
|
||||
const d = listDevices().find(x => x.device_id === peerId) as any
|
||||
if (!d) return null
|
||||
return {
|
||||
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
|
||||
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
|
||||
driveToken: d.drive_token || undefined,
|
||||
} as DeviceInfo
|
||||
}
|
||||
|
||||
ipcMain.handle('drive:list', async (_e, args: { peerId: string; path?: string }) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
return listDrive(peer, args.path || '')
|
||||
})
|
||||
|
||||
ipcMain.handle('drive:root', async (_e, args: { peerId: string }) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
return getDriveRoot(peer)
|
||||
})
|
||||
|
||||
// 下载选中的文件/目录 (顺序下载, 逐个报进度)
|
||||
ipcMain.handle('drive:download', async (_e, args: { peerId: string; paths: string[]; destDir?: string }) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
const destDir = args.destDir || ensureDownloadDir()
|
||||
const results: { relPath: string; ok: boolean; savedPath?: string; size?: number; reason?: string }[] = []
|
||||
let index = 0
|
||||
const total = (args.paths || []).length
|
||||
for (const relPath of args.paths || []) {
|
||||
// 选中的是目录 -> 递归收集里面的文件, 保持层级下载
|
||||
const collected = await collectDriveFiles(peer, relPath)
|
||||
if (!collected.ok) {
|
||||
results.push({ relPath, ok: false, reason: collected.reason })
|
||||
continue
|
||||
}
|
||||
// 目录本身也存一份 (下载整目录时, 以目录名作为本地子目录)
|
||||
const isDir = collected.files.length > 0 && collected.files[0].relPath !== relPath
|
||||
const baseName = basename(relPath)
|
||||
for (const f of collected.files) {
|
||||
// 相对目录的层级 (去掉目录名前缀), 只取目录部分, 文件名由下载时自己拼
|
||||
const relInside = isDir ? f.relPath.slice(relPath.length).replace(/^[/\\]+/, '') : ''
|
||||
const dirInside = relInside ? dirname(relInside) : ''
|
||||
const destSubPath = isDir ? (dirInside && dirInside !== '.' ? join(baseName, dirInside) : baseName) : undefined
|
||||
const r = await downloadDriveFile({
|
||||
peer, relPath: f.relPath, destDir,
|
||||
destSubPath: destSubPath || undefined,
|
||||
onProgress: (sent, totalBytes) => {
|
||||
broadcastToRenderer('drive:progress', { peerId: args.peerId, relPath: f.relPath, sent, total: totalBytes, index, count: total })
|
||||
},
|
||||
})
|
||||
results.push({ relPath: f.relPath, ok: r.ok, savedPath: r.path, size: r.size, reason: r.reason })
|
||||
index++
|
||||
}
|
||||
if (!isDir) index++
|
||||
}
|
||||
const okCount = results.filter(r => r.ok).length
|
||||
recordAudit({
|
||||
action: 'drive.download', target: args.peerId,
|
||||
result: okCount === results.length && results.length > 0 ? 'ok' : 'error',
|
||||
payload: { count: results.length, ok: okCount, files: results.map(r => r.relPath) },
|
||||
})
|
||||
return { ok: true, results }
|
||||
})
|
||||
|
||||
// 打开本机网盘目录
|
||||
ipcMain.handle('drive:openFolder', async () => {
|
||||
try {
|
||||
await shell.openPath(ensureDriveDir())
|
||||
return true
|
||||
} catch { return false }
|
||||
})
|
||||
|
||||
// 换网盘根目录
|
||||
ipcMain.handle('drive:pickDir', async () => {
|
||||
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||
if (!w) return null
|
||||
const r = await dialog.showOpenDialog(w, { properties: ['openDirectory', 'createDirectory'] })
|
||||
if (r.canceled || !r.filePaths[0]) return null
|
||||
updateSettings({ driveDir: r.filePaths[0] })
|
||||
ensureDriveDir()
|
||||
rebuildMenu()
|
||||
broadcastToRenderer('settings:changed', getSettings())
|
||||
return r.filePaths[0]
|
||||
})
|
||||
|
||||
// ---- 应用控制 ----
|
||||
ipcMain.handle('app:hide', () => {
|
||||
const w = BrowserWindow.getAllWindows()[0]
|
||||
@@ -1051,9 +1204,8 @@ export function registerIpc() {
|
||||
|
||||
ipcMain.handle('forward:close', (_e, args: { sessionId: string }) => {
|
||||
if (!ctx) return false
|
||||
// 控制端: 关 clientSession
|
||||
ctx.forward.cleanupClientSession(args.sessionId, 'user')
|
||||
return true
|
||||
// 我发起的是 clientSession, 对方发起的是 serverSession —— 两边都得关得掉
|
||||
return ctx.forward.closeSession(args.sessionId, 'user')
|
||||
})
|
||||
|
||||
ipcMain.handle('forward:listSessions', () => {
|
||||
|
||||
Reference in New Issue
Block a user