2a641b1eff
## USB 整体下线 把 USB 功能真删掉, 不是只藏 UI: - 删 `src/main/remote/usb.ts` + `src/renderer/src/components/remote/UsbPanel.vue` - 删 `usb` (node-usb) 和 `node-hid` 两个依赖 —— 查过, 本来就没被真正用起来 - `asarUnpack` 去掉 node-usb/usb/node-hid 的规则 - ChatView 的 tab 由 "USB" 改成 "串口" ## 串口映射 + 串口助手 (新) `src/main/remote/serial.ts` (取代 usb.ts) 只做串口, 一个文件两件事: - **串口映射**: 把一端的真实串口映射成另一端可用的虚拟串口 (Linux/macOS 用 socat 建 PTY 配对, Windows 用 com0com 建 COM 对)。 方向做成 "我用对方的" / "对方用我的" 两种, 两边都能发起。 - **串口助手**: 不建虚拟口时, 应用自己当助手 —— 字节流直接显示, 能发文本/HEX, 能追加 LF, 能循环发送, 能清屏/存日志。 `src/renderer/src/components/remote/SerialPanel.vue` (新) 把两者做在一个面板里: 设备表 (串口 / VID:PID / 产品 / 制造商)、串口参数 (波特率/数据位/停止位/校验)、 TX/RX 时间戳 + hex + ascii 视图、虚拟口路径横幅 (可一键复制)。 ## 串口助手要能调 Linux Linux 上调串口最常见的两个坑, 都做成产品功能: - **没权限**: 诊断 dialout 组 + socat + 串口列表, 一键提权修复 (`sudo usermod -aG dialout $USER`, 提示要重新登录才生效)。 "设备没插" 和 "没权限" 分开报错, 不再混成一句 "打开失败"。 - **缺 socat**: 建虚拟口前先探 socat, 没有就弹安装指引 (apt/dnf), 免得 attach 做到一半才失败。 平台化文案也分开了: Linux 提示 dialout, Windows 提示设备管理器, macOS 提示隐私授权。 ## 线上格式一个字节没动 (兼容性关键) 局域网里常有一台机器跑旧版本, 所以: - `PROTOCOL_VERSION` 仍然 = 1 - WS 帧类型仍然叫 `'usb'` - 只把内部类型改名 (`SerialDeviceInfo` / `SerialAttachConfig` / ...) 代价: 混版本时已下线的 USB 字节桥不可用。换来: 新旧版本聊天照常, 串口映射也照常, 不会因为升协议被踢下线。**别手贱顺手改对** (AGENTS.md 坑 #10)。 ## 其它 - 主进程 `output` 事件带方向 (rx/tx), 助手才能分清"我发的"和"设备回的"; 客户端自己发出的字节不回显成 rx (无回环, S5 用例守住) - 虚拟串口 (vcom) 模式下 app 不读内部端, 避免和 socat 抢数据 - `src/main/permissions.ts` 重写成串口专用, 删掉 USB/HID 相关诊断与修复 - 保留 `src/main/remote/vcom.ts` (socat/com0com 配对) 不动, 与 serial.ts 分离 ## 验证 - npm run typecheck ✅ - npm run selftest ✅ 79 通过 / 0 失败 (USB 用例整体换成 S1-S9 串口用例) - npm run build ✅ - npm run package:linux ✅ 新 deb (84MB) - npm run selftest:package ✅ 4/4 (改成校验 serialport 原生绑定已解包) 真机 (CDP 驱动真实窗口) 验证过: 串口 tab 能打开, 设备表列到 32 个 /dev/ttyS*, 助手区 + hex 区在, 波特率/数据位/停止位/校验/循环发送/清屏/保存日志 按钮齐全, 方向切换会换设备列表 (我用对方的 = 对方设备, 对方用我的 = 本机设备)。
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import Database from 'better-sqlite3'
|
|
import { paths } from './paths'
|
|
import type { MessageBody, MessageType } from './protocol'
|
|
import { existsSync, mkdirSync } from 'node:fs'
|
|
import { dirname } from 'node:path'
|
|
|
|
if (!existsSync(dirname(paths.db))) mkdirSync(dirname(paths.db), { recursive: true })
|
|
|
|
export const db: Database.Database = new Database(paths.db)
|
|
db.pragma('journal_mode = WAL')
|
|
db.pragma('foreign_keys = ON')
|
|
|
|
// 设备: 持久化最近一次见到的设备信息, 便于离线后回看
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
device_id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
hostname TEXT,
|
|
platform TEXT,
|
|
app_version TEXT,
|
|
last_ip TEXT,
|
|
last_seen INTEGER NOT NULL,
|
|
ignored INTEGER NOT NULL DEFAULT 0,
|
|
drive_token TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
message_id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL,
|
|
from_id TEXT NOT NULL,
|
|
to_id TEXT NOT NULL,
|
|
ts INTEGER NOT NULL,
|
|
body_json TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'delivered',
|
|
-- 引用方向, 用于查询某个会话 (1对1)
|
|
conversation_key TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_messages_conv_ts
|
|
ON messages (conversation_key, ts);
|
|
|
|
CREATE TABLE IF NOT EXISTS unread (
|
|
device_id TEXT PRIMARY KEY,
|
|
count INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- 远程操作审计 (terminal / forward / serial)
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts INTEGER NOT NULL,
|
|
action TEXT NOT NULL,
|
|
source_device TEXT,
|
|
target_device TEXT,
|
|
session_id TEXT,
|
|
payload_json TEXT,
|
|
result TEXT NOT NULL,
|
|
bytes_in INTEGER NOT NULL DEFAULT 0,
|
|
bytes_out INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
|
|
`)
|
|
|
|
// 迁移: 给老库补 ignored 列 (用 try/catch 包住, 已存在会抛)
|
|
try { db.exec(`ALTER TABLE devices ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0`) } catch {}
|
|
// 迁移: 给老库补 drive_token 列 (网盘访问令牌, 老版本没有)
|
|
try { db.exec(`ALTER TABLE devices ADD COLUMN drive_token TEXT`) } catch {}
|
|
|
|
// conversation_key: 任意两个 deviceId 排序后 join, 1对1
|
|
export function conversationKey(a: string, b: string): string {
|
|
return [a, b].sort().join('::')
|
|
}
|
|
|
|
// 设备
|
|
export interface DeviceRow {
|
|
device_id: string
|
|
name: string
|
|
hostname: string | null
|
|
platform: string | null
|
|
app_version: string | null
|
|
last_ip: string | null
|
|
last_seen: number
|
|
ignored: number // 0/1
|
|
drive_token: string | null // 网盘访问令牌 (对端用它访问我的网盘)
|
|
}
|
|
|
|
const upsertDeviceStmt = db.prepare(`
|
|
INSERT INTO devices (device_id, name, hostname, platform, app_version, last_ip, last_seen, drive_token)
|
|
VALUES (@device_id, @name, @hostname, @platform, @app_version, @last_ip, @last_seen, @drive_token)
|
|
ON CONFLICT(device_id) DO UPDATE SET
|
|
name = excluded.name,
|
|
hostname = excluded.hostname,
|
|
platform = excluded.platform,
|
|
app_version = excluded.app_version,
|
|
last_ip = excluded.last_ip,
|
|
last_seen = excluded.last_seen,
|
|
drive_token = excluded.drive_token
|
|
`)
|
|
|
|
export function upsertDevice(d: DeviceRow) {
|
|
upsertDeviceStmt.run(d)
|
|
}
|
|
|
|
export function listDevices(includeIgnored = false): DeviceRow[] {
|
|
const sql = includeIgnored
|
|
? `SELECT * FROM devices ORDER BY last_seen DESC`
|
|
: `SELECT * FROM devices WHERE ignored = 0 ORDER BY last_seen DESC`
|
|
return db.prepare(sql).all() as DeviceRow[]
|
|
}
|
|
|
|
export function getDevice(id: string): DeviceRow | undefined {
|
|
return db.prepare(`SELECT * FROM devices WHERE device_id = ?`).get(id) as DeviceRow | undefined
|
|
}
|
|
|
|
export function setDeviceIgnored(deviceId: string, ignored: boolean) {
|
|
db.prepare(`UPDATE devices SET ignored = ? WHERE device_id = ?`).run(ignored ? 1 : 0, deviceId)
|
|
}
|
|
|
|
export function listIgnoredDevices(): DeviceRow[] {
|
|
return db.prepare(`SELECT * FROM devices WHERE ignored = 1 ORDER BY last_seen DESC`).all() as DeviceRow[]
|
|
}
|
|
|
|
// 删除设备本地记录 (DB 行)。下次 UDP announce 会作为新设备重新入
|
|
export function deleteDevice(deviceId: string): boolean {
|
|
const r = db.prepare(`DELETE FROM devices WHERE device_id = ?`).run(deviceId)
|
|
db.prepare(`DELETE FROM unread WHERE device_id = ?`).run(deviceId)
|
|
return r.changes > 0
|
|
}
|
|
|
|
export function deleteConversation(selfId: string, peerId: string): number {
|
|
const key = conversationKey(selfId, peerId)
|
|
const r = db.prepare(`DELETE FROM messages WHERE conversation_key = ?`).run(key)
|
|
db.prepare(`DELETE FROM unread WHERE device_id = ?`).run(peerId)
|
|
return r.changes
|
|
}
|
|
|
|
// 消息
|
|
export interface MessageRow {
|
|
message_id: string
|
|
type: MessageType
|
|
from_id: string
|
|
to_id: string
|
|
ts: number
|
|
body_json: string
|
|
status: string
|
|
conversation_key: string
|
|
}
|
|
|
|
const insertMsgStmt = db.prepare(`
|
|
INSERT OR REPLACE INTO messages
|
|
(message_id, type, from_id, to_id, ts, body_json, status, conversation_key)
|
|
VALUES
|
|
(@message_id, @type, @from_id, @to_id, @ts, @body_json, @status, @conversation_key)
|
|
`)
|
|
|
|
export function insertMessage(args: {
|
|
messageId: string
|
|
type: MessageType
|
|
fromId: string
|
|
toId: string
|
|
ts: number
|
|
body: MessageBody
|
|
status?: string
|
|
}) {
|
|
const row: MessageRow = {
|
|
message_id: args.messageId,
|
|
type: args.type,
|
|
from_id: args.fromId,
|
|
to_id: args.toId,
|
|
ts: args.ts,
|
|
body_json: JSON.stringify(args.body),
|
|
status: args.status ?? 'delivered',
|
|
conversation_key: conversationKey(args.fromId, args.toId),
|
|
}
|
|
insertMsgStmt.run(row)
|
|
return row
|
|
}
|
|
|
|
export function getMessage(id: string): MessageRow | undefined {
|
|
return db.prepare(`SELECT * FROM messages WHERE message_id = ?`).get(id) as MessageRow | undefined
|
|
}
|
|
|
|
export function listMessages(peerId: string, selfId: string, limit = 200, before?: number): MessageRow[] {
|
|
const key = conversationKey(selfId, peerId)
|
|
if (before) {
|
|
return db.prepare(
|
|
`SELECT * FROM messages WHERE conversation_key = ? AND ts < ? ORDER BY ts DESC LIMIT ?`
|
|
).all(key, before, limit).reverse() as MessageRow[]
|
|
}
|
|
return db.prepare(
|
|
`SELECT * FROM messages WHERE conversation_key = ? ORDER BY ts DESC LIMIT ?`
|
|
).all(key, limit).reverse() as MessageRow[]
|
|
}
|
|
|
|
// 待发送 (离线时缓存, 上线后由 flushPendingFor 触发重发)
|
|
export function listPendingMessages(peerId: string, selfId: string): MessageRow[] {
|
|
const key = conversationKey(selfId, peerId)
|
|
return db.prepare(
|
|
`SELECT * FROM messages
|
|
WHERE conversation_key = ? AND status = 'pending'
|
|
ORDER BY ts ASC`
|
|
).all(key) as MessageRow[]
|
|
}
|
|
|
|
// 已上传完但 WS update 帧 (带 savedPath) 没送达: metadata 帧 status='sent',
|
|
// body.savedPath 已填, 重发完整 envelope 让收件方出 "打开/位置"
|
|
export function listSentFileUpdates(selfId: string, peerId: string): MessageRow[] {
|
|
return db.prepare(
|
|
`SELECT * FROM messages
|
|
WHERE from_id = ? AND to_id = ?
|
|
AND status = 'sent'
|
|
AND type IN ('file', 'image')
|
|
AND json_extract(body_json, '$.savedPath') IS NOT NULL
|
|
ORDER BY ts ASC`
|
|
).all(selfId, peerId) as MessageRow[]
|
|
}
|
|
|
|
export function listAllPending(selfId: string): MessageRow[] {
|
|
return db.prepare(
|
|
`SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC`
|
|
).all(selfId) as MessageRow[]
|
|
}
|
|
|
|
export function updateMessageStatus(messageId: string, status: string) {
|
|
db.prepare(`UPDATE messages SET status = ? WHERE message_id = ?`).run(status, messageId)
|
|
}
|
|
|
|
// 上传完成后回写 savedPath / hash 到已存在的消息体 (进度条场景: 先显示气泡, 后台上传完再填充)
|
|
export function updateMessageBody(messageId: string, patch: Record<string, any>) {
|
|
const row = getMessage(messageId)
|
|
if (!row) return
|
|
const body = JSON.parse(row.body_json)
|
|
const next = { ...body, ...patch }
|
|
db.prepare(`UPDATE messages SET body_json = ? WHERE message_id = ?`).run(JSON.stringify(next), messageId)
|
|
}
|
|
|
|
export function deleteMessage(messageId: string) {
|
|
db.prepare(`DELETE FROM messages WHERE message_id = ?`).run(messageId)
|
|
}
|
|
|
|
// 未读
|
|
export function incrUnread(deviceId: string) {
|
|
db.prepare(`
|
|
INSERT INTO unread (device_id, count) VALUES (?, 1)
|
|
ON CONFLICT(device_id) DO UPDATE SET count = count + 1
|
|
`).run(deviceId)
|
|
}
|
|
|
|
export function clearUnread(deviceId: string) {
|
|
db.prepare(`DELETE FROM unread WHERE device_id = ?`).run(deviceId)
|
|
}
|
|
|
|
export function getUnread(): Record<string, number> {
|
|
const rows = db.prepare(`SELECT device_id, count FROM unread`).all() as { device_id: string; count: number }[]
|
|
const out: Record<string, number> = {}
|
|
for (const r of rows) out[r.device_id] = r.count
|
|
return out
|
|
}
|
|
|
|
export function totalUnread(): number {
|
|
const r = db.prepare(`SELECT COALESCE(SUM(count), 0) AS s FROM unread`).get() as { s: number }
|
|
return r.s
|
|
}
|
|
|
|
// 审计日志 (远程操作: terminal / forward / serial)
|
|
export interface AuditRow {
|
|
id: number
|
|
ts: number
|
|
action: string
|
|
source_device: string | null
|
|
target_device: string | null
|
|
session_id: string | null
|
|
payload_json: string | null
|
|
result: string
|
|
bytes_in: number
|
|
bytes_out: number
|
|
}
|
|
|
|
const insertAuditStmt = db.prepare(`
|
|
INSERT INTO audit_log
|
|
(ts, action, source_device, target_device, session_id, payload_json, result, bytes_in, bytes_out)
|
|
VALUES
|
|
(@ts, @action, @source_device, @target_device, @session_id, @payload_json, @result, @bytes_in, @bytes_out)
|
|
`)
|
|
|
|
export function recordAudit(args: {
|
|
action: string
|
|
source?: string | null
|
|
target?: string | null
|
|
sessionId?: string | null
|
|
payload?: any
|
|
result: 'ok' | 'denied' | 'error' | 'closed'
|
|
bytesIn?: number
|
|
bytesOut?: number
|
|
}): void {
|
|
insertAuditStmt.run({
|
|
ts: Date.now(),
|
|
action: args.action,
|
|
source_device: args.source ?? null,
|
|
target_device: args.target ?? null,
|
|
session_id: args.sessionId ?? null,
|
|
payload_json: args.payload !== undefined ? JSON.stringify(args.payload) : null,
|
|
result: args.result,
|
|
bytes_in: args.bytesIn ?? 0,
|
|
bytes_out: args.bytesOut ?? 0,
|
|
})
|
|
}
|
|
|
|
export function listAudit(opts: { limit?: number; since?: number; sessionId?: string } = {}): AuditRow[] {
|
|
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 2000)
|
|
if (opts.sessionId) {
|
|
return db.prepare(`SELECT * FROM audit_log WHERE session_id = ? ORDER BY ts DESC LIMIT ?`).all(opts.sessionId, limit) as AuditRow[]
|
|
}
|
|
if (opts.since) {
|
|
return db.prepare(`SELECT * FROM audit_log WHERE ts >= ? ORDER BY ts DESC LIMIT ?`).all(opts.since, limit) as AuditRow[]
|
|
}
|
|
return db.prepare(`SELECT * FROM audit_log ORDER BY ts DESC LIMIT ?`).all(limit) as AuditRow[]
|
|
}
|
|
|
|
export function pruneAudit(olderThanMs: number): number {
|
|
const r = db.prepare(`DELETE FROM audit_log WHERE ts < ?`).run(Date.now() - olderThanMs)
|
|
return r.changes
|
|
}
|
|
|
|
// 远程操作白名单 (按 deviceId -> { terminal, forward, serial })
|
|
// 写入 settings.json (electron-store) 而不是 DB, 便于跨设备查看与回滚
|
|
export interface RemoteAllowRow {
|
|
device_id: string
|
|
terminal: number // 0/1
|
|
forward: number
|
|
serial: number
|
|
}
|