diff --git a/launcher/win/remote-backend.mjs b/launcher/win/remote-backend.mjs index 7dad0e2..f014566 100644 --- a/launcher/win/remote-backend.mjs +++ b/launcher/win/remote-backend.mjs @@ -128,6 +128,8 @@ export function makeDeps(overrides = {}) { homeDir: home, userId: overrides.userId || currentUserId(), runSchtasks: overrides.runSchtasks || ((args) => spawnSync("schtasks", args, { encoding: "utf8" })), + listProcesses: overrides.listProcesses || listWindowsProcesses, + killProcessTree: overrides.killProcessTree || ((pid) => spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { encoding: "utf8" })), probePort: overrides.probePort || (() => probePortSync(APP_SERVER_PORT)), fetch: overrides.fetch || globalThis.fetch, log: overrides.log || (() => {}), @@ -175,9 +177,43 @@ export function enable(deps) { export function disable(deps) { deps.runSchtasks(["/End", "/TN", TASK_NAME]); // 停当前实例,忽略失败 deps.runSchtasks(["/Delete", "/TN", TASK_NAME, "/F"]); + stopInstallDaemons(deps); return { ok: true, enabled: false }; } +function listWindowsProcesses() { + const script = [ + "Get-CimInstance Win32_Process", + "| Select-Object ProcessId,ParentProcessId,Name,CommandLine", + "| ConvertTo-Json -Compress", + ].join(" "); + const res = spawnSync("powershell", ["-NoProfile", "-Command", script], { encoding: "utf8", timeout: 10_000 }); + if (res.status !== 0 || !res.stdout.trim()) return []; + try { + const parsed = JSON.parse(res.stdout); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return []; + } +} + +function stopInstallDaemons(deps) { + const daemonMain = normalizeProcessPath(installPaths(deps.installRoot).daemonMain); + for (const proc of deps.listProcesses()) { + const rawCommandLine = proc.commandLine ?? proc.CommandLine ?? ""; + const commandLine = normalizeProcessPath(rawCommandLine); + if (!commandLine.includes(daemonMain)) continue; + if (!/\bstart\b/i.test(rawCommandLine)) continue; + const pid = Number(proc.processId ?? proc.ProcessId); + if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue; + deps.killProcessTree(pid); + } +} + +function normalizeProcessPath(value) { + return String(value).replace(/\//g, "\\").toLowerCase(); +} + // pair/pair-once 额外渲染二维码 BMP,路径回给托盘显示;其余命令原样走 core。 export async function run(command, rest, deps) { if (command === "pair" || command === "pair-once") { diff --git a/native/CodexZhTray.cs b/native/CodexZhTray.cs index 1d0f5f3..f55cdc4 100644 --- a/native/CodexZhTray.cs +++ b/native/CodexZhTray.cs @@ -25,6 +25,9 @@ static class Program static string NodePath; static string BackendPath; + [DllImport("user32.dll")] + static extern bool SetProcessDPIAware(); + [STAThread] static void Main(string[] args) { @@ -41,6 +44,7 @@ static void Main(string[] args) using (var mutex = new Mutex(true, "Global\\CodexZhRemoteTray", out created)) { if (!created) return; + try { SetProcessDPIAware(); } catch { } Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new TrayContext(NodePath, BackendPath)); @@ -201,11 +205,11 @@ class TrayContext : ApplicationContext // 共享字体:控件不负责释放赋给它的 Font,每次开窗新建会泄漏 GDI 字体对象。 // 复用随进程存活的静态实例即可根治(无控件会去 Dispose 它们)。 - static readonly Font FontBase = new Font("Microsoft YaHei", 9f); - static readonly Font FontTitle = new Font("Microsoft YaHei", 14, FontStyle.Bold); - static readonly Font FontSectionBold = new Font("Microsoft YaHei", 10, FontStyle.Bold); - static readonly Font FontRowName = new Font("Microsoft YaHei", 9.5f); - static readonly Font FontRowSub = new Font("Microsoft YaHei", 8f); + static readonly Font FontBase = new Font("Microsoft YaHei UI", 9f); + static readonly Font FontTitle = new Font("Microsoft YaHei UI", 13.5f, FontStyle.Bold); + static readonly Font FontSectionBold = new Font("Microsoft YaHei UI", 10f, FontStyle.Bold); + static readonly Font FontRowName = new Font("Microsoft YaHei UI", 9.5f); + static readonly Font FontRowSub = new Font("Microsoft YaHei UI", 8.5f); public TrayContext(string node, string backend) { @@ -217,7 +221,7 @@ public TrayContext(string node, string backend) Visible = true, Icon = TrayIcons.Get(IconState.Disabled), Text = "Codex-叉叉 远程", - ContextMenuStrip = new ContextMenuStrip(), + ContextMenuStrip = new ContextMenuStrip { Font = FontBase }, }; // 每次打开菜单前现取 status 重建(对齐 Mac menuNeedsUpdate,无定时器)。 // 遵循 Windows 原生约定:仅右键弹菜单(左键不做处理)。 @@ -251,22 +255,24 @@ void RebuildMenu() if (enabled) AddInfo(m, "已配对设备:" + deviceCount); m.Items.Add(new ToolStripSeparator()); - // 「扫码配对」两态都在:未开启时点它即隐式开启远程(见 DoPair),配对与启用合并为一步。 + // 「启动远程」用于已配对设备直接恢复连接;「扫码配对手机」仍会按需隐式启动远程。 if (enabled) { - AddItem(m, "扫码配对…", (s, e) => DoPair()); - AddItem(m, "已配对设备…", (s, e) => DoDevices()); - AddItem(m, "通知设置…", (s, e) => DoNotify()); + if (!running) AddItem(m, "启动远程", (s, e) => DoEnable()); + AddItem(m, "扫码配对手机", (s, e) => DoPair()); + AddItem(m, "已配对设备", (s, e) => DoDevices()); + AddItem(m, "通知设置", (s, e) => DoNotify()); m.Items.Add(new ToolStripSeparator()); AddItem(m, "停用远程", (s, e) => DoDisable()); } else { - // 未开启态极简:只暴露入口动作,其余(设备/通知/停用)开启后才有意义 - AddItem(m, "扫码配对手机…", (s, e) => DoPair()); + // 未开启态也保留单独启动入口:手机已配对时不必再打开二维码。 + AddItem(m, "启动远程", (s, e) => DoEnable()); + AddItem(m, "扫码配对手机", (s, e) => DoPair()); } m.Items.Add(new ToolStripSeparator()); - AddItem(m, enabled ? "退出托盘(远程继续运行)" : "退出托盘", (s, e) => DoQuit()); + AddItem(m, "退出并停止远程", (s, e) => DoQuit()); } static void AddInfo(ContextMenuStrip m, string text) @@ -287,16 +293,29 @@ void DoDisable() Backend.Call("disable"); RefreshIcon(Backend.Call("status")); } + bool EnsureRemoteEnabled() + { + var st = Backend.Call("status"); + if (Backend.Bool(st, "enabled") && Backend.Bool(st, "running")) return true; + + var en = Backend.Call("enable"); + if (Backend.HasKey(en, "error")) + { + Alert("开启失败", Backend.Str(en, "error")); + return false; + } + RefreshIcon(Backend.Call("status")); + return true; + } + void DoEnable() + { + if (EnsureRemoteEnabled()) Alert("远程已启动", "手机网页现在可以连接这台电脑。"); + } // 扫码 = 开启。未启用时先隐式开启远程(装自启 + 拉 daemon),daemon 在用户扫码的 // 几秒间隙里完成 relay 预热;已启用则直接出码,不重启 daemon(避免打断在连的会话)。 void DoPair() { - if (!Backend.Bool(Backend.Call("status"), "enabled")) - { - var en = Backend.Call("enable"); - if (Backend.HasKey(en, "error")) { Alert("开启失败", Backend.Str(en, "error")); return; } // daemon 起不来就别出码 - RefreshIcon(Backend.Call("status")); - } + if (!EnsureRemoteEnabled()) return; var res = Backend.Call("pair"); string url = Backend.Str(res, "url"); if (url == null) { Alert("配对失败", Backend.Str(res, "error") ?? "未知错误"); return; } @@ -310,6 +329,7 @@ void DoDevices() void DoNotify() { ShowNotify(); } void DoQuit() { + Backend.Call("disable"); tray.Visible = false; tray.Dispose(); ExitThread(); @@ -318,7 +338,7 @@ void DoQuit() // —— 扫码配对窗 —— void ShowQR(string url, string qrPath) { - var form = MakeWindow("微信扫码 · 配对C叉叉", 420, 620); + var form = MakeWindow("微信扫码 · 配对C叉叉", 480, 700); // 单列 TableLayoutPanel:每个控件 Anchor=None → 在列内水平居中。 // (FlowLayoutPanel TopDown 只会靠左堆叠,无法居中。) var root = new TableLayoutPanel @@ -327,7 +347,7 @@ void ShowQR(string url, string qrPath) ColumnCount = 1, RowCount = 7, AutoScroll = true, - Padding = new Padding(26, 20, 26, 20), + Padding = new Padding(30, 22, 30, 22), }; root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f)); for (int i = 0; i < 7; i++) root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); @@ -346,8 +366,8 @@ void ShowQR(string url, string qrPath) addCentered(new Label { Text = "● 远程已开启", ForeColor = Color.Gray, AutoSize = true }, 12); // 二维码白底卡片(后端已白底黑点;再垫白底防深色主题不可扫) - var card = new Panel { Width = 320, Height = 320, BackColor = Color.White }; - var pic = new PictureBox { Width = 288, Height = 288, Left = 16, Top = 16, SizeMode = PictureBoxSizeMode.Zoom }; + var card = new Panel { Width = 300, Height = 300, BackColor = Color.White }; + var pic = new PictureBox { Width = 268, Height = 268, Left = 16, Top = 16, SizeMode = PictureBoxSizeMode.Zoom }; // 经流读入并拷成内存位图:Image.FromFile 会长期锁住临时 BMP(后端下次可能覆盖), // 且 PictureBox 不负责释放其 Image,故手动在关窗时 Dispose。 try @@ -364,13 +384,16 @@ void ShowQR(string url, string qrPath) addCentered(new Label { Text = "扫码链接长期有效,请勿轻易转发", ForeColor = Color.Gray, AutoSize = true }, 8); - var copyPerm = new Button { Text = MiddleTruncate(LinkForDisplay(url), 44), Width = 340, Height = 32, FlatStyle = FlatStyle.System }; - copyPerm.Click += (s, e) => { Clipboard.SetText(url); Flash((Button)s, MiddleTruncate(LinkForDisplay(url), 44)); }; + var linkPreview = new Label { Text = LinkForDisplay(url), Width = 390, Height = 24, TextAlign = ContentAlignment.MiddleCenter, AutoEllipsis = true }; + addCentered(linkPreview, 4); + + var copyPerm = new Button { Text = "复制配对链接", Width = 380, Height = 34, FlatStyle = FlatStyle.System }; + copyPerm.Click += (s, e) => { Clipboard.SetText(url); Flash((Button)s, "复制配对链接"); }; addCentered(copyPerm, 4); addCentered(new Label { Text = "↑ 点击链接即可复制到剪贴板", ForeColor = Color.Gray, AutoSize = true }, 16); - var copyOnce = new Button { Text = "复制邀请链接(一次性 · 5 分钟)", Width = 340, Height = 32, FlatStyle = FlatStyle.System }; + var copyOnce = new Button { Text = "复制邀请链接(一次性 · 5 分钟)", Width = 380, Height = 34, FlatStyle = FlatStyle.System }; copyOnce.Click += (s, e) => { var r = Backend.Call("pair-once"); @@ -387,8 +410,8 @@ void ShowQR(string url, string qrPath) // —— 已配对设备窗 —— void ShowDevices(Dictionary res) { - var form = MakeWindow("已配对设备", 420, 480); - var root = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, Padding = new Padding(16), AutoScroll = true }; + var form = MakeWindow("已配对设备", 480, 500); + var root = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, Padding = new Padding(20), AutoScroll = true }; form.Controls.Add(root); object[] devices = (res != null && res.ContainsKey("devices")) ? res["devices"] as object[] : new object[0]; @@ -427,14 +450,14 @@ void ShowDevices(Dictionary res) else if (createdAt > 0) sub = "从未连接(配对于 " + FmtEpoch(createdAt) + ") · #" + id6; else sub = "从未连接 · #" + id6; - var rowPanel = new Panel { Width = 372, Height = 46, Margin = new Padding(0, 0, 0, 6) }; - var col = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, Left = 0, Top = 2, Width = 250, Height = 44, WrapContents = false }; + var rowPanel = new Panel { Width = 420, Height = 50, Margin = new Padding(0, 0, 0, 6) }; + var col = new FlowLayoutPanel { FlowDirection = FlowDirection.TopDown, Left = 0, Top = 2, Width = 315, Height = 48, WrapContents = false }; col.Controls.Add(new Label { Text = title, AutoSize = true, Font = FontRowName }); col.Controls.Add(new Label { Text = sub, AutoSize = true, ForeColor = Color.Gray, Font = FontRowSub }); rowPanel.Controls.Add(col); string devId = id; - var btn = new Button { Text = viewer ? "撤销" : "移除", Width = 72, Height = 28, Left = 290, Top = 8, FlatStyle = FlatStyle.System }; + var btn = new Button { Text = viewer ? "撤销" : "移除", Width = 76, Height = 30, Left = 338, Top = 8, FlatStyle = FlatStyle.System }; btn.Click += (s, e) => { Backend.Call("revoke", devId); form.Close(); ShowDevices(Backend.Call("devices")); }; rowPanel.Controls.Add(btn); root.Controls.Add(rowPanel); @@ -443,7 +466,7 @@ void ShowDevices(Dictionary res) if (unused > 0) { root.Controls.Add(new Label { Text = "有 " + unused + " 条从未连接的链接(生成过但没被扫过)", ForeColor = Color.Gray, AutoSize = true, Margin = new Padding(0, 8, 0, 4) }); - var prune = new Button { Text = "清理从未连接的链接(" + unused + ")", Width = 340, Height = 30, FlatStyle = FlatStyle.System }; + var prune = new Button { Text = "清理从未连接的链接(" + unused + ")", Width = 400, Height = 32, FlatStyle = FlatStyle.System }; prune.Click += (s, e) => { var confirm = MessageBox.Show("将作废所有生成过但从未被扫过的链接(可能是外泄/转发但没用的)。已连过的设备不受影响。", "清理从未连接的链接", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning); @@ -463,20 +486,20 @@ void ShowDevices(Dictionary res) static readonly string[] NotifyTypes = { "bark", "serverchan", "wecom", "dingtalk", "custom" }; void ShowNotify() { - var form = MakeWindow("通知设置", 420, 460); - var root = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, Padding = new Padding(16), AutoScroll = true }; + var form = MakeWindow("通知设置", 480, 480); + var root = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, Padding = new Padding(20), AutoScroll = true }; form.Controls.Add(root); root.Controls.Add(new Label { Text = "添加通知渠道", Font = FontSectionBold, AutoSize = true, Margin = new Padding(0, 0, 0, 6) }); - var combo = new ComboBox { Width = 340, DropDownStyle = ComboBoxStyle.DropDownList }; + var combo = new ComboBox { Width = 400, DropDownStyle = ComboBoxStyle.DropDownList }; combo.Items.AddRange(new object[] { "Bark", "Server酱", "企业微信", "钉钉", "自定义" }); combo.SelectedIndex = 0; root.Controls.Add(combo); - var field = new TextBox { Width = 340, Margin = new Padding(0, 6, 0, 6) }; + var field = new TextBox { Width = 400, Margin = new Padding(0, 6, 0, 6) }; root.Controls.Add(field); - root.Controls.Add(new Label { Text = "Bark/Server酱 填 Key;其余填 Webhook URL", ForeColor = Color.Gray, AutoSize = true, Margin = new Padding(0, 0, 0, 6) }); + root.Controls.Add(new Label { Text = "Bark/Server酱 填 Key;其余填 Webhook URL", ForeColor = Color.Gray, AutoSize = true, MaximumSize = new Size(400, 0), Margin = new Padding(0, 0, 0, 6) }); - var btnRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 340, Height = 36, WrapContents = false }; + var btnRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 400, Height = 38, WrapContents = false }; var addBtn = new Button { Text = "添加", Width = 90, Height = 28, FlatStyle = FlatStyle.System }; var testBtn = new Button { Text = "发送测试", Width = 100, Height = 28, FlatStyle = FlatStyle.System }; btnRow.Controls.Add(addBtn); @@ -510,9 +533,9 @@ void ShowNotify() if (n == null) continue; long idx = Backend.Long(n, "index"); string label = Backend.Str(n, "label") ?? ""; - var rowPanel = new Panel { Width = 372, Height = 32 }; - rowPanel.Controls.Add(new Label { Text = label, AutoSize = true, Left = 0, Top = 6, Width = 250 }); - var del = new Button { Text = "删除", Width = 72, Height = 26, Left = 290, Top = 2, FlatStyle = FlatStyle.System }; + var rowPanel = new Panel { Width = 420, Height = 34 }; + rowPanel.Controls.Add(new Label { Text = label, AutoSize = true, Left = 0, Top = 6, Width = 310 }); + var del = new Button { Text = "删除", Width = 76, Height = 28, Left = 338, Top = 2, FlatStyle = FlatStyle.System }; long capIdx = idx; del.Click += (s, e) => { Backend.Call("notify-remove", capIdx.ToString()); form.Close(); ShowNotify(); }; rowPanel.Controls.Add(del); @@ -535,6 +558,7 @@ Form MakeWindow(string title, int w, int h) MaximizeBox = false, MinimizeBox = false, ShowInTaskbar = true, + AutoScaleMode = AutoScaleMode.Dpi, Font = FontBase, }; windows.Add(form); diff --git a/remote/README.md b/remote/README.md index 4e2b30f..6e0f563 100644 --- a/remote/README.md +++ b/remote/README.md @@ -112,6 +112,16 @@ bundle 外文件(LaunchAgent plist、`~/.codex-zh/*`),不破坏签名封 7. 重启 → daemon + 菜单图标自动回来。 8. 「停用远程」→ 两个 agent 消失、plist 删除、图标消失。 +## Windows 托盘集成 + +Windows 版托盘程序提供与菜单栏一致的远程控制入口,后端命令复用 +`launcher/win/remote-backend.mjs`。 + +- 「启动远程」只开启 daemon,适合手机已经配对、只需要恢复连接的场景。 +- 「扫码配对手机」会按需先启动远程,再显示二维码,适合首次配对或新增设备。 +- 「停用远程」会删除计划任务,并兜底结束当前安装目录下残留的 daemon 进程。 +- 退出托盘时会先停用远程,避免用户以为已关闭但后台仍在运行。 + ## 进度与已知边界 已实现: diff --git a/test/remote-backend-win.test.mjs b/test/remote-backend-win.test.mjs index 812a801..431163b 100644 --- a/test/remote-backend-win.test.mjs +++ b/test/remote-backend-win.test.mjs @@ -35,6 +35,8 @@ function harness(overrides = {}) { return { status: 0, stdout: "", stderr: "" }; }, probePort: () => state.portOpen, + listProcesses: () => [], + killProcessTree: () => {}, ...overrides, }); return { dir, deps, calls, state, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; @@ -109,6 +111,37 @@ test("disable:End 再 Delete /F", () => { } }); +test("disable:兜底终止当前安装目录残留的 daemon 进程树", () => { + const killed = []; + const h = harness({ + listProcesses: () => [ + { + ProcessId: 101, + Name: "node.exe", + CommandLine: '"C:\\Program Files\\Codex-ZH\\app\\resources\\cua_node\\bin\\node.exe" "C:\\Program Files\\Codex-ZH\\remote\\daemon\\src\\main.mjs" start', + }, + { + ProcessId: 102, + Name: "codex.exe", + CommandLine: "C:\\Program Files\\Codex-ZH\\app\\resources\\codex.exe app-server --listen ws://127.0.0.1:19271", + }, + { + ProcessId: 201, + Name: "node.exe", + CommandLine: '"C:\\Program Files\\Other\\remote\\daemon\\src\\main.mjs" start', + }, + ], + killProcessTree: (pid) => killed.push(pid), + }); + try { + const res = disable(h.deps); + assert.equal(res.enabled, false); + assert.deepEqual(killed, [101]); + } finally { + h.cleanup(); + } +}); + test("isEnabled 跟随任务存在;isRunning 跟随端口探测", () => { const h = harness(); try { diff --git a/test/tray-source.test.mjs b/test/tray-source.test.mjs new file mode 100644 index 0000000..2c36a93 --- /dev/null +++ b/test/tray-source.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = () => readFileSync("native/CodexZhTray.cs", "utf8"); + +test("Windows 托盘退出会先停用远程 daemon", () => { + assert.match( + source(), + /void DoQuit\(\)[\s\S]*Backend\.Call\("disable"\)[\s\S]*ExitThread\(\)/, + ); +}); + +test("Windows 托盘菜单提供单独启动远程且菜单项不带省略号", () => { + const text = source(); + assert.match(text, /AddItem\(m, "启动远程", \(s, e\) => DoEnable\(\)\)/); + assert.doesNotMatch(text, /AddItem\(m, "[^"]*…"/); +}); + +test("Windows 托盘声明 DPI aware 并使用 UI 字体", () => { + const text = source(); + assert.match(text, /SetProcessDPIAware\(\)/); + assert.match(text, /AutoScaleMode = AutoScaleMode\.Dpi/); + assert.match(text, /new Font\("Microsoft YaHei UI"/); +}); + +test("Windows 托盘窗口留出 DPI 后的内容空间", () => { + const text = source(); + assert.match(text, /MakeWindow\("微信扫码 · 配对C叉叉", 480, 700\)/); + assert.match(text, /MakeWindow\("已配对设备", 480, 500\)/); + assert.match(text, /MakeWindow\("通知设置", 480, 480\)/); + assert.match(text, /Text = "复制配对链接"/); + assert.doesNotMatch(text, /MiddleTruncate\(LinkForDisplay\(url\), 44\)/); +});