From 30cff70cb1cef68e4937d8eae1a3d5c21c8c8d62 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:14:57 +0800 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=20CLI=20?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=9B=B4=E6=96=B0=E4=B8=8E=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 2 +- docs/adr/0055-app-updates-member-clis.md | 6 ++++++ docs/studio.md | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 440d1a13..6d74816f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,7 +25,7 @@ _Avoid_: 迁移、合并工作区 ## Provider -AgentNave 可启动的本地 CLI 智能体。目前支持 Antigravity CLI、Claude Code、CodeBuddy Code、Codex CLI、Grok CLI、Pi Coding Agent、DeepSeek Harness 与 ZCode。 +AgentNave 可启动的本地 CLI 智能体。目前支持 Antigravity CLI、Claude Code、CodeBuddy Code、Codex CLI、Grok CLI、Pi Coding Agent、DeepSeek Harness 与 ZCode。Codex CLI 可独立安装,也可由 ChatGPT 桌面应用附带;安装来源不产生新的 Provider 身份。 ## 宿主(Host) diff --git a/docs/adr/0055-app-updates-member-clis.md b/docs/adr/0055-app-updates-member-clis.md index ae805231..b693afb4 100644 --- a/docs/adr/0055-app-updates-member-clis.md +++ b/docs/adr/0055-app-updates-member-clis.md @@ -14,6 +14,12 @@ - 安装:App 不执行。员工页对没装的 CLI 列出官方安装命令供复制,用户在自己的终端运行;没有核实过官方命令的 CLI 不列。 - 预览版只展示,拒绝执行更新。 +## 2026-11-07 修订:独立更新并发与安装来源 + +用户要求可安全并行的 CLI 同时更新。以上「同一时间只跑一个更新」改为:不同安装环境可并行;同一 CLI 不重复启动,使用同一包管理环境的更新串行,避免共享安装目录的竞争。每个更新独立回报结果,一个失败不取消其他更新。 + +员工页区分独立安装的 Codex CLI 与 ChatGPT(兼容旧 Codex)桌面应用内置的 CLI,显示各自版本及当前使用的来源;保留既有可执行文件选择顺序。独立 CLI 沿原安装方式更新,桌面内置 CLI 由所属桌面应用更新,App 不代为覆盖它。内部 Provider 与宿主身份仍为 `codex`。 + ## 取舍 - 安装不由 App 执行:新机器可能没有 Node 或 Homebrew,全局目录可能要管理员权限,代用户处理这些既复杂又容易装错位置;复制命令把这些留给用户自己的终端和习惯。更新时 CLI 已经在本机,安装方式可以从它的位置可靠地判断。 diff --git a/docs/studio.md b/docs/studio.md index 39045f12..78cba5b4 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -102,6 +102,8 @@ boss 未获点名时先读 `list_members`,从一份 `members` 列表中按模 应用启动时和此后每天检查一次新版本,也可在右键菜单“检查更新…”。发现新版时窗口和面板右上角显示升级按钮(`↑ v<版本>`),右键菜单给出“更新到 v<版本>…”。点击后先重新检查,弹窗列出这之后各版更新了什么,确认后才更新,不会静默更新:先下载并校验新版 App,若 uv 安装的 `agentnave` 比新版旧就先升级本机服务,再安装 App 并自动重启;服务升级失败就不装 App,可重试。只有本机服务比托盘旧时,按钮显示“↑ 升级服务”,托盘调用 uv 从本仓库对应 tag 安装,失败时可重试或复制命令。重启后右上角短暂显示“已更新到 v<版本>”。各宿主的服务经入口中继运行,升级后会在空闲时自动换用新版本,通常无需重连;工具接口变化时须重连以刷新工具清单。 +员工页的「全部更新」会并发更新互不冲突的 CLI;共用包管理安装环境的更新会排队,每项分别显示结果。Codex 同时装有独立 CLI 与桌面内置 CLI 时,分别显示版本及当前使用来源。独立 CLI 沿原安装方式更新,ChatGPT 桌面内置 CLI 随桌面应用更新,当前来源选择顺序保持不变。 + 也可从源码构建(需 Rust 与 Node): ```bash From cf28f03b6d3212b5acbcc943a87e846849a030d2 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:18:14 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(tray):=20=E5=91=98=E5=B7=A5=E9=A1=B5?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E6=9B=B4=E6=96=B0=20CLI=20=E5=B9=B6=E5=8C=BA?= =?UTF-8?q?=E5=88=86=20Codex=20=E5=AE=89=E8=A3=85=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个 provider/target 独立运行状态,全部更新并发启动并分别汇总成败 - 响应按目标合并,全部结束后只采用最新一次成员读取,避免旧状态回滚 - 有 installations 时列出两份版本和当前使用来源,独立安装带 target 更新 - 旧后端没有 installations 时保持原有展示和请求 Co-Authored-By: Claude Opus 5.5 --- apps/tray/ui/member-cards.js | 8 +- apps/tray/ui/member-updates.js | 171 ++++++++++++++------- apps/tray/ui/mock.js | 2 +- apps/tray/ui/settings-state.js | 2 +- apps/tray/ui/settings.css | 7 +- apps/tray/ui/tests/member-updates.test.cjs | 118 +++++++++++--- 6 files changed, 226 insertions(+), 82 deletions(-) diff --git a/apps/tray/ui/member-cards.js b/apps/tray/ui/member-cards.js index 3e94e13b..a93fd9f5 100644 --- a/apps/tray/ui/member-cards.js +++ b/apps/tray/ui/member-cards.js @@ -182,7 +182,7 @@ function renderCards() { el("div", { className: "member-title" }, el("span", { className: "cli-icon", "aria-hidden": "true" }, AN.hostTile(provider, name)), el("span", { className: "cli-name", title: name }, name), - info.installed && info.version ? el("span", { className: "cli-version" }, info.version) : null, + renderCliVersion(provider, info), renderCliUpdate(provider), info.installed ? null : el("span", { className: "badge badge-bad" }, L("未安装", "Not installed"))), el("div", { className: "member-end" }, @@ -196,7 +196,7 @@ function renderCards() { onClick: () => value !== split && setSplit(provider, value), }, text))), el("div", { className: "member-tail" }, renderPermission(provider) || el("span"), renderMembership(provider, enabled)))); - const card = el("div", { className: `cli-card member-card${enabled ? "" : " off"}`, dataset: { provider } }, head); + const card = el("div", { className: `cli-card member-card${enabled ? "" : " off"}`, dataset: { provider } }, head, renderCliInstallations(provider)); if (pendingDisable === provider) { card.appendChild(el("div", { className: "disable-confirm", role: "group", "aria-label": L(`确认移除 ${name}`, `Confirm removing ${name}`) }, el("p", {}, L(`移除会清除 ${name} 的默认模型。重新启用不会恢复;点击“保存设置”后才会生效。`, `Removing clears default models for ${name}. Re-enabling will not restore them; takes effect after clicking “Save settings”.`)), @@ -288,7 +288,7 @@ function renderErrorBanner() { // Re-probe the CLIs. A quiet probe runs when Settings opens or regains focus and only speaks // up when the lists changed. async function refreshDetection(quiet = false) { - if (busy || probing || cliUpdating || cliBatch) return; + if (busy || probing || cliBusy()) return; probing = true; probedAt = Date.now(); const before = savedSnapshot; @@ -329,7 +329,7 @@ async function refreshDetection(quiet = false) { } function probeWhenStale() { - if (!cliUpdating && !cliBatch && document.visibilityState === "visible" && Date.now() - probedAt > 30_000) refreshDetection(true); + if (!cliBusy() && document.visibilityState === "visible" && Date.now() - probedAt > 30_000) refreshDetection(true); } // Load initial members data diff --git a/apps/tray/ui/member-updates.js b/apps/tray/ui/member-updates.js index 2dd73b4c..320c06c9 100644 --- a/apps/tray/ui/member-updates.js +++ b/apps/tray/ui/member-updates.js @@ -2,11 +2,17 @@ let cliUpdates = { clis: {} }; let cliInstallCommands = {}; -let cliUpdating = null; +const cliUpdating = new Set(); let cliBatch = null; let cliSummary = ""; const cliUpdateErrors = {}; let cliQuery = 0; +let cliRead = 0; +let cliReading = 0; + +function cliBusy() { + return cliUpdating.size > 0 || cliReading > 0; +} async function loadCliInstallCommands() { try { @@ -18,7 +24,7 @@ async function loadCliInstallCommands() { // Called only after cards have painted; channel checks never hold up initial members data. async function loadCliUpdates() { - if (cliSummary && !cliUpdating && !cliBatch) { cliSummary = ""; renderCards(); } + if (cliSummary && !cliBusy() && !cliBatch) { cliSummary = ""; renderCards(); } const query = ++cliQuery; // A failed check must not leave an older 'update available' result actionable. let latest = { clis: {} }; @@ -27,23 +33,58 @@ async function loadCliUpdates() { if (!res.ok) throw new Error(String(res.status)); latest = await res.json(); } catch (_) { /* Nothing to offer until a check succeeds. */ } - if (query !== cliQuery || cliUpdating || cliBatch) return; + if (query !== cliQuery || cliBusy()) return; // A check that found nothing new leaves the cards, and any open control in them, alone. if (JSON.stringify(latest) === JSON.stringify(cliUpdates)) return; cliUpdates = latest; renderCards(); } +function cliKey(provider, target) { + return target ? `${provider}/${target}` : provider; +} + +function cliInstallations(provider) { + return cliUpdates.clis?.[provider]?.installations || []; +} + +function renderCliUpdateButton(provider, target, status) { + const key = cliKey(provider, target); + const running = cliUpdating.has(key); + return status?.update || running ? el("button", { + type: "button", className: "cli-update", disabled: running, + dataset: { focusKey: `${key}/update` }, + title: L(`通过 ${status?.command} 更新`, `Update via ${status?.command}`), + onClick: () => updateSingleCli(provider, target), + }, running ? L("更新中…", "Updating…") : L(`更新到 ${status.latest}`, `Update to ${status.latest}`)) : null; +} + function renderCliUpdate(provider) { - const status = cliUpdates.clis?.[provider]; - const running = cliUpdating === provider; return el("span", { className: "cli-update-slot" }, - status?.update || running ? el("button", { - type: "button", className: "cli-update", disabled: !!cliUpdating || !!cliBatch, - dataset: { focusKey: `${provider}/update` }, - title: L(`通过 ${status?.command} 更新`, `Update via ${status?.command}`), - onClick: () => updateSingleCli(provider), - }, running ? L("更新中…", "Updating…") : L(`更新到 ${status.latest}`, `Update to ${status.latest}`)) : null); + cliInstallations(provider).length ? null : renderCliUpdateButton(provider, undefined, cliUpdates.clis?.[provider])); +} + +// The check's version moves as soon as an update lands; detection catches up when all have finished. +function renderCliVersion(provider, info) { + const version = cliUpdates.clis?.[provider]?.version || info.version; + if (!info.installed || !version || cliInstallations(provider).length) return null; + return el("span", { className: "cli-version" }, version); +} + +function renderCliInstallation(provider, install) { + const standalone = install.id === "standalone"; + const note = standalone ? "" : L("随桌面应用更新", "Updates with the desktop app"); + return el("div", { className: "cli-installation", title: [note, install.path].filter(Boolean).join("\n") }, + el("span", { className: "cli-installation-name" }, install.label), + el("span", { className: "cli-version" }, install.version || ""), + install.active ? el("span", { className: "badge badge-accent" }, L("当前使用", "In use")) : el("span"), + el("span", { className: "cli-update-slot" }, standalone ? renderCliUpdateButton(provider, install.id, install) : null)); +} + +function renderCliInstallations(provider) { + const installations = cliInstallations(provider); + if (!installations.length) return null; + return el("div", { className: "cli-installations" }, installations.map((install) => renderCliInstallation(provider, install))); } function renderCliUpdateError(provider) { @@ -52,27 +93,68 @@ function renderCliUpdateError(provider) { } function updatableClis() { - return PROVIDERS.filter((p) => detected[p]?.installed && cliUpdates.clis?.[p]?.update); + return PROVIDERS.filter((p) => detected[p]?.installed).flatMap((provider) => { + const installations = cliInstallations(provider); + if (installations.length) { + return installations.some((i) => i.id === "standalone" && i.update) ? [{ provider, target: "standalone" }] : []; + } + return cliUpdates.clis?.[provider]?.update ? [{ provider }] : []; + }); +} + +function waitingClis() { + return updatableClis().filter(({ provider, target }) => !cliUpdating.has(cliKey(provider, target))); } function renderCliUpdateSummary() { - const providers = updatableClis(); - const visible = providers.length >= 2 || cliBatch || cliSummary; + const targets = updatableClis(); + const visible = targets.length >= 2 || cliBatch || cliSummary; let text = cliSummary; if (cliBatch) { - const name = PROVIDER_NAMES[cliBatch.provider] || cliBatch.provider; - text = L(`正在更新 ${name}…(${cliBatch.index}/${cliBatch.total})`, `Updating ${name}… (${cliBatch.index}/${cliBatch.total})`); - } else if (!text && providers.length >= 2) { - text = L(`${providers.length} 个 CLI 有新版本`, `${providers.length} CLIs have updates`); + const total = cliBatch.keys.length; + const done = cliBatch.keys.filter((key) => !cliUpdating.has(key)).length; + text = L(`正在更新…(${done}/${total})`, `Updating… (${done}/${total})`); + } else if (!text && targets.length >= 2) { + text = L(`${targets.length} 个 CLI 有新版本`, `${targets.length} CLIs have updates`); } return el("div", { className: `cli-update-summary${visible ? "" : " member-hidden"}`, role: "status" }, el("span", {}, text), - providers.length >= 2 ? el("button", { type: "button", disabled: !!cliUpdating || !!cliBatch, + targets.length >= 2 ? el("button", { type: "button", disabled: !!cliBatch || !waitingClis().length, onClick: updateAllClis }, L("全部更新", "Update all")) : null); } -async function performCliUpdate(provider) { - cliUpdating = provider; +// Read the backend's refreshed detection and members document without discarding edits. +async function settleCliUpdates(provider) { + const read = ++cliRead; + cliReading++; + renderCards(); + try { + const before = savedSnapshot; + const seatsBefore = reviewSaves; + const data = await apiGetMembers(false); + // Only the newest read made after the last update has finished describes every CLI. + if (read === cliRead && !cliUpdating.size) { + detected = data.detected || {}; + // A save made during this read is newer than what it returned. + if (!dirty && !busy && savedSnapshot === before && reviewSaves === seatsBefore) { + currentDoc = data.document || currentDoc; + markSaved(); + documentError = data.error || null; + takeReview(data); + renderErrorBanner(); + } + } + } catch (err) { + cliUpdateErrors[provider] = [cliUpdateErrors[provider], err.message].filter(Boolean).join("\n"); + } + cliReading--; + cliQuery++; + renderCards(); +} + +async function performCliUpdate(provider, target) { + const key = cliKey(provider, target); + cliUpdating.add(key); delete cliUpdateErrors[provider]; renderCards(); try { @@ -80,55 +162,38 @@ async function performCliUpdate(provider) { cliQuery++; const res = await fetch("/app/cli-updates/update", { method: "POST", headers: { "Content-Type": "application/json", "X-AgentNave-Action": "1" }, - body: JSON.stringify({ provider }), + body: JSON.stringify(target ? { provider, target } : { provider }), }); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || L("更新失败", "Update failed")); - cliUpdates = data; + // Each response is only current for its own CLI while others are still updating. + cliUpdates = { ...cliUpdates, clis: { ...cliUpdates.clis, [provider]: data.clis?.[provider] } }; return true; } catch (err) { cliUpdateErrors[provider] = err.message; return false; } finally { - // Read the backend's refreshed detection and members document without discarding edits. - try { - const before = savedSnapshot; - const seatsBefore = reviewSaves; - const data = await apiGetMembers(false); - detected = data.detected || {}; - // A save made during this read is newer than what it returned. - if (!dirty && !busy && savedSnapshot === before && reviewSaves === seatsBefore) { - currentDoc = data.document || currentDoc; - markSaved(); - documentError = data.error || null; - takeReview(data); - renderErrorBanner(); - } - } catch (err) { - cliUpdateErrors[provider] = [cliUpdateErrors[provider], err.message].filter(Boolean).join("\n"); - } + cliUpdating.delete(key); cliQuery++; - cliUpdating = null; - renderCards(); + if (cliUpdating.size) renderCards(); + else await settleCliUpdates(provider); } } -async function updateSingleCli(provider) { - if (cliUpdating || cliBatch) return; +async function updateSingleCli(provider, target) { + if (cliUpdating.has(cliKey(provider, target))) return; cliSummary = ""; - await performCliUpdate(provider); + await performCliUpdate(provider, target); } async function updateAllClis() { - if (cliUpdating || cliBatch) return; - const providers = updatableClis(); - let succeeded = 0; + const targets = waitingClis(); + if (cliBatch || !targets.length) return; + cliBatch = { keys: targets.map(({ provider, target }) => cliKey(provider, target)) }; try { - for (const [i, provider] of providers.entries()) { - cliBatch = { provider, index: i + 1, total: providers.length }; - if (await performCliUpdate(provider)) succeeded++; - } - cliSummary = L(`更新完成:成功 ${succeeded},失败 ${providers.length - succeeded}`, `Updates finished: ${succeeded} succeeded, ${providers.length - succeeded} failed`); + const results = await Promise.all(targets.map(({ provider, target }) => performCliUpdate(provider, target))); + const succeeded = results.filter(Boolean).length; + cliSummary = L(`更新完成:成功 ${succeeded},失败 ${targets.length - succeeded}`, `Updates finished: ${succeeded} succeeded, ${targets.length - succeeded} failed`); } finally { cliBatch = null; renderCards(); diff --git a/apps/tray/ui/mock.js b/apps/tray/ui/mock.js index 1a3dcede..d76f60ce 100644 --- a/apps/tray/ui/mock.js +++ b/apps/tray/ui/mock.js @@ -176,7 +176,7 @@ const HOSTS = { claude: "Claude Code", "claude-desktop": "Claude 桌面", - "codex-desktop": "Codex 桌面", + "codex-desktop": "ChatGPT(Codex)", codex: "Codex", codebuddy: "CodeBuddy", grok: "Grok", diff --git a/apps/tray/ui/settings-state.js b/apps/tray/ui/settings-state.js index cc8d7db9..22ed9bc4 100644 --- a/apps/tray/ui/settings-state.js +++ b/apps/tray/ui/settings-state.js @@ -481,7 +481,7 @@ function updateControls() { const canSave = !busy && dirty && !documentError && !emptyRestricted && errors.length === 0; $("save-btn").disabled = !canSave; - $("refresh-detection").disabled = busy || probing || !!cliUpdating || !!cliBatch; + $("refresh-detection").disabled = busy || probing || cliBusy(); $("refresh-detection").textContent = probing ? L("正在检测…", "Detecting…") : L("刷新模型列表", "Refresh models"); // Feedback hints diff --git a/apps/tray/ui/settings.css b/apps/tray/ui/settings.css index 295a0f3d..a45594a1 100644 --- a/apps/tray/ui/settings.css +++ b/apps/tray/ui/settings.css @@ -218,7 +218,7 @@ .member-title { margin-left: 0; } .member-end > .segment-control, .member-row > :nth-child(3), .member-row > .member-list { grid-column: auto; } .member-row > .member-list { width: calc(100% - 34px); } - .member-switch, .member-error { padding-left: 0; } + .member-switch, .member-error, .cli-installations { padding-left: 0; } .member-interactive { grid-column: auto; } .member-picker { margin-left: 0; } .member-card > .disable-confirm { margin: 0 -14px; } @@ -380,9 +380,14 @@ .cli-update { max-width: 100%; padding: 3px 8px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .member-title > .cli-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .cli-version { white-space: nowrap; flex: none; } +.cli-installations { display: grid; grid-template-columns: minmax(0, max-content) max-content max-content minmax(0, 132px); grid-auto-rows: var(--control-height); align-items: center; gap: 0 12px; justify-content: start; padding-left: 30px; font-size: 12.5px; } +.cli-installation { display: contents; } +.cli-installation-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--muted); } .cli-update-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 32px; font-size: 12.5px; } .cli-update-summary > span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .cli-update-summary > button { flex: none; font-size: 12px; padding: 4px 10px; } +.cli-installation .cli-update-slot { height: auto; } +.cli-installation .cli-update { height: var(--control-height); } .cli-update-error { height: 32px; line-height: 16px; overflow: auto; white-space: pre-wrap; } .cli-install-row { display: grid; grid-template-columns: 145px minmax(0, 1fr); gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); } .cli-install-name { font-weight: 550; color: var(--ink); } diff --git a/apps/tray/ui/tests/member-updates.test.cjs b/apps/tray/ui/tests/member-updates.test.cjs index 1bb6fbae..f6eedd8c 100644 --- a/apps/tray/ui/tests/member-updates.test.cjs +++ b/apps/tray/ui/tests/member-updates.test.cjs @@ -37,43 +37,88 @@ function app(fetch) { const members = { detected: { codex: { installed: true }, claude: { installed: true } } }; -test("update all runs sequential POSTs, continues after a failure and summarizes", async () => { - const calls = []; - let active = 0, maxActive = 0; +test("update all starts every POST together, survives a failure and reads members once", async () => { + const bodies = []; + let active = 0, maxActive = 0, reads = 0; const ui = app(async (url, options) => { - if (url === "/settings/members") return { ok: true, json: async () => members }; + if (url === "/settings/members") { reads++; return { ok: true, json: async () => members }; } assert.equal(url, "/app/cli-updates/update"); assert.equal(options.method, "POST"); assert.equal(options.headers["X-AgentNave-Action"], "1"); - const provider = JSON.parse(options.body).provider; - calls.push(provider); + const body = JSON.parse(options.body); + bodies.push(body); maxActive = Math.max(maxActive, ++active); await new Promise((resolve) => setImmediate(resolve)); active--; - return provider === "codex" + return body.provider === "codex" ? { ok: false, json: async () => ({ error: "codex update: failed" }) } : { ok: true, json: async () => ({ clis: {} }) }; }); await ui.run("updateAllClis()"); - assert.deepEqual(calls, ["codex", "claude"]); - assert.equal(maxActive, 1); + assert.deepEqual(bodies, [{ provider: "codex" }, { provider: "claude" }]); + assert.equal(maxActive, 2); + assert.equal(reads, 1); assert.equal(ui.run("cliSummary"), "更新完成:成功 1,失败 1"); assert.equal(ui.run("cliBatch"), null); - assert.equal(ui.run("cliUpdating"), null); - assert.deepEqual(ui.json("summaries.filter(Boolean).map(s => s.index).filter((s, i, a) => i === 0 || s !== a[i - 1])"), [1, 2]); + assert.equal(ui.run("cliBusy()"), false); assert.equal(ui.json("errors[0]").children[0], "codex update: failed"); }); -test("single update disables buttons and displays failure inside that card", async () => { - let finish; - const ui = app((url) => url === "/settings/members" +test("a response cannot roll back a target another response already updated", async () => { + const finish = {}; + const ui = app((url, options) => url === "/settings/members" ? Promise.resolve({ ok: true, json: async () => members }) - : new Promise(resolve => { finish = resolve; })); + : new Promise(resolve => { finish[JSON.parse(options.body).provider] = resolve; })); + const old = { update: true, latest: "2.0.0", version: "1.0.0" }; + const fresh = { update: false, latest: "2.0.0", version: "2.0.0" }; + const all = ui.run("updateAllClis()"); + finish.claude({ ok: true, json: async () => ({ clis: { codex: old, claude: fresh } }) }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(ui.run("cliUpdating.has('claude')"), false); + assert.equal(ui.run("cliUpdating.has('codex')"), true); + finish.codex({ ok: true, json: async () => ({ clis: { codex: fresh, claude: old } }) }); + await all; + assert.equal(ui.run("cliUpdates.clis.claude.version"), "2.0.0"); + assert.equal(ui.run("cliUpdates.clis.codex.version"), "2.0.0"); + assert.equal(ui.json("updatableClis()").length, 0); +}); + +test("a members read overtaken by a later update cannot replace its detection", async () => { + const reads = [], posts = []; + const ui = app((url) => new Promise(resolve => (url === "/settings/members" ? reads : posts).push(resolve))); + const reply = (body) => ({ ok: true, json: async () => body }); + const tick = () => new Promise((resolve) => setImmediate(resolve)); + const first = ui.run("updateSingleCli('codex')"); + posts[0](reply({ clis: {} })); + await tick(); + assert.equal(reads.length, 1); + assert.equal(ui.run("cliBusy()"), true); + const second = ui.run("updateSingleCli('claude')"); + posts[1](reply({ clis: {} })); + await tick(); + reads[1](reply({ detected: { claude: { version: "new" } } })); + await second; + assert.equal(ui.run("cliBusy()"), true); + reads[0](reply({ detected: { claude: { version: "old" } } })); + await first; + assert.equal(ui.run("detected.claude.version"), "new"); + assert.equal(ui.run("cliBusy()"), false); +}); + +test("a running update leaves other buttons usable and ignores a repeat of itself", async () => { + let finish, posts = 0; + const ui = app((url) => { + if (url === "/settings/members") return Promise.resolve({ ok: true, json: async () => members }); + posts++; + return new Promise(resolve => { finish = resolve; }); + }); const updating = ui.run("updateSingleCli('claude')"); const button = ui.json("renderCliUpdate('claude').children[0]"); assert.equal(button.attrs.disabled, true); assert.equal(button.children[0], "更新中…"); - assert.equal(ui.json("renderCliUpdate('codex').children[0]").attrs.disabled, true); + assert.equal(ui.json("renderCliUpdate('codex').children[0]").attrs.disabled, false); + await ui.run("updateSingleCli('claude')"); + assert.equal(posts, 1); finish({ ok: false, json: async () => ({ error: "claude update: second\nthird\nfourth" }) }); await updating; const error = ui.json("errors[1]"); @@ -82,6 +127,39 @@ test("single update disables buttons and displays failure inside that card", asy assert.equal(ui.json("errors[0]").children[0], ""); }); +test("Codex lists both installations and updates only the standalone one by target", async () => { + const bodies = []; + const ui = app(async (url, options) => { + if (url === "/settings/members") return { ok: true, json: async () => members }; + bodies.push(JSON.parse(options.body)); + return { ok: true, json: async () => ({ clis: {} }) }; + }); + ui.run(`cliUpdates = { clis: { codex: { update: false, version: '0.167.0-alpha.3.2', installations: [ + { id: 'desktop', label: 'ChatGPT 内置 CLI', path: '/app/codex', version: '0.167.0-alpha.3.2', active: true, update: false }, + { id: 'standalone', label: '独立安装', path: '/bin/codex', version: '0.166.0', active: false, update: true, latest: '0.167.0', command: 'npm i' }, + ] } } }`); + const [desktop, standalone] = ui.json("renderCliInstallations('codex').children[0]"); + const text = (row) => JSON.stringify(row); + assert.match(text(desktop), /0\.167\.0-alpha\.3\.2/); + assert.match(text(desktop), /当前使用/); + assert.match(desktop.attrs.title, /随桌面应用更新/); + assert.doesNotMatch(text(desktop), /cli-update"/); + assert.match(text(standalone), /0\.166\.0/); + assert.doesNotMatch(text(standalone), /当前使用/); + assert.match(text(standalone), /更新到 0\.167\.0/); + assert.equal(ui.json("renderCliUpdate('codex').children[0]"), null); + assert.equal(ui.json("renderCliVersion('codex', { installed: true, version: '1' })"), null); + assert.deepEqual(ui.json("updatableClis()"), [{ provider: "codex", target: "standalone" }]); + await ui.run("updateSingleCli('codex', 'standalone')"); + assert.deepEqual(bodies, [{ provider: "codex", target: "standalone" }]); +}); + +test("without installations a card keeps its single version and has no source rows", () => { + const ui = app(async () => { throw new Error("unexpected request"); }); + assert.equal(ui.json("renderCliInstallations('codex')"), null); + assert.equal(ui.json("renderCliVersion('codex', { installed: true, version: '1.0.0' })").children[0], "1.0.0"); +}); + test("failed latest query clears stale update availability", async () => { const ui = app(async () => { throw new Error("offline"); }); await ui.run("loadCliUpdates()"); @@ -140,18 +218,14 @@ test("updating disables refresh controls and skips both manual and focus detecti function memberSaveErrors() { return []; } countPermissionOptions = () => 0; document.visibilityState = 'visible'; - cliUpdating = 'codex'; + cliUpdating.add('codex'); `); ui.run("productionUpdateControls()"); assert.equal(ui.run("$('refresh-detection').disabled"), true); await ui.run("refreshDetection()"); ui.run("probeWhenStale()"); assert.equal(requests, 0); - ui.run("cliUpdating = null; cliBatch = { provider: 'claude' }; productionUpdateControls()"); - assert.equal(ui.run("$('refresh-detection').disabled"), true); - await ui.run("refreshDetection()"); - assert.equal(requests, 0); - ui.run("cliBatch = null; productionUpdateControls()"); + ui.run("cliUpdating.clear(); productionUpdateControls()"); assert.equal(ui.run("$('refresh-detection').disabled"), false); }); From ee6be3ad17f7cd29039b25b938307a867a48d904 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:22:12 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(tray):=20=E5=AE=8C=E5=96=84=20Codex=20?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E8=A1=8C=E7=9A=84=E7=AA=84=E5=B1=8F=E5=92=8C?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 来源行允许换行,更新按钮不再截断版本,tooltip 写明目标版本和命令 - 有来源行时不再保留标题行的空更新槽,避免挤压卡片标题 - 英文界面按 id 和所属应用映射来源名称 - 只有独立安装时走单版本展示和默认更新入口 Co-Authored-By: Claude Opus 5.5 --- apps/tray/ui/member-updates.js | 21 +++++--- apps/tray/ui/settings.css | 18 ++++--- apps/tray/ui/tests/member-updates.test.cjs | 58 ++++++++++++++++++++-- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/apps/tray/ui/member-updates.js b/apps/tray/ui/member-updates.js index 320c06c9..f8bc1c94 100644 --- a/apps/tray/ui/member-updates.js +++ b/apps/tray/ui/member-updates.js @@ -44,8 +44,16 @@ function cliKey(provider, target) { return target ? `${provider}/${target}` : provider; } +// A lone standalone installation is an ordinary CLI: its card needs no source rows. function cliInstallations(provider) { - return cliUpdates.clis?.[provider]?.installations || []; + const installations = cliUpdates.clis?.[provider]?.installations || []; + return installations.length === 1 && installations[0].id === "standalone" ? [] : installations; +} + +function cliInstallationName({ id, label }) { + if (id === "standalone") return L(label, "Standalone CLI"); + const app = ["ChatGPT", "Codex"].find((name) => label.includes(name)); + return app ? L(label, `${app} built-in CLI`) : label; } function renderCliUpdateButton(provider, target, status) { @@ -54,14 +62,14 @@ function renderCliUpdateButton(provider, target, status) { return status?.update || running ? el("button", { type: "button", className: "cli-update", disabled: running, dataset: { focusKey: `${key}/update` }, - title: L(`通过 ${status?.command} 更新`, `Update via ${status?.command}`), + title: L(`通过 ${status?.command} 更新到 ${status?.latest}`, `Update to ${status?.latest} via ${status?.command}`), onClick: () => updateSingleCli(provider, target), }, running ? L("更新中…", "Updating…") : L(`更新到 ${status.latest}`, `Update to ${status.latest}`)) : null; } function renderCliUpdate(provider) { - return el("span", { className: "cli-update-slot" }, - cliInstallations(provider).length ? null : renderCliUpdateButton(provider, undefined, cliUpdates.clis?.[provider])); + if (cliInstallations(provider).length) return null; + return el("span", { className: "cli-update-slot" }, renderCliUpdateButton(provider, undefined, cliUpdates.clis?.[provider])); } // The check's version moves as soon as an update lands; detection catches up when all have finished. @@ -75,9 +83,10 @@ function renderCliInstallation(provider, install) { const standalone = install.id === "standalone"; const note = standalone ? "" : L("随桌面应用更新", "Updates with the desktop app"); return el("div", { className: "cli-installation", title: [note, install.path].filter(Boolean).join("\n") }, - el("span", { className: "cli-installation-name" }, install.label), + el("span", { className: "cli-installation-name" }, cliInstallationName(install)), el("span", { className: "cli-version" }, install.version || ""), - install.active ? el("span", { className: "badge badge-accent" }, L("当前使用", "In use")) : el("span"), + el("span", { className: "cli-installation-active" }, + install.active ? el("span", { className: "badge badge-accent" }, L("当前使用", "In use")) : null), el("span", { className: "cli-update-slot" }, standalone ? renderCliUpdateButton(provider, install.id, install) : null)); } diff --git a/apps/tray/ui/settings.css b/apps/tray/ui/settings.css index a45594a1..414c6ab2 100644 --- a/apps/tray/ui/settings.css +++ b/apps/tray/ui/settings.css @@ -218,7 +218,7 @@ .member-title { margin-left: 0; } .member-end > .segment-control, .member-row > :nth-child(3), .member-row > .member-list { grid-column: auto; } .member-row > .member-list { width: calc(100% - 34px); } - .member-switch, .member-error, .cli-installations { padding-left: 0; } + .member-switch, .member-error { padding-left: 0; } .member-interactive { grid-column: auto; } .member-picker { margin-left: 0; } .member-card > .disable-confirm { margin: 0 -14px; } @@ -380,14 +380,20 @@ .cli-update { max-width: 100%; padding: 3px 8px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .member-title > .cli-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .cli-version { white-space: nowrap; flex: none; } -.cli-installations { display: grid; grid-template-columns: minmax(0, max-content) max-content max-content minmax(0, 132px); grid-auto-rows: var(--control-height); align-items: center; gap: 0 12px; justify-content: start; padding-left: 30px; font-size: 12.5px; } -.cli-installation { display: contents; } -.cli-installation-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--muted); } +.cli-installations { padding-left: 30px; font-size: 12.5px; } +.cli-installation { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; min-height: var(--control-height); } +.cli-installation-name { flex: none; min-width: 132px; white-space: nowrap; color: var(--muted); } +.cli-installation-active { flex: none; min-width: 60px; } .cli-update-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 32px; font-size: 12.5px; } .cli-update-summary > span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .cli-update-summary > button { flex: none; font-size: 12px; padding: 4px 10px; } -.cli-installation .cli-update-slot { height: auto; } -.cli-installation .cli-update { height: var(--control-height); } +.cli-installation .cli-version { min-width: 132px; } +.cli-installation .cli-update-slot { flex: none; height: auto; } +.cli-installation .cli-update { min-height: var(--control-height); max-width: none; overflow: visible; } +@media (max-width: 700px) { + .cli-installations { display: grid; gap: 8px; padding-left: 0; } + .cli-installation-active:empty { display: none; } +} .cli-update-error { height: 32px; line-height: 16px; overflow: auto; white-space: pre-wrap; } .cli-install-row { display: grid; grid-template-columns: 145px minmax(0, 1fr); gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); } .cli-install-name { font-weight: 550; color: var(--ink); } diff --git a/apps/tray/ui/tests/member-updates.test.cjs b/apps/tray/ui/tests/member-updates.test.cjs index f6eedd8c..809b5f7f 100644 --- a/apps/tray/ui/tests/member-updates.test.cjs +++ b/apps/tray/ui/tests/member-updates.test.cjs @@ -3,13 +3,13 @@ const assert = require("node:assert/strict"); const { readFileSync } = require("node:fs"); const vm = require("node:vm"); -function app(fetch) { +function app(fetch, AN = {}) { const controls = new Map(); const control = (id) => { if (!controls.has(id)) controls.set(id, { options: [], dataset: {}, classList: { toggle() {}, contains() { return false; } } }); return controls.get(id); }; - const context = vm.createContext({ AN: {}, fetch, + const context = vm.createContext({ AN, fetch, document: { getElementById: control }, window: { addEventListener() {} }, }); @@ -136,7 +136,7 @@ test("Codex lists both installations and updates only the standalone one by targ }); ui.run(`cliUpdates = { clis: { codex: { update: false, version: '0.167.0-alpha.3.2', installations: [ { id: 'desktop', label: 'ChatGPT 内置 CLI', path: '/app/codex', version: '0.167.0-alpha.3.2', active: true, update: false }, - { id: 'standalone', label: '独立安装', path: '/bin/codex', version: '0.166.0', active: false, update: true, latest: '0.167.0', command: 'npm i' }, + { id: 'standalone', label: 'Codex CLI', path: '/bin/codex', version: '0.166.0', active: false, update: true, latest: '0.167.0', command: 'npm i' }, ] } } }`); const [desktop, standalone] = ui.json("renderCliInstallations('codex').children[0]"); const text = (row) => JSON.stringify(row); @@ -147,13 +147,63 @@ test("Codex lists both installations and updates only the standalone one by targ assert.match(text(standalone), /0\.166\.0/); assert.doesNotMatch(text(standalone), /当前使用/); assert.match(text(standalone), /更新到 0\.167\.0/); - assert.equal(ui.json("renderCliUpdate('codex').children[0]"), null); + assert.match(text(standalone), /通过 npm i 更新到 0\.167\.0/); + assert.equal(ui.json("renderCliUpdate('codex')"), null); assert.equal(ui.json("renderCliVersion('codex', { installed: true, version: '1' })"), null); assert.deepEqual(ui.json("updatableClis()"), [{ provider: "codex", target: "standalone" }]); await ui.run("updateSingleCli('codex', 'standalone')"); assert.deepEqual(bodies, [{ provider: "codex", target: "standalone" }]); }); +test("a lone standalone installation keeps the single version and default update", async () => { + const bodies = []; + const ui = app(async (url, options) => { + if (url === "/settings/members") return { ok: true, json: async () => members }; + bodies.push(JSON.parse(options.body)); + return { ok: true, json: async () => ({ clis: {} }) }; + }); + ui.run(`cliUpdates = { clis: { codex: { update: true, latest: '2.0.0', version: '1.0.0', command: 'npm i', installations: [ + { id: 'standalone', label: 'Codex CLI', version: '1.0.0', active: true, update: true, latest: '2.0.0', command: 'npm i' }, + ] } } }`); + assert.equal(ui.json("renderCliInstallations('codex')"), null); + assert.equal(ui.json("renderCliVersion('codex', { installed: true })").children[0], "1.0.0"); + assert.equal(ui.json("renderCliUpdate('codex').children[0]").children[0], "更新到 2.0.0"); + assert.deepEqual(ui.json("updatableClis()"), [{ provider: "codex" }]); + await ui.run("updateSingleCli('codex')"); + assert.deepEqual(bodies, [{ provider: "codex" }]); +}); + +test("a later response cannot restore the standalone version it read before that update", async () => { + const finish = {}; + const ui = app((url, options) => url === "/settings/members" + ? Promise.resolve({ ok: true, json: async () => members }) + : new Promise(resolve => { finish[JSON.parse(options.body).provider] = resolve; })); + const codex = (version, update) => ({ update: false, installations: [ + { id: "desktop", label: "ChatGPT 内置 CLI", version: "3.0.0", active: true, update: false }, + { id: "standalone", label: "Codex CLI", version, active: false, update, latest: "2.0.0", command: "npm i" }, + ] }); + ui.run(`cliUpdates = { clis: ${JSON.stringify({ codex: codex("1.0.0", true), claude: { update: true, latest: "2.0.0" } })} }`); + const all = ui.run("updateAllClis()"); + finish.codex({ ok: true, json: async () => ({ clis: { codex: codex("2.0.0", false) } }) }); + await new Promise((resolve) => setImmediate(resolve)); + finish.claude({ ok: true, json: async () => ({ clis: { codex: codex("1.0.0", true), claude: { update: false } } }) }); + await all; + assert.equal(ui.run("cliUpdates.clis.codex.installations[1].version"), "2.0.0"); + assert.equal(ui.json("updatableClis()").length, 0); +}); + +test("English labels name each installation by its id and app", () => { + const ui = app(async () => { throw new Error("unexpected request"); }, { L: (_zh, en) => en }); + const row = (install) => JSON.stringify(ui.json(`renderCliInstallation('codex', ${JSON.stringify(install)})`)); + const active = row({ id: "desktop", label: "ChatGPT 内置 CLI", active: true }); + assert.match(active, /ChatGPT built-in CLI/); + assert.match(active, /In use/); + assert.match(active, /Updates with the desktop app/); + assert.match(row({ id: "desktop", label: "Codex 内置 CLI" }), /Codex built-in CLI/); + assert.match(row({ id: "standalone", label: "Codex CLI" }), /Standalone CLI/); + assert.doesNotMatch(active, /内置/); +}); + test("without installations a card keeps its single version and has no source rows", () => { const ui = app(async () => { throw new Error("unexpected request"); }); assert.equal(ui.json("renderCliInstallations('codex')"), null); From e504bc515992b06918f13383735a317107e22e18 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:24:45 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(tray):=20=E6=8C=89=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=B9=B6=E5=8F=91=E6=9B=B4=E6=96=B0=20CLI=20?= =?UTF-8?q?=E5=B9=B6=E5=8C=BA=E5=88=86=20Codex=20=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tray/src/data/request.rs | 7 +- apps/tray/src/data/tests/cli_updates.rs | 26 +++ apps/tray/src/detect.rs | 2 +- apps/tray/src/detect/locate.rs | 46 +++++ apps/tray/src/detect/update.rs | 123 ++++++++++--- apps/tray/src/detect/update/codex.rs | 84 +++++++++ apps/tray/src/detect/update/install.rs | 9 +- apps/tray/src/detect/update/locks.rs | 80 +++++++++ apps/tray/src/detect/update/tests.rs | 162 ++++++++++++++++- .../src/detect/update/tests/concurrency.rs | 167 ++++++++++++++++++ apps/tray/src/hosts.rs | 2 +- apps/tray/src/hosts/registry.rs | 12 +- apps/tray/src/hosts/split.rs | 6 +- apps/tray/src/hosts/tests.rs | 15 ++ apps/tray/src/main.rs | 1 + 15 files changed, 703 insertions(+), 39 deletions(-) create mode 100644 apps/tray/src/detect/update/codex.rs create mode 100644 apps/tray/src/detect/update/locks.rs create mode 100644 apps/tray/src/detect/update/tests/concurrency.rs diff --git a/apps/tray/src/data/request.rs b/apps/tray/src/data/request.rs index e8c0f807..d535d712 100644 --- a/apps/tray/src/data/request.rs +++ b/apps/tray/src/data/request.rs @@ -126,7 +126,12 @@ impl Reader { else { return Response::error(400, "需要有效的 CLI 名称"); }; - match crate::detect::update::update(provider) { + let target = + match crate::detect::update::validate_target(provider, payload.get("target")) { + Ok(target) => target, + Err(error) => return Response::error(400, &error), + }; + match crate::detect::update::update(provider, target) { Ok(state) => Response::json(&state), Err(error) => Response::error(400, &error), } diff --git a/apps/tray/src/data/tests/cli_updates.rs b/apps/tray/src/data/tests/cli_updates.rs index c6d77e74..3c66b909 100644 --- a/apps/tray/src/data/tests/cli_updates.rs +++ b/apps/tray/src/data/tests/cli_updates.rs @@ -57,3 +57,29 @@ fn cli_updates_routes_require_get_or_authorized_post() { ); assert_eq!(reader.respond("/settings/member-updates.js").status, 200); } + +#[test] +fn cli_update_target_rejects_desktop_unknown_and_other_providers() { + let dir = tempfile::tempdir().unwrap(); + let reader = Reader::new(dir.path().to_owned()); + for body in [ + json!({"provider":"codex", "target":"desktop"}), + json!({"provider":"codex", "target":"/tmp/codex"}), + json!({"provider":"codex", "target":false}), + json!({"provider":"codex", "target":null}), + json!({"provider":"claude", "target":"standalone"}), + ] { + let response = reader.request( + "POST", + "/app/cli-updates/update", + Some("1"), + &serde_json::to_vec(&body).unwrap(), + ); + assert_eq!(response.status, 400); + let result: Value = serde_json::from_slice(&response.body).unwrap(); + assert!( + result["error"].as_str().unwrap().contains("target"), + "{result}" + ); + } +} diff --git a/apps/tray/src/detect.rs b/apps/tray/src/detect.rs index e0dc35e3..d68901b8 100644 --- a/apps/tray/src/detect.rs +++ b/apps/tray/src/detect.rs @@ -13,7 +13,7 @@ use probe::*; #[cfg(windows)] pub(crate) use locate::uninstall_icon; use locate::*; -pub(crate) use locate::{executable, locate}; +pub(crate) use locate::{codex_desktop_app, executable, locate}; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; diff --git a/apps/tray/src/detect/locate.rs b/apps/tray/src/detect/locate.rs index 8a347afb..d065de25 100644 --- a/apps/tray/src/detect/locate.rs +++ b/apps/tray/src/detect/locate.rs @@ -67,6 +67,52 @@ pub(super) fn find(name: &str) -> Option { search_path(&names) } +/// The same bundled priority as `find`; standalone discovery must not use `find("codex")`. +pub(super) fn codex_desktop() -> Option { + #[cfg(target_os = "macos")] + return DESKTOP_CODEX + .iter() + .filter_map(|entry| expand(entry)) + .find(|p| executable(p)); + #[cfg(windows)] + return codex_bundled(); + #[cfg(not(any(target_os = "macos", windows)))] + None +} + +pub(crate) fn codex_desktop_app(path: &Path) -> Option<&'static str> { + let real = path.canonicalize().unwrap_or_else(|_| path.to_owned()); + for path in [path, real.as_path()] { + let text = path.to_string_lossy().replace('\\', "/").to_lowercase(); + if text.contains("chatgpt.app/contents/") || text.contains("/openai/chatgpt/bin/") { + return Some("ChatGPT"); + } + if text.contains(".app/contents/") + || text.contains("/.codex/plugins/.plugin-appserver/") + || text.contains("/openai/codex/bin/") + { + return Some("Codex"); + } + } + None +} + +pub(super) fn standalone_codex() -> Option { + let inherited = std::env::var_os("PATH").unwrap_or_default(); + standalone_codex_in(std::env::split_paths(&inherited).chain(common_dirs())) +} + +pub(super) fn standalone_codex_in(dirs: impl IntoIterator) -> Option { + let names: &[&str] = if cfg!(windows) { + &["codex.exe", "codex.cmd", "codex.bat"] + } else { + &["codex"] + }; + dirs.into_iter() + .flat_map(|dir| names.iter().map(move |name| dir.join(name))) + .find(|path| executable(path) && codex_desktop_app(path).is_none()) +} + pub(super) fn find_on_path(name: &str) -> Option { search_path(&[name.to_owned()]) } diff --git a/apps/tray/src/detect/update.rs b/apps/tray/src/detect/update.rs index 4f39d85b..d7c81bc8 100644 --- a/apps/tray/src/detect/update.rs +++ b/apps/tray/src/detect/update.rs @@ -1,6 +1,8 @@ //! CLI maintenance belongs to the App and follows the detected installation (ADR-0055). +mod codex; mod install; mod latest; +mod locks; #[cfg(test)] mod tests; @@ -8,10 +10,10 @@ use latest::{Channel, cached, newer}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::{Mutex, MutexGuard}; +use std::sync::{LazyLock, Mutex}; use std::time::Duration; -static UPDATING: Mutex<()> = Mutex::new(()); +static UPDATING: LazyLock = LazyLock::new(locks::Updates::default); const UPDATE_TIMEOUT: Duration = Duration::from_secs(10 * 60); #[derive(Debug)] @@ -21,6 +23,7 @@ struct Plan { command: String, via: &'static str, channel: Channel, + environment: locks::Environment, } pub(crate) fn state() -> Value { @@ -40,7 +43,13 @@ fn snapshot(all: &Value, query: impl Fn(&Channel) -> Option + Sync) -> V let query = &query; ( provider, - scope.spawn(move || cli_state(provider, &all[provider], query)), + scope.spawn(move || { + if provider == "codex" { + codex::state(&all[provider], &codex::paths(&all[provider]), query) + } else { + cli_state(provider, &all[provider], query) + } + }), ) }) .collect(); @@ -73,37 +82,95 @@ fn cli_state(provider: &str, info: &Value, query: impl Fn(&Channel) -> Option) -> Result, String> { - if preview { +/// Only a Codex standalone installation can be selected; paths never come from a request. +pub(crate) fn validate_target( + provider: &str, + target: Option<&Value>, +) -> Result, String> { + match target { + None => Ok(None), + Some(Value::String(target)) if provider == "codex" && target == "standalone" => { + Ok(Some("standalone")) + } + _ => Err("target 仅支持 Codex 的 standalone;桌面内置 CLI 请更新所属桌面应用".into()), + } +} + +pub(crate) fn update(provider: &str, target: Option<&str>) -> Result { + if crate::PREVIEW { return Err("预览版不执行 CLI 更新".into()); } - lock.try_lock() - .map_err(|_| "已有 CLI 正在更新,请稍后重试".into()) + let path = match (provider, target) { + ("codex", Some("standalone")) => super::locate::standalone_codex(), + (_, None) => super::locate::find(match provider { + "deepseek" => "dsh", + "antigravity" => "agy", + other => other, + }), + _ => return Err("target 仅支持 Codex 的 standalone".into()), + } + .ok_or("未找到此 CLI 安装来源")?; + update_at( + provider, + &path, + crate::PREVIEW, + &UPDATING, + cached, + UPDATE_TIMEOUT, + )?; + Ok(state()) } -pub(crate) fn update(provider: &str) -> Result { - let _guard = claim(crate::PREVIEW, &UPDATING)?; - let before = super::detected(false); - let info = &before[provider]; - let plan = info["path"] - .as_str() - .filter(|_| info["installed"] == true) - .and_then(|p| install::identify(provider, Path::new(p))) - .ok_or("无法安全更新此 CLI")?; - let latest = cached(&plan.channel).ok_or("无法查询最新版本")?; - let current = info["version"].as_str().ok_or("无法读取当前版本")?; - if !newer(&latest, current) { +fn read_version(path: &Path) -> Option { + super::run(path, &["--version"]) + .ok() + .and_then(|out| super::version_of(&out)) +} + +fn same_path(a: &Path, b: &Path) -> bool { + a == b + || a.canonicalize() + .ok() + .zip(b.canonicalize().ok()) + .is_some_and(|(a, b)| a == b) +} + +fn update_at( + provider: &str, + path: &Path, + preview: bool, + locks: &locks::Updates, + query: impl Fn(&Channel) -> Option, + timeout: Duration, +) -> Result { + let mut claim = locks.claim(preview, provider)?; + let plan = install::identify(provider, path) + .ok_or("无法安全更新此 CLI;桌面内置 CLI 请更新所属桌面应用")?; + claim.environment(&plan.environment); + // Read and verify this installation directly. A parallel global detection refresh may + // contain another installation or an old version and must never decide update success. + let current = read_version(path).ok_or("无法读取当前版本")?; + let latest = query(&plan.channel).ok_or("无法查询最新版本")?; + if !newer(&latest, ¤t) { return Err("没有可用的新版".into()); } - execute(&plan, UPDATE_TIMEOUT)?; - let after = super::detected(true); - verify( - &plan.command, - current, - after[provider]["version"].as_str(), - &latest, - )?; - Ok(snapshot(&after, cached)) + execute(&plan, timeout)?; + let after = read_version(path); + verify(&plan.command, ¤t, after.as_deref(), &latest)?; + let after = after.expect("verified version"); + if provider == "codex" { + codex::remember(path, Some(after.clone())); + } + // Patch only the active installation's version; no model probes or full-cache refresh. + let mut cache = super::CACHE.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(all) = cache.as_mut() + && all[provider]["path"] + .as_str() + .is_some_and(|active| same_path(path, Path::new(active))) + { + all[provider]["version"] = json!(after); + } + Ok(after) } fn verify(command: &str, before: &str, after: Option<&str>, latest: &str) -> Result<(), String> { diff --git a/apps/tray/src/detect/update/codex.rs b/apps/tray/src/detect/update/codex.rs new file mode 100644 index 00000000..04b682c8 --- /dev/null +++ b/apps/tray/src/detect/update/codex.rs @@ -0,0 +1,84 @@ +//! Two source categories only; discovering the secondary CLI never probes its models. +use super::*; +use std::collections::HashMap; +use std::time::Instant; + +type VersionEntry = (Instant, Option); +static VERSIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +const VERSION_TTL: Duration = Duration::from_secs(30); + +pub(super) fn remember(path: &Path, version: Option) { + VERSIONS + .lock() + .unwrap_or_else(|p| p.into_inner()) + .insert(path.to_owned(), (Instant::now(), version)); +} + +fn version(path: &Path) -> Option { + // Coalesce concurrent GETs too; a stale in-flight read cannot overwrite remember() after + // an update has verified its new version. Only the secondary source needs this probe. + let mut versions = VERSIONS.lock().unwrap_or_else(|p| p.into_inner()); + if let Some((at, version)) = versions.get(path) + && at.elapsed() < VERSION_TTL + { + return version.clone(); + } + let version = read_version(path); + versions.insert(path.to_owned(), (Instant::now(), version.clone())); + version +} + +pub(super) fn paths(info: &Value) -> [Option; 2] { + let mut paths = [ + super::super::locate::codex_desktop(), + super::super::locate::standalone_codex(), + ]; + if let Some(active) = info["path"].as_str().map(Path::new) { + let index = usize::from(!install::desktop("codex", active)); + paths[index] = Some(active.to_owned()); + } + paths +} + +pub(super) fn state( + info: &Value, + paths: &[Option; 2], + query: &impl Fn(&Channel) -> Option, +) -> Value { + let mut state = cli_state("codex", &Value::Null, |_| None); + let mut installations = Vec::new(); + for (index, path) in paths + .iter() + .enumerate() + .filter_map(|(i, p)| Some((i, p.as_ref()?))) + { + let active = info["path"] + .as_str() + .is_some_and(|p| same_path(Path::new(p), path)); + let version = if active { + info["version"].clone() + } else { + json!(version(path)) + }; + let installation = json!({"installed": true, "path": path, "version": version}); + let mut entry = cli_state("codex", &installation, query); + if active { + state = entry.clone(); + } + entry["id"] = json!(if index == 0 { "desktop" } else { "standalone" }); + entry["label"] = json!(if index == 0 { + match super::super::locate::codex_desktop_app(path) { + Some("ChatGPT") => "ChatGPT 内置 CLI", + _ => "Codex 内置 CLI", + } + } else { + "Codex CLI" + }); + entry["path"] = json!(path); + entry["active"] = json!(active); + installations.push(entry); + } + state["installations"] = json!(installations); + state +} diff --git a/apps/tray/src/detect/update/install.rs b/apps/tray/src/detect/update/install.rs index 1f6ad7ef..345c2201 100644 --- a/apps/tray/src/detect/update/install.rs +++ b/apps/tray/src/detect/update/install.rs @@ -11,7 +11,10 @@ pub(super) fn package(provider: &str) -> Option<&'static str> { } } -fn desktop(provider: &str, path: &Path) -> bool { +pub(super) fn desktop(provider: &str, path: &Path) -> bool { + if provider == "codex" && super::super::locate::codex_desktop_app(path).is_some() { + return true; + } if path .to_string_lossy() .replace('\\', "/") @@ -130,6 +133,7 @@ pub(super) fn classify(provider: &str, real: &Path, windows: bool) -> Option Option { cask, } }; + let environment = locks::Environment::Brew(program.canonicalize().ok()?); return Some(Plan { program, command: format!("brew {}", args.join(" ")), args, via: "homebrew", + environment, channel, }); } @@ -263,6 +269,7 @@ pub(super) fn npm( ], command: format!("{npm} install -g --prefix {package}"), via: "npm", + environment: locks::Environment::Npm(prefix.canonicalize().ok()?), channel: Channel::Npm(package.trim_end_matches("@latest").into()), }); } diff --git a/apps/tray/src/detect/update/locks.rs b/apps/tray/src/detect/update/locks.rs new file mode 100644 index 00000000..6ef68bcc --- /dev/null +++ b/apps/tray/src/detect/update/locks.rs @@ -0,0 +1,80 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Condvar, Mutex}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) enum Environment { + Native(PathBuf), + Npm(PathBuf), + Brew(PathBuf), +} + +#[derive(Default)] +struct Running { + providers: HashSet, + environments: HashSet, +} + +#[derive(Default)] +pub(super) struct Updates { + running: Mutex, + ready: Condvar, +} + +pub(super) struct Claim<'a> { + updates: &'a Updates, + provider: String, + environment: Option, +} + +impl Updates { + pub(super) fn claim(&self, preview: bool, provider: &str) -> Result, String> { + if preview { + return Err("预览版不执行 CLI 更新".into()); + } + let mut running = self.running.lock().unwrap_or_else(|p| p.into_inner()); + if !running.providers.insert(provider.to_owned()) { + return Err("已有 CLI 正在更新此 Provider,请稍后重试".into()); + } + Ok(Claim { + updates: self, + provider: provider.into(), + environment: None, + }) + } +} + +impl Claim<'_> { + /// Keep the environment until verification finishes, including while another CLI waits. + pub(super) fn environment(&mut self, environment: &Environment) { + let mut running = self + .updates + .running + .lock() + .unwrap_or_else(|p| p.into_inner()); + while running.environments.contains(environment) { + running = self + .updates + .ready + .wait(running) + .unwrap_or_else(|p| p.into_inner()); + } + running.environments.insert(environment.clone()); + self.environment = Some(environment.clone()); + } +} + +impl Drop for Claim<'_> { + fn drop(&mut self) { + let mut running = self + .updates + .running + .lock() + .unwrap_or_else(|p| p.into_inner()); + running.providers.remove(&self.provider); + if let Some(environment) = &self.environment { + running.environments.remove(environment); + } + self.updates.ready.notify_all(); + } +} diff --git a/apps/tray/src/detect/update/tests.rs b/apps/tray/src/detect/update/tests.rs index fc43ebfb..1e22d43c 100644 --- a/apps/tray/src/detect/update/tests.rs +++ b/apps/tray/src/detect/update/tests.rs @@ -304,12 +304,19 @@ fn channel_cache_success_failure_and_expiry() { #[test] fn preview_mutex_and_post_update_verification() { - let lock = Mutex::new(()); - assert!(claim(true, &lock).unwrap_err().contains("预览版")); - let guard = claim(false, &lock).unwrap(); - assert!(claim(false, &lock).unwrap_err().contains("已有 CLI")); + let locks = locks::Updates::default(); + assert!(locks.claim(true, "codex").err().unwrap().contains("预览版")); + let guard = locks.claim(false, "codex").unwrap(); + assert!( + locks + .claim(false, "codex") + .err() + .unwrap() + .contains("已有 CLI") + ); + assert!(locks.claim(false, "claude").is_ok()); drop(guard); - assert!(claim(false, &lock).is_ok()); + assert!(locks.claim(false, "codex").is_ok()); assert!( verify("codex update", "1.0.0", Some("1.0.0"), "2.0.0") .unwrap_err() @@ -364,6 +371,7 @@ fn fake_update_receives_arguments_environment_and_empty_stdin() { command: "fake update".into(), via: "native", channel: Channel::Grok("stable".into()), + environment: locks::Environment::Native(program.clone()), }; assert_eq!( execute(&plan, Duration::from_secs(5)).unwrap_err(), @@ -478,3 +486,147 @@ fn snapshot_checks_multiple_channels_in_parallel() { assert_eq!(state["clis"]["codex"]["update"], true); assert_eq!(state["clis"]["claude"]["update"], true); } + +#[test] +fn snapshot_codex_exposes_active_installation() { + let temp = TempDir::new().unwrap(); + let path = file(temp.path(), ".codex/packages/standalone/1/codex"); + let all = json!({"codex": {"installed": true, "path": path, "version": "1.0.0"}}); + let state = snapshot(&all, |_| Some("2.0.0".into())); + let installations = state["clis"]["codex"]["installations"] + .as_array() + .expect("Codex installations"); + let standalone = installations + .iter() + .find(|item| item["id"] == "standalone") + .unwrap(); + assert_eq!(standalone["path"], all["codex"]["path"]); + assert_eq!(standalone["version"], "1.0.0"); + assert_eq!(standalone["active"], true); + assert_eq!(standalone["update"], true); +} + +#[cfg(unix)] +mod concurrency; + +#[cfg(unix)] +#[test] +fn codex_sources_have_separate_versions_and_verify_the_original_path() { + let temp = TempDir::new().unwrap(); + let desktop = file( + temp.path(), + "Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex", + ); + fs::write(&desktop, "#!/bin/sh\necho 'codex-cli 9.0.0'\n").unwrap(); + let standalone = file(temp.path(), ".codex/packages/standalone/1/codex"); + fs::write(&standalone, "#!/bin/sh\nif [ \"$1\" = update ]; then echo 2.0.0 > \"$0.version\"; else echo read >> \"$0.calls\"; printf 'codex-cli '; cat \"$0.version\"; fi\n").unwrap(); + fs::write(standalone.with_extension("version"), "1.0.0\n").unwrap(); + let info = json!({"installed": true, "path": desktop, "version": "9.0.0"}); + let sources = [Some(desktop.clone()), Some(standalone.clone())]; + let state = codex::state(&info, &sources, &|_| Some("2.0.0".into())); + assert_eq!(state["version"], "9.0.0"); + assert_eq!(state["update"], false); + let installations = &state["installations"]; + assert_eq!(installations[0]["label"], "ChatGPT 内置 CLI"); + assert_eq!(installations[0]["version"], "9.0.0"); + assert_eq!(installations[0]["active"], true); + assert_eq!(installations[0]["update"], false); + assert_eq!(installations[1]["version"], "1.0.0"); + assert_eq!(installations[1]["active"], false); + assert_eq!(installations[1]["update"], true); + let again = codex::state(&info, &sources, &|_| Some("2.0.0".into())); + assert_eq!(again, state); + assert_eq!( + fs::read_to_string(standalone.with_extension("calls")) + .unwrap() + .lines() + .count(), + 1 + ); + let locks = locks::Updates::default(); + assert!( + update_at( + "codex", + &desktop, + false, + &locks, + |_| Some("10.0.0".into()), + Duration::from_secs(5) + ) + .is_err() + ); + assert_eq!( + update_at( + "codex", + &standalone, + false, + &locks, + |_| Some("2.0.0".into()), + Duration::from_secs(5) + ) + .unwrap(), + "2.0.0" + ); + let after = codex::state(&info, &sources, &|_| Some("2.0.0".into())); + assert_eq!(after["installations"][1]["version"], "2.0.0"); + assert_eq!(after["installations"][1]["update"], false); + // A successful command with no version change cannot be validated using the desktop's 9.0.0. + fs::write( + &standalone, + "#!/bin/sh\n[ \"$1\" = update ] || echo 'codex-cli 2.0.0'\nexit 0\n", + ) + .unwrap(); + assert!( + update_at( + "codex", + &standalone, + false, + &locks, + |_| Some("3.0.0".into()), + Duration::from_secs(5) + ) + .unwrap_err() + .contains("仍是旧版本") + ); +} + +#[test] +fn standalone_discovery_skips_bundled_paths_and_their_aliases() { + let temp = TempDir::new().unwrap(); + let name = if cfg!(windows) { "codex.exe" } else { "codex" }; + let desktop = file( + temp.path(), + &format!("Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/{name}"), + ); + let old_copy = file(temp.path(), &format!("OpenAI/Codex/bin/hash/{name}")); + let standalone = file(temp.path(), &format!("node/bin/{name}")); + let mut dirs = vec![ + desktop.parent().unwrap().to_owned(), + old_copy.parent().unwrap().to_owned(), + ]; + #[cfg(unix)] + { + let alias = temp.path().join("alias"); + fs::create_dir_all(&alias).unwrap(); + std::os::unix::fs::symlink(&desktop, alias.join(name)).unwrap(); + dirs.push(alias); + } + dirs.push(standalone.parent().unwrap().to_owned()); + assert_eq!( + super::super::locate::standalone_codex_in(dirs), + Some(standalone) + ); + let legacy = file( + temp.path(), + "Applications/Codex.app/Contents/Resources/codex", + ); + let info = json!({"path":legacy,"version":"0.1.0"}); + let state = codex::state(&info, &[Some(legacy), None], &|_| None); + assert_eq!(state["installations"][0]["label"], "Codex 内置 CLI"); + assert_eq!(state["installations"][0]["update"], false); + assert_eq!( + validate_target("codex", Some(&json!("standalone"))).unwrap(), + Some("standalone") + ); + assert_eq!(validate_target("codex", None).unwrap(), None); +} diff --git a/apps/tray/src/detect/update/tests/concurrency.rs b/apps/tray/src/detect/update/tests/concurrency.rs new file mode 100644 index 00000000..635f6767 --- /dev/null +++ b/apps/tray/src/detect/update/tests/concurrency.rs @@ -0,0 +1,167 @@ +use super::*; + +#[test] +fn independent_updates_run_together_and_duplicate_provider_is_refused() { + let temp = TempDir::new().unwrap(); + let a = file(temp.path(), ".codex/packages/standalone/1/codex"); + let b = file(temp.path(), ".grok/bin/grok"); + for (path, peer) in [(&a, &b), (&b, &a)] { + fs::write(path.with_extension("version"), "1.0.0\n").unwrap(); + fs::write( + path, + format!( + r#"#!/bin/sh +if [ "$1" != update ]; then cat "$0.version"; exit; fi +: > "$0.started" +n=0 +while [ ! -f '{}.started' ]; do + n=$((n+1)); [ "$n" -lt 1000 ] || {{ echo "peer update did not start" >&2; exit 8; }} + sleep 0.01 +done +sleep 0.1 +echo 2.0.0 > "$0.version" +"#, + peer.display() + ), + ) + .unwrap(); + } + let locks = locks::Updates::default(); + std::thread::scope(|scope| { + let first = scope.spawn(|| { + update_at( + "codex", + &a, + false, + &locks, + |_| Some("2.0.0".into()), + Duration::from_secs(20), + ) + }); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !a.with_extension("started").exists() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + a.with_extension("started").exists(), + "first command started" + ); + assert!( + update_at( + "codex", + &a, + false, + &locks, + |_| panic!("duplicate must not query"), + Duration::from_secs(20) + ) + .unwrap_err() + .contains("已有 CLI") + ); + let second = scope.spawn(|| { + update_at( + "grok", + &b, + false, + &locks, + |_| Some("2.0.0".into()), + Duration::from_secs(20), + ) + }); + assert_eq!(first.join().unwrap().unwrap(), "2.0.0"); + assert_eq!(second.join().unwrap().unwrap(), "2.0.0"); + }); +} + +#[test] +fn shared_npm_environment_serializes_commands_and_version_verification() { + let temp = TempDir::new().unwrap(); + let npm = file(temp.path(), "node/bin/npm"); + fs::write( + &npm, + r#"#!/bin/sh +prefix="$4" +mkdir "$prefix/busy" || exit 8 +case "$5" in + @openai/*) cli=codex;; + *) cli=claude;; +esac +sleep 0.05 +echo 2.0.0 > "$prefix/$cli.version" +rmdir "$prefix/busy" +"#, + ) + .unwrap(); + let prefix = npm.parent().unwrap().parent().unwrap(); + let mut paths = Vec::new(); + for (provider, package) in [ + ("codex", "@openai/codex"), + ("claude", "@anthropic-ai/claude-code"), + ] { + let path = file( + prefix, + &format!("lib/node_modules/{package}/bin/{provider}"), + ); + fs::write(prefix.join(format!("{provider}.version")), "1.0.0\n").unwrap(); + fs::write( + &path, + format!( + r#"#!/bin/sh +prefix='{}' +mkdir "$prefix/busy" || exit 8 +cat "$prefix/{provider}.version" +sleep 0.05 +rmdir "$prefix/busy" +"#, + prefix.display() + ), + ) + .unwrap(); + paths.push((provider, path)); + } + let locks = locks::Updates::default(); + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + let jobs: Vec<_> = paths + .iter() + .map(|(provider, path)| { + let barrier = &barrier; + let locks = &locks; + scope.spawn(move || { + barrier.wait(); + update_at( + provider, + path, + false, + locks, + |_| Some("2.0.0".into()), + Duration::from_secs(20), + ) + }) + }) + .collect(); + for job in jobs { + assert_eq!(job.join().unwrap().unwrap(), "2.0.0"); + } + }); +} + +#[test] +fn environment_keys_follow_npm_prefix_and_brew_not_package() { + let temp = TempDir::new().unwrap(); + file(temp.path(), "brew/bin/brew"); + let mut keys = Vec::new(); + for (provider, name) in [("codex", "codex"), ("claude", "claude-code")] { + let path = file(temp.path(), &format!("brew/Cellar/{name}/1/bin/{provider}")); + keys.push(install::identify(provider, &path).unwrap().environment); + } + assert_eq!(keys[0], keys[1]); + file(temp.path(), "other-brew/bin/brew"); + let other = file(temp.path(), "other-brew/Cellar/codex/1/bin/codex"); + assert_ne!( + keys[0], + install::identify("codex", &other).unwrap().environment + ); + let guard = locks::Updates::default(); + assert!(guard.claim(true, "codex").is_err()); +} diff --git a/apps/tray/src/hosts.rs b/apps/tray/src/hosts.rs index 35d7e988..48149fdc 100644 --- a/apps/tray/src/hosts.rs +++ b/apps/tray/src/hosts.rs @@ -199,8 +199,8 @@ impl Context { .iter() .flat_map(|base| { [ - base.join("Codex.app/Contents/Resources/codex"), base.join("ChatGPT.app/Contents/Resources/codex-cli/bin/codex"), + base.join("Codex.app/Contents/Resources/codex"), ] }) .collect::>() diff --git a/apps/tray/src/hosts/registry.rs b/apps/tray/src/hosts/registry.rs index 0bbaca2e..0d67867d 100644 --- a/apps/tray/src/hosts/registry.rs +++ b/apps/tray/src/hosts/registry.rs @@ -16,7 +16,17 @@ impl Hosts { ), ( "codex", - "Codex", + match self + .context + .find("codex") + .or_else(|| crate::detect::locate("codex")) + .as_deref() + .and_then(crate::detect::codex_desktop_app) + { + Some("ChatGPT") => "ChatGPT(Codex)", + Some(_) => "Codex", + None => "Codex CLI", + }, "codex", Some("CODEX_HOME"), h.join(".codex"), diff --git a/apps/tray/src/hosts/split.rs b/apps/tray/src/hosts/split.rs index b861c1dc..a3ff591d 100644 --- a/apps/tray/src/hosts/split.rs +++ b/apps/tray/src/hosts/split.rs @@ -130,11 +130,15 @@ pub(super) fn rows(context: &Context, hosts: &[Host], codex_homes: &[PathBuf]) - "mcpServers", )); } + let codex_label = hosts + .iter() + .find(|host| host.id == "codex") + .map_or("Codex CLI", |host| host.label); for dir in codex_homes { let folder = dir.strip_prefix(home).unwrap_or(dir).display().to_string(); rows.push(( interned(format!("codex@{folder}")), - interned(format!("Codex({folder})")), + interned(format!("{codex_label}({folder})")), "codex", None, dir.clone(), diff --git a/apps/tray/src/hosts/tests.rs b/apps/tray/src/hosts/tests.rs index 48a7c7fc..faced2fb 100644 --- a/apps/tray/src/hosts/tests.rs +++ b/apps/tray/src/hosts/tests.rs @@ -2204,3 +2204,18 @@ fn saving_orchestration_works_out_each_rows_mode_and_drops_the_pages_copy() { json!({"claude": "split", "grok": "split"}) ); } + +#[cfg(target_os = "macos")] +#[test] +fn codex_host_label_tracks_the_bundled_cli_without_changing_identity() { + let (_temp, hosts) = fixture(); + executable_file(&hosts.context.search[0].join("codex"), "fake"); + assert_eq!(host(&hosts, "codex").label, "Codex CLI"); + let desktop = + hosts.context.applications[0].join("ChatGPT.app/Contents/Resources/codex-cli/bin/codex"); + executable_file(&desktop, "fake"); + let codex = host(&hosts, "codex"); + assert_eq!(codex.id, "codex"); + assert_eq!(codex.boss, "codex"); + assert_eq!(codex.label, "ChatGPT(Codex)"); +} diff --git a/apps/tray/src/main.rs b/apps/tray/src/main.rs index cec805f2..70aa6dbe 100644 --- a/apps/tray/src/main.rs +++ b/apps/tray/src/main.rs @@ -870,6 +870,7 @@ mod tests { ("POST", "/app/characters/gpt"), ("POST", "/app/service/install"), ("POST", "/app/update/apply"), + ("POST", "/app/cli-updates/update"), ("DELETE", "/app/chats/x"), ] { assert!(preview_refuses(true, method, path), "{method} {path}"); From 3ce1e96b3ab7573981ea0a72fb11923429c6d034 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:28:01 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(tray):=20=E6=98=8E=E7=A1=AE=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20CLI=20=E7=9A=84=E6=9D=A5=E6=BA=90=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tray/ui/member-updates.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tray/ui/member-updates.js b/apps/tray/ui/member-updates.js index f8bc1c94..84078a53 100644 --- a/apps/tray/ui/member-updates.js +++ b/apps/tray/ui/member-updates.js @@ -51,7 +51,7 @@ function cliInstallations(provider) { } function cliInstallationName({ id, label }) { - if (id === "standalone") return L(label, "Standalone CLI"); + if (id === "standalone") return L("独立 CLI", "Standalone CLI"); const app = ["ChatGPT", "Codex"].find((name) => label.includes(name)); return app ? L(label, `${app} built-in CLI`) : label; } From 95dd293cc42cfc22b9fdc9973308c98e4c0bbb8a Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:40:03 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(tray):=20=E4=BF=9D=E7=95=99=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=90=8E=E7=9A=84=E6=8E=A2=E6=B5=8B=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=B9=B6=E6=81=A2=E5=A4=8D=E5=AE=BF=E4=B8=BB=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为版本写回记录探测序号,合并全量探测期间完成的同路径更新,避免旧结果覆盖已验证版本;探测和独立安装更新仍在缓存锁外并行。恢复宿主 Context 的 Codex.app 优先级,员工检测优先级保持不变。 --- apps/tray/src/detect.rs | 96 +++++++++++++-- apps/tray/src/detect/update.rs | 25 ++-- apps/tray/src/detect/update/tests.rs | 8 ++ .../src/detect/update/tests/concurrency.rs | 110 ++++++++++++++++++ apps/tray/src/hosts.rs | 2 +- apps/tray/src/hosts/tests.rs | 17 +++ 6 files changed, 229 insertions(+), 29 deletions(-) diff --git a/apps/tray/src/detect.rs b/apps/tray/src/detect.rs index d68901b8..c75260d5 100644 --- a/apps/tray/src/detect.rs +++ b/apps/tray/src/detect.rs @@ -15,11 +15,12 @@ pub(crate) use locate::uninstall_icon; use locate::*; pub(crate) use locate::{codex_desktop_app, executable, locate}; +use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::Mutex; use std::sync::mpsc; +use std::sync::{LazyLock, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -76,15 +77,89 @@ const CLAUDE_ALIASES: [&str; 4] = ["fable", "opus", "sonnet", "haiku"]; /// dsh has no listing command; its bundled official models, shipped default first. const DEEPSEEK_CATALOG: [&str; 2] = ["deepseek-flash", "deepseek-v4-pro"]; -static CACHE: Mutex> = Mutex::new(None); +static CACHE: LazyLock> = LazyLock::new(|| Mutex::new(Default::default())); + +#[derive(Default)] +struct DetectionCache { + all: Option, + revision: u64, + // At most one successful write per provider, including during the first full probe. + versions: BTreeMap, +} + +struct VersionWrite { + revision: u64, + path: PathBuf, + version: String, +} + +impl VersionWrite { + fn apply(&self, provider: &str, all: &mut Value) { + if all[provider]["path"] + .as_str() + .is_some_and(|active| same_path(&self.path, Path::new(active))) + { + all[provider]["version"] = json!(self.version); + } + } +} + +impl DetectionCache { + fn remember(&mut self, provider: &str, path: &Path, version: &str) { + self.revision += 1; + let write = VersionWrite { + revision: self.revision, + path: path.to_owned(), + version: version.to_owned(), + }; + if let Some(all) = &mut self.all { + write.apply(provider, all); + } + self.versions.insert(provider.to_owned(), write); + } + + fn finish(&mut self, started: u64, mut all: Value) -> Value { + for (provider, write) in &self.versions { + if write.revision > started { + write.apply(provider, &mut all); + } + } + self.all = Some(all.clone()); + all + } +} + +fn same_path(a: &Path, b: &Path) -> bool { + a == b + || a.canonicalize() + .ok() + .zip(b.canonicalize().ok()) + .is_some_and(|(a, b)| a == b) +} /// Probe results for all providers, kept until `refresh` asks for a new probe. Probes run -/// outside the lock, so concurrent first reads may probe twice; the last result wins. +/// outside the lock; successful updates made during a probe keep their verified versions. +/// A later probe can still observe external version changes. pub fn detected(refresh: bool) -> Value { - let cache = || CACHE.lock().unwrap_or_else(|poison| poison.into_inner()); - if !refresh && let Some(cached) = cache().clone() { - return cached; - } + detected_with(refresh, &CACHE, probe, |all| { + publish_native(&crate::data::data_root(), all); + }) +} + +fn detected_with( + refresh: bool, + cache: &Mutex, + probe: impl Fn(&str) -> Value + Sync, + publish: impl Fn(&Value), +) -> Value { + let cache = || cache.lock().unwrap_or_else(|poison| poison.into_inner()); + let started = { + let cache = cache(); + if !refresh && let Some(cached) = &cache.all { + return cached.clone(); + } + cache.revision + }; let results: Vec<(&str, Value)> = thread::scope(|scope| { let probes: Vec<_> = PROVIDERS .iter() @@ -102,9 +177,8 @@ pub fn detected(refresh: bool) -> Value { for (provider, result) in results { all.insert(provider.to_owned(), result); } - let all = Value::Object(all); - publish_native(&crate::data::data_root(), &all); - *cache() = Some(all.clone()); + let all = cache().finish(started, Value::Object(all)); + publish(&all); all } @@ -164,7 +238,7 @@ fn publish_native(root: &Path, all: &Value) { /// that requested no model by it until the CLI reports the model it used. pub fn cached_native(provider: &str) -> Option { let cache = CACHE.lock().unwrap_or_else(|poison| poison.into_inner()); - cache.as_ref()?[provider]["native"]["model"] + cache.all.as_ref()?[provider]["native"]["model"] .as_str() .map(str::to_owned) } diff --git a/apps/tray/src/detect/update.rs b/apps/tray/src/detect/update.rs index d7c81bc8..8b295741 100644 --- a/apps/tray/src/detect/update.rs +++ b/apps/tray/src/detect/update.rs @@ -6,6 +6,7 @@ mod locks; #[cfg(test)] mod tests; +use super::same_path; use latest::{Channel, cached, newer}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; @@ -115,6 +116,7 @@ pub(crate) fn update(provider: &str, target: Option<&str>) -> Result Option { .and_then(|out| super::version_of(&out)) } -fn same_path(a: &Path, b: &Path) -> bool { - a == b - || a.canonicalize() - .ok() - .zip(b.canonicalize().ok()) - .is_some_and(|(a, b)| a == b) -} - fn update_at( provider: &str, path: &Path, preview: bool, locks: &locks::Updates, + cache: &Mutex, query: impl Fn(&Channel) -> Option, timeout: Duration, ) -> Result { @@ -161,15 +156,11 @@ fn update_at( if provider == "codex" { codex::remember(path, Some(after.clone())); } - // Patch only the active installation's version; no model probes or full-cache refresh. - let mut cache = super::CACHE.lock().unwrap_or_else(|p| p.into_inner()); - if let Some(all) = cache.as_mut() - && all[provider]["path"] - .as_str() - .is_some_and(|active| same_path(path, Path::new(active))) - { - all[provider]["version"] = json!(after); - } + // Also protect in-flight full probes; never wait for model probes or update other paths. + cache + .lock() + .unwrap_or_else(|p| p.into_inner()) + .remember(provider, path, &after); Ok(after) } diff --git a/apps/tray/src/detect/update/tests.rs b/apps/tray/src/detect/update/tests.rs index 1e22d43c..6dd28d7c 100644 --- a/apps/tray/src/detect/update/tests.rs +++ b/apps/tray/src/detect/update/tests.rs @@ -544,12 +544,17 @@ fn codex_sources_have_separate_versions_and_verify_the_original_path() { 1 ); let locks = locks::Updates::default(); + let cache = Mutex::new(crate::detect::DetectionCache { + all: Some(json!({"codex": info})), + ..Default::default() + }); assert!( update_at( "codex", &desktop, false, &locks, + &cache, |_| Some("10.0.0".into()), Duration::from_secs(5) ) @@ -561,12 +566,14 @@ fn codex_sources_have_separate_versions_and_verify_the_original_path() { &standalone, false, &locks, + &cache, |_| Some("2.0.0".into()), Duration::from_secs(5) ) .unwrap(), "2.0.0" ); + assert_eq!(cache.lock().unwrap().all.as_ref().unwrap()["codex"], info); let after = codex::state(&info, &sources, &|_| Some("2.0.0".into())); assert_eq!(after["installations"][1]["version"], "2.0.0"); assert_eq!(after["installations"][1]["update"], false); @@ -582,6 +589,7 @@ fn codex_sources_have_separate_versions_and_verify_the_original_path() { &standalone, false, &locks, + &cache, |_| Some("3.0.0".into()), Duration::from_secs(5) ) diff --git a/apps/tray/src/detect/update/tests/concurrency.rs b/apps/tray/src/detect/update/tests/concurrency.rs index 635f6767..15e8b5ab 100644 --- a/apps/tray/src/detect/update/tests/concurrency.rs +++ b/apps/tray/src/detect/update/tests/concurrency.rs @@ -1,4 +1,5 @@ use super::*; +use crate::detect::{DetectionCache, detected_with}; #[test] fn independent_updates_run_together_and_duplicate_provider_is_refused() { @@ -27,6 +28,13 @@ echo 2.0.0 > "$0.version" .unwrap(); } let locks = locks::Updates::default(); + let cache = Mutex::new(DetectionCache { + all: Some(json!({ + "codex": {"path": a, "version": "1.0.0"}, + "grok": {"path": b, "version": "1.0.0"}, + })), + ..Default::default() + }); std::thread::scope(|scope| { let first = scope.spawn(|| { update_at( @@ -34,6 +42,7 @@ echo 2.0.0 > "$0.version" &a, false, &locks, + &cache, |_| Some("2.0.0".into()), Duration::from_secs(20), ) @@ -52,6 +61,7 @@ echo 2.0.0 > "$0.version" &a, false, &locks, + &cache, |_| panic!("duplicate must not query"), Duration::from_secs(20) ) @@ -64,6 +74,7 @@ echo 2.0.0 > "$0.version" &b, false, &locks, + &cache, |_| Some("2.0.0".into()), Duration::from_secs(20), ) @@ -71,6 +82,9 @@ echo 2.0.0 > "$0.version" assert_eq!(first.join().unwrap().unwrap(), "2.0.0"); assert_eq!(second.join().unwrap().unwrap(), "2.0.0"); }); + let all = detected_with(false, &cache, |_| panic!("must use cache"), |_| {}); + assert_eq!(all["codex"]["version"], "2.0.0"); + assert_eq!(all["grok"]["version"], "2.0.0"); } #[test] @@ -120,6 +134,7 @@ rmdir "$prefix/busy" paths.push((provider, path)); } let locks = locks::Updates::default(); + let cache = Mutex::new(DetectionCache::default()); let barrier = std::sync::Barrier::new(2); std::thread::scope(|scope| { let jobs: Vec<_> = paths @@ -127,6 +142,7 @@ rmdir "$prefix/busy" .map(|(provider, path)| { let barrier = &barrier; let locks = &locks; + let cache = &cache; scope.spawn(move || { barrier.wait(); update_at( @@ -134,6 +150,7 @@ rmdir "$prefix/busy" path, false, locks, + cache, |_| Some("2.0.0".into()), Duration::from_secs(20), ) @@ -165,3 +182,96 @@ fn environment_keys_follow_npm_prefix_and_brew_not_package() { let guard = locks::Updates::default(); assert!(guard.claim(true, "codex").is_err()); } + +#[test] +fn late_full_probe_cannot_undo_a_verified_update() { + // Cover both a forced refresh and the first, still-unpublished detection. + for seeded in [true, false] { + let temp = TempDir::new().unwrap(); + let path = file(temp.path(), ".grok/bin/grok"); + fs::write(path.with_extension("version"), "1.0.0\n").unwrap(); + fs::write( + &path, + r#"#!/bin/sh +if [ "$1" = update ]; then echo 2.0.0 > "$0.version"; else cat "$0.version"; fi +"#, + ) + .unwrap(); + let info = || { + json!({"installed": true, "path": path, + "version": read_version(&path), "models": [], "error": null}) + }; + let cache = Mutex::new(DetectionCache { + all: seeded.then(|| json!({"grok": info()})), + ..Default::default() + }); + let locks = locks::Updates::default(); + let (taken, captured) = std::sync::mpsc::channel(); + let (release, blocked) = std::sync::mpsc::channel(); + let blocked = Mutex::new(blocked); + std::thread::scope(|scope| { + let refresh = scope.spawn(|| { + detected_with( + seeded, + &cache, + |provider| match provider { + "grok" => { + let old = info(); + taken.send(()).unwrap(); + old + } + "claude" => { + blocked + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + json!({"installed": false}) + } + _ => json!({"installed": false}), + }, + |_| {}, + ) + }); + captured.recv_timeout(Duration::from_secs(10)).unwrap(); + let updated = update_at( + "grok", + &path, + false, + &locks, + &cache, + |_| Some("2.0.0".into()), + Duration::from_secs(5), + ); + // Always release the other probe before asserting, even if update fails. + release.send(()).unwrap(); + assert_eq!(updated.unwrap(), "2.0.0"); + let result = refresh.join().unwrap(); + assert_eq!(result["grok"]["version"], "2.0.0"); + assert_eq!( + detected_with( + false, + &cache, + |_| panic!("cached reads must not probe"), + |_| {} + )["grok"]["version"], + "2.0.0" + ); + }); + // A later refresh must observe an external downgrade, not pin the verified update. + fs::write(path.with_extension("version"), "0.9.0\n").unwrap(); + let refreshed = detected_with( + true, + &cache, + |provider| { + if provider == "grok" { + info() + } else { + json!({"installed": false}) + } + }, + |_| {}, + ); + assert_eq!(refreshed["grok"]["version"], "0.9.0"); + } +} diff --git a/apps/tray/src/hosts.rs b/apps/tray/src/hosts.rs index 48149fdc..35d7e988 100644 --- a/apps/tray/src/hosts.rs +++ b/apps/tray/src/hosts.rs @@ -199,8 +199,8 @@ impl Context { .iter() .flat_map(|base| { [ - base.join("ChatGPT.app/Contents/Resources/codex-cli/bin/codex"), base.join("Codex.app/Contents/Resources/codex"), + base.join("ChatGPT.app/Contents/Resources/codex-cli/bin/codex"), ] }) .collect::>() diff --git a/apps/tray/src/hosts/tests.rs b/apps/tray/src/hosts/tests.rs index faced2fb..fb1fc473 100644 --- a/apps/tray/src/hosts/tests.rs +++ b/apps/tray/src/hosts/tests.rs @@ -2219,3 +2219,20 @@ fn codex_host_label_tracks_the_bundled_cli_without_changing_identity() { assert_eq!(codex.boss, "codex"); assert_eq!(codex.label, "ChatGPT(Codex)"); } + +#[cfg(target_os = "macos")] +#[test] +fn codex_host_execution_keeps_codex_app_ahead_of_chatgpt() { + let (_temp, hosts) = fixture(); + let codex = hosts.context.applications[0].join("Codex.app/Contents/Resources/codex"); + let chatgpt = + hosts.context.applications[0].join("ChatGPT.app/Contents/Resources/codex-cli/bin/codex"); + executable_file(&codex, "fake"); + executable_file(&chatgpt, "fake"); + executable_file(&hosts.context.search[0].join("codex"), "fake"); + assert_eq!(hosts.context.find("codex"), Some(codex.clone())); + assert_eq!(host(&hosts, "codex").label, "Codex"); + fs::remove_file(codex).unwrap(); + assert_eq!(hosts.context.find("codex"), Some(chatgpt)); + assert_eq!(host(&hosts, "codex").label, "ChatGPT(Codex)"); +}