diff --git a/Agent.md b/Agent.md index bcc54f8..254a12d 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (218: 43 daemon_client + 19 conn-manager + 22 app-commands + 97 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (221: 43 daemon_client + 19 conn-manager + 22 app-commands + 100 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -112,8 +112,8 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (705) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (218: 43 daemon_client + 19 conn-manager + 22 app-commands + 97 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +Python: `uv run pytest tests/ -v` (729) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (221: 43 daemon_client + 19 conn-manager + 22 app-commands + 100 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/config.py b/emrg/config.py index 9e57396..9bb7b5d 100644 --- a/emrg/config.py +++ b/emrg/config.py @@ -39,15 +39,21 @@ class LlmConfig: @dataclass class UpdateConfig: - """Auto update-check settings (rant 2026-08-10T07:12:12). - - check: master switch — when false, the daemon never queries GitHub. - ttl_hours: how often to re-check the latest release (default 24h). - Prompting is always display-only (no auto download/install). + """Auto update-check settings (rants 2026-08-10T07:12:12, 2026-08-12T12:10:12). + + check: master switch — when false, the daemon never queries GitHub + (checking, downloading and prompting are all disabled). + ttl_hours: how often to re-check the latest release (default 1h; + rant 2026-08-12T12:10:12: host 指示 24h → 1h). + auto_download: when a newer version exists, download the current + platform's installer into ~/.emrg/updates/ in the background + (stream + Range resume + SHA256 verify). Installation is ALWAYS + user-initiated — never auto-installed. """ check: bool = True - ttl_hours: int = 24 + ttl_hours: int = 1 + auto_download: bool = True @dataclass @@ -109,7 +115,8 @@ def load_config() -> EmrgConfig: update_data = data.get("update", {}) update = UpdateConfig( check=update_data.get("check", True), - ttl_hours=update_data.get("ttl_hours", 24), + ttl_hours=update_data.get("ttl_hours", 1), + auto_download=update_data.get("auto_download", True), ) return EmrgConfig(llm=llm, update=update) @@ -120,7 +127,7 @@ def load_update_config() -> UpdateConfig: The daemon is constructed with just LlmConfig; this helper lets it read the update-check switch without parsing the full config. Missing config - file or missing section → defaults (check=True, ttl=24h). + file or missing section → defaults (check=True, ttl=1h, auto_download=True). """ cfg_path = config_path() if not cfg_path.exists(): @@ -132,7 +139,8 @@ def load_update_config() -> UpdateConfig: update_data = data.get("update", {}) return UpdateConfig( check=update_data.get("check", True), - ttl_hours=update_data.get("ttl_hours", 24), + ttl_hours=update_data.get("ttl_hours", 1), + auto_download=update_data.get("auto_download", True), ) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index b2a175b..ed380e6 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -636,10 +636,11 @@ vision = false }); ipcMain.handle("emrg:updateCheck", async (_e, { force } = {}) => { - // Auto update-check prompt (rant 2026-08-10T07:12:12): query daemon's - // cached latest release; display-only, no auto download/install. - // force=true (rant 2026-08-11T09:18:16): settings manual check button - // — daemon runs a fresh GitHub fetch instead of returning the cache. + // Auto update-check (rant 2026-08-10T07:12:12): query daemon's cached + // latest release; force=true (rant 2026-08-11T09:18:16): settings + // manual check button — daemon runs a fresh GitHub fetch. + // rant 2026-08-12T12:10:12: response also carries the auto-download + // state (downloaded_version/path/sha) for the "ready to install" UI. try { const frame = await requireConn().sendCommandAndWait( "update_check", @@ -651,6 +652,9 @@ vision = false latest_version: frame.latest_version || "", has_update: Boolean(frame.has_update), prompted_version: frame.prompted_version || "", + downloaded_version: frame.downloaded_version || "", + downloaded_path: frame.downloaded_path || "", + downloaded_sha: frame.downloaded_sha || "", enabled: Boolean(frame.enabled), }; } catch { @@ -658,6 +662,40 @@ vision = false } }); + ipcMain.handle("emrg:updateInstall", async (_e, { path: p, version } = {}) => { + // Auto-update one-click install (rant 2026-08-12T12:10:12): the daemon + // already downloaded + SHA256-verified the installer into + // ~/.emrg/updates/. The user clicked "install" — launch the installer, + // then quit EMRG (the installer stops any remaining EMRG processes + // itself — stop-emrg.cmd / pkg install logic). Install is ALWAYS + // user-initiated; the download itself was automatic. + try { + if (!p || typeof p !== "string") return { ok: false, error: "missing path" }; + if (!fs.existsSync(p)) return { ok: false, error: "downloaded installer not found" }; + const platform = process.platform; + if (platform === "win32") { + // PrivilegesRequired=lowest → no UAC prompt for the exe itself. + const child = spawn(p, [], { detached: true, stdio: "ignore", windowsHide: true }); + child.unref(); + } else if (platform === "darwin") { + // `open ` mounts + launches the Installer.app flow. + const child = spawn("open", [p], { detached: true, stdio: "ignore" }); + child.unref(); + } else if (platform === "linux") { + fs.chmodSync(p, 0o755); + const child = spawn(p, [], { detached: true, stdio: "ignore" }); + child.unref(); + } else { + return { ok: false, error: `unsupported platform: ${platform}` }; + } + // Let the IPC reply flush, then close the GUI — the installer takes over. + setTimeout(() => { try { app.quit(); } catch { /* ignore */ } }, 500); + return { ok: true, version: String(version || "") }; + } catch (err) { + return { ok: false, error: String((err && err.message) || err) }; + } + }); + ipcMain.handle("emrg:updateCheckPrompted", async (_e, { version }) => { // Idempotency (rant 07:12:12 §4): record that the GUI showed the prompt // for this version — same version never re-prompted. diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 0859df3..c10bfc3 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -41,6 +41,7 @@ const api = { githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), updateCheck: (payload) => ipcRenderer.invoke("emrg:updateCheck", payload), updateCheckPrompted: (payload) => ipcRenderer.invoke("emrg:updateCheckPrompted", payload), + updateInstall: (payload) => ipcRenderer.invoke("emrg:updateInstall", payload), githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload), githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"), githubConnectWeb: () => ipcRenderer.invoke("emrg:githubConnectWeb"), diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index fbe240e..cbe56e4 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -1325,6 +1325,13 @@ const App = (() => { } } break; + case "update_downloaded": + // rant 2026-08-12T12:10:12:daemon 后台自动下载 + 校验完新安装包 → + // 非阻塞提示"已就绪,点击安装"(设置 → 关于显示安装按钮) + Chat.addSystemMessage( + _t("app.updateReady", { latest: data.downloaded_version || "" }), + ); + break; case "group_cleared": Chat.groupNodes.delete(data.requestId); break; diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 46b5265..45c556e 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -491,9 +491,12 @@ const Dialogs = (() => { } // Auto update-check prompt (rant 2026-08-10T07:12:12): display-only, one - // line in the about area, never a modal — no auto download/install. + // line in the about area, never a modal. // force (rant 2026-08-11T09:18:16): settings manual check button — run a // fresh GitHub fetch instead of returning the daemon's cached result. + // rant 2026-08-12T12:10:12: when the daemon already auto-downloaded a + // verified installer (downloaded_version), show a one-click install button + // instead of the plain Releases link. // ⚠️ 局部变量命名 updEl(勿用 el——会遮蔽模块级 el() 元素工厂,el("a",…) // 抛 TypeError → catch 吞掉 → 更新行永远 hidden,正是 #602 隐藏缺陷) async function refreshUpdateCheck({ force = false } = {}) { @@ -501,32 +504,69 @@ const Dialogs = (() => { if (!updEl) return; // 元素缺失(测试桩)时忽略 try { const u = await window.emrg.updateCheck({ force }); - if (!u || !u.enabled || !u.has_update || !u.latest_version) { + if (!u || !u.enabled) { updEl.classList.add("hidden"); updEl.textContent = ""; return; } - if (u.latest_version === u.prompted_version) { - updEl.classList.add("hidden"); - updEl.textContent = ""; - return; - } - const link = el("a", { - href: "https://github.com/argszero/emrg/releases", - target: "_blank", - rel: "noopener", - }, _t("settings.updateAvailable", { latest: u.latest_version })); updEl.textContent = ""; - updEl.appendChild(link); - updEl.classList.remove("hidden"); - // 幂等:同版本只提示一次 - try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ } + let shown = false; + // ① 已自动下载 + 校验通过 → 一键安装按钮(rant 2026-08-12T12:10:12) + if (u.downloaded_version && u.downloaded_version !== u.current_version) { + const btn = el("button", { + type: "button", + class: "btn btn-sm btn-primary", + }, _t("settings.updateReady", { latest: u.downloaded_version })); + btn.addEventListener("click", () => showUpdateInstallConfirm(u)); + updEl.appendChild(btn); + shown = true; + } + // ② 有新版但未下载 → Releases 链接(幂等:同版本只提示一次) + if (!shown && u.has_update && u.latest_version && u.latest_version !== u.prompted_version) { + const link = el("a", { + href: "https://github.com/argszero/emrg/releases", + target: "_blank", + rel: "noopener", + }, _t("settings.updateAvailable", { latest: u.latest_version })); + updEl.appendChild(link); + shown = true; + try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ } + } + updEl.classList.toggle("hidden", !shown); } catch { updEl.classList.add("hidden"); updEl.textContent = ""; } } + // Auto-update one-click install (rant 2026-08-12T12:10:12): the daemon + // downloaded + SHA256-verified the installer; the user clicks install → + // confirm (SmartScreen hint on Windows) → launch installer + quit EMRG + // (the installer stops any remaining EMRG processes itself). + function showUpdateInstallConfirm(u) { + const isWin = /win/i.test((navigator.platform || "") + (navigator.userAgent || "")); + showConfirm( + _t("settings.installTitle"), + _t(isWin ? "settings.installConfirmWin" : "settings.installConfirm", { latest: u.downloaded_version || "" }), + { + okText: _t("settings.installNow"), + danger: false, + onOk: async () => { + try { + const res = await window.emrg.updateInstall({ path: u.downloaded_path, version: u.downloaded_version }); + if (res && res.ok) { + Chat.addSystemMessage(_t("settings.installStarted", { latest: u.downloaded_version || "" })); + } else { + Chat.addSystemMessage(_t("settings.installFailed", { msg: (res && res.error) || "" })); + } + } catch { + Chat.addSystemMessage(_t("settings.installFailed", { msg: "" })); + } + }, + }, + ); + } + // 设置页"检查更新"手动按钮(rant 2026-08-11T09:18:16):点击立即强制 // 重新检查(不等 TTL 轮询),检查中显示"检查中…",完成后恢复按钮。 function initUpdateCheckButton() { @@ -547,11 +587,21 @@ const Dialogs = (() => { // 启动主动更新提示(rant 2026-08-11T09:18:16):GUI 启动成功路径调用, // 不依赖打开设置对话框——有新版本且未提示过时输出一条非阻塞系统消息 - // (对齐 TUI 启动 system 行)。boot 时 daemon 可能未就绪 → 静默失败。 + // (对齐 TUI 启动 system 行)。rant 2026-08-12T12:10:12:若 daemon 已 + // 自动下载好安装包,则提示"已就绪,点击安装"。boot 时 daemon 可能未 + // 就绪 → 静默失败。 async function promptUpdateAtStartup() { try { const u = await window.emrg.updateCheck({ force: false }); - if (!u || !u.enabled || !u.has_update || !u.latest_version) return; + if (!u || !u.enabled) return; + // 已自动下载 + 校验通过 → "已就绪,点击安装"(设置 → 关于) + if (u.downloaded_version && u.downloaded_version !== u.current_version) { + Chat.addSystemMessage( + _t("app.updateReady", { latest: u.downloaded_version }), + ); + return; + } + if (!u.has_update || !u.latest_version) return; if (u.latest_version === u.prompted_version) return; Chat.addSystemMessage( _t("app.updateAvailable", { latest: u.latest_version }), @@ -773,6 +823,7 @@ const Dialogs = (() => { refreshUpdateCheck, initUpdateCheckButton, promptUpdateAtStartup, + showUpdateInstallConfirm, // rant 12:10:12:已下载安装包 → 一键安装确认 initOpenSessionDialog, // P5:打开会话对话框初始化 showOpenSessionDialog, // P5:两步打开会话 initNewSessionDialog, // P5 slice 2:新建会话对话框初始化 diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 011e62c..1caf0cc 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -105,6 +105,14 @@ const I18N = (() => { "settings.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装)", "settings.checkUpdate": "检查更新", "settings.checkingUpdate": "检查中…", + "settings.updateReady": "新版本 v{latest} 已就绪 —— 点击安装", + "settings.installNow": "点击安装", + "settings.installTitle": "安装新版本", + "settings.installConfirm": "安装 v{latest}?EMRG 将自动退出,由安装器接管。", + "settings.installConfirmWin": "安装 v{latest}?EMRG 将自动退出。若出现 SmartScreen 提示,点击「更多信息 → 仍要运行」。", + "settings.installStarted": "正在启动安装 v{latest}…", + "settings.installFailed": "安装启动失败:{msg}", + "app.updateReady": "新版本 v{latest} 已下载 —— 设置 → 关于 → 点击安装", "app.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装):https://github.com/argszero/emrg/releases", "settings.githubTokenEmpty": "请先粘贴 GitHub Personal Access Token", "settings.githubConnecting": "连接中…", @@ -449,6 +457,14 @@ const I18N = (() => { "settings.updateAvailable": "New version v{latest} available — click to visit Releases (no auto-install)", "settings.checkUpdate": "Check for updates", "settings.checkingUpdate": "Checking…", + "settings.updateReady": "New version v{latest} ready — click to install", + "settings.installNow": "Install now", + "settings.installTitle": "Install new version", + "settings.installConfirm": "Install v{latest}? EMRG will quit and the installer takes over.", + "settings.installConfirmWin": "Install v{latest}? EMRG will quit. If SmartScreen appears, click 'More info → Run anyway'.", + "settings.installStarted": "Launching installer for v{latest}…", + "settings.installFailed": "Failed to launch installer: {msg}", + "app.updateReady": "New version v{latest} downloaded — install it from Settings → About", "app.updateAvailable": "New version v{latest} available — https://github.com/argszero/emrg/releases (no auto-install)", "settings.githubTokenEmpty": "Please paste a GitHub Personal Access Token first", "settings.githubConnecting": "Connecting…", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 9a09586..8d871d5 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -205,6 +205,7 @@ function makeSandbox(overrides = {}) { closePreview: async () => ({ ok: true }), panelResized: async () => ({ ok: true }), getPreviewState: async () => ({ path: null }), // P2.3:崩溃恢复拉取 + updateInstall: async () => ({ ok: true }), // rant 12:10:一键安装 IPC 默认桩 ...overrides, }, }; @@ -2262,3 +2263,74 @@ test("P2.3:handlePreviewState 幂等——已打开路径仅激活不重复开 assert.strictEqual(els["result-tabbar"].children.length, before, "已打开路径不得重复开 Tab"); assert.ok(calls.previewHtml.length >= 2, "恢复激活应重新 previewHtml(bounds/loadURL 同步)"); }); + +// ── rant 2026-08-12T12:10:12:自动下载 + GUI 一键安装 ── + +test("rant 12:10:已下载安装包 → 设置页一键安装按钮 → 确认 → updateInstall", async () => { + const installCalls = []; + const { ctx, els } = makeSandbox({ + updateCheck: async () => ({ + enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", + current_version: "0.2.27", + downloaded_version: "0.2.99", + downloaded_path: "/home/u/.emrg/updates/EMRG-0.2.99-windows-x64.exe", + }), + updateInstall: async (p) => { installCalls.push(p); return { ok: true }; }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.refreshUpdateCheck()", ctx); + await tick(); + const updEl = els["about-update"]; + assert.strictEqual(updEl.classList.contains("hidden"), false, "update row visible"); + assert.ok(updEl.children.length >= 1, "install button rendered"); + const btn = updEl.children[0]; + assert.ok((btn.textContent || "").includes("0.2.99"), `button text has version: "${btn.textContent}"`); + assert.ok(btn.className.includes("btn"), `button styled as button: "${btn.className}"`); + btn.click(); // → 确认对话框 + await tick(); + assert.strictEqual(els["confirm-dialog"].open, true, "confirm dialog shown"); + assert.ok((els["confirm-message"].textContent || "").includes("0.2.99"), "confirm mentions version"); + await vm.runInContext("EMRG_Dialogs.confirmOk()", ctx); + await tick(); + assert.strictEqual(installCalls.length, 1, "updateInstall called once"); + assert.strictEqual(installCalls[0].path, "/home/u/.emrg/updates/EMRG-0.2.99-windows-x64.exe", "downloaded path passed"); + assert.strictEqual(installCalls[0].version, "0.2.99", "version passed"); +}); + +test("rant 12:10:启动提示——已下载 → 系统消息“已就绪”(非更新链接)", async () => { + const { ctx } = makeSandbox({ + updateCheck: async () => ({ + enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", + current_version: "0.2.27", + downloaded_version: "0.2.99", + downloaded_path: "/home/u/.emrg/updates/EMRG-0.2.99-macos-arm64.pkg", + }), + }); + await tick(); + const r = await vm.runInContext(`(async function() { + await EMRG_Dialogs.promptUpdateAtStartup(); + const texts = []; + for (let i = 0; i < $("chat-view").children.length; i++) texts.push($("chat-view").children[i].textContent); + return texts.join("|"); + })()`, ctx); + assert.ok(r.includes("0.2.99"), `ready message contains version: "${r}"`); + assert.ok(r.includes("已下载") || r.includes("downloaded"), `ready wording: "${r}"`); +}); + +test("rant 12:10:downloaded_version == 当前版本 → 无安装按钮(退化更新链接)", async () => { + const { ctx, els } = makeSandbox({ + updateCheck: async () => ({ + enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", + current_version: "0.2.27", downloaded_version: "0.2.27", + }), + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.refreshUpdateCheck()", ctx); + await tick(); + const updEl = els["about-update"]; + assert.strictEqual(updEl.classList.contains("hidden"), false, "update row visible"); + assert.strictEqual(updEl.children.length, 1, "single element (no install button)"); + const child = updEl.children[0]; + assert.ok(!child.className.includes("btn"), "not a button when downloaded == current"); + assert.ok((child.attributes.href || "").includes("releases"), "falls back to the Releases link"); +}); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index ec9b2aa..755ff08 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -381,13 +381,15 @@ async def _skills_ttl_loop(self) -> None: await asyncio.sleep(_UPDATE_TTL_SECONDS) async def _update_check_loop(self) -> None: - """Background auto update-check prompt (rant 2026-08-10T07:12:12). - - Runs at startup + every [update] ttl_hours (default 24h). ONLY checks - the latest release via api.github.com and persists state to - ~/.emrg/.last_update_check.json — no auto download/install. Failures - are silent (never crash, never log noise); the next TTL retries. - Disabled entirely when [update] check = false in config.toml. + """Background auto update-check + auto-download (rants 07:12:12, 12:10:12). + + Runs at startup + every [update] ttl_hours (default 1h). Checks the + latest release via api.github.com and persists state to + ~/.emrg/.last_update_check.json. When a newer version exists and + [update] auto_download is enabled, the installer is downloaded in a + background task (stream + Range resume + SHA256 verify) — NEVER + auto-installed. Failures are silent (never crash, never log noise); + the next TTL retries. Disabled entirely when [update] check = false. """ from emrg.config import load_update_config from emrg.update_check import ( @@ -401,7 +403,7 @@ async def _update_check_loop(self) -> None: logger.debug("auto update-check disabled by config ([update] check=false)") return - ttl = max(3600, int(update_cfg.ttl_hours or 24) * 3600) + ttl = max(3600, int(update_cfg.ttl_hours or 1) * 3600) while True: state = load_state() if should_check(state, ttl): @@ -410,8 +412,73 @@ async def _update_check_loop(self) -> None: logger.debug( "update check: latest=%s", result.get("latest_version") ) + await self._maybe_auto_download( + result.get("latest_version"), update_cfg.auto_download + ) await asyncio.sleep(ttl) + async def _maybe_auto_download(self, latest_version: str, auto_download: bool) -> None: + """Kick off a background installer download when a newer version exists. + + rant 2026-08-12T12:10:12: auto-download runs in its own task so the + check loop / chat is never blocked. Skipped when auto_download is + disabled, the version is not newer than the running one, or the same + version is already downloaded (and verified). + """ + if not auto_download or not latest_version: + return + import emrg + from emrg.update_check import ( + is_newer, + load_state, + parse_version, + ) + + state = load_state() + current = getattr(emrg, "__version__", "0") + if not is_newer(parse_version(latest_version), parse_version(current)): + return + if state.get("downloaded_version") == latest_version: + return # already downloaded + verified + asyncio.create_task(self._auto_download_update(latest_version)) + + async def _auto_download_update(self, version: str) -> None: + """Background installer download + state persist + client notify. + + Never raises; failures are silent and retried at the next TTL. On + success the downloaded_* fields are persisted to the update state + file and connected clients get an update_downloaded broadcast so the + GUI can show the "ready to install" prompt (rant 2026-08-12T12:10:12). + """ + from emrg.update_check import ( + download_release_asset, + load_state, + save_state, + ) + + try: + result = await download_release_asset(version) + except Exception: + logger.debug("update auto-download failed (retry next TTL)", exc_info=True) + return + if not result: + return # silent — next TTL retries + try: + state = load_state() + state.update(result) + save_state(state) + except Exception: + pass + logger.info( + "update auto-downloaded: %s -> %s", + result.get("downloaded_version"), + result.get("downloaded_path"), + ) + try: + await self._broadcast_all({"type": "update_downloaded", **result}) + except Exception: + pass + def _evolution_count(self) -> int: """Total completed evolution cycles across scheduler handlers + disk. @@ -1471,6 +1538,11 @@ async def _process_message( "latest_version": latest, "has_update": has_update, "prompted_version": state.get("prompted_version") or "", + # rant 2026-08-12T12:10:12: auto-download state — GUI shows the + # "ready to install" button when downloaded_version is newer. + "downloaded_version": state.get("downloaded_version") or "", + "downloaded_path": state.get("downloaded_path") or "", + "downloaded_sha": state.get("downloaded_sha") or "", "enabled": load_update_config().check, }) diff --git a/emrg/update_check.py b/emrg/update_check.py index 6abf12e..07e4374 100644 --- a/emrg/update_check.py +++ b/emrg/update_check.py @@ -1,10 +1,17 @@ -"""Automatic update-check + notify (rant 2026-08-10T07:12:12). - -Design (host-specified boundary): ONLY check for new versions and PROMPT — -never auto-download, never auto-install, never start an installer flow. -The prompt is lightweight and non-intrusive (TUI status line / GUI settings -about area), one prompt per version (idempotent via state file), silent on -network failure (retry at next TTL). +"""Automatic update-check + notify + auto-download (rants 2026-08-10T07:12:12, +2026-08-12T12:10:12). + +Design (host-specified boundaries): +- CHECK: look for new versions on api.github.com (never prereleases). +- PROMPT: one prompt per version, idempotent via state file, silent on + network failure (retry at next TTL). +- DOWNLOAD (rant 2026-08-12T12:10:12): when a newer version is found and + [update] auto_download is enabled, the daemon downloads the current + platform's installer asset in the background — stream + Range resume, + SHA256 verify against the release asset digest, landed in + ~/.emrg/updates/. NEVER auto-installs: the GUI prompts the user and the + user clicks to install. +- [update] check=false disables everything (including download). Check source: api.github.com (github.com:443 / raw.githubusercontent.com are blocked on the host network — see git_utils / skills installer patterns). @@ -12,7 +19,10 @@ from __future__ import annotations +import hashlib import json +import os +import platform import time from pathlib import Path from typing import Optional @@ -21,13 +31,18 @@ from emrg.config import config_dir -# Default TTL between checks (seconds). Host-configurable via [update] ttl_hours. -DEFAULT_TTL_SECONDS = 24 * 3600 +# Default TTL between checks (seconds). Host-configurable via [update] ttl_hours +# (rant 2026-08-12T12:10:12: default 24h → 1h). +DEFAULT_TTL_SECONDS = 3600 # API endpoint — releases/latest never includes prereleases (semver-satisfying). RELEASES_LATEST_URL = "https://api.github.com/repos/argszero/emrg/releases/latest" +# Direct asset download base (no API rate limits on release downloads). +DOWNLOAD_BASE_URL = "https://github.com/argszero/emrg/releases/download" CHECK_TIMEOUT_SECONDS = 10.0 +DOWNLOAD_TIMEOUT_SECONDS = 600.0 STATE_FILE_NAME = ".last_update_check.json" +UPDATES_DIR_NAME = "updates" def parse_version(tag: str) -> tuple: @@ -59,17 +74,25 @@ def is_newer(latest: tuple, current: tuple) -> bool: # ── State file (~/.emrg/.last_update_check.json) ────────────────────────── # {checked_at: float epoch, latest_version: "0.2.18"|None, -# prompted_version: "0.2.18"|None} +# prompted_version: "0.2.18"|None, +# downloaded_version/path/sha: last successful auto-download (rant 12:10:12)} # - checked_at: last successful check timestamp (TTL gate) # - latest_version: last known latest from GitHub # - prompted_version: the version for which a prompt was already shown # (idempotency: same version is only prompted once) +# - downloaded_*: populated by the background auto-download when a new +# installer was fetched + SHA256-verified into ~/.emrg/updates/ def state_path() -> Path: return config_dir() / STATE_FILE_NAME +def updates_dir() -> Path: + """Landing directory for auto-downloaded installers (~/.emrg/updates/).""" + return config_dir() / UPDATES_DIR_NAME + + def load_state() -> dict: try: data = json.loads(state_path().read_text(encoding="utf-8")) @@ -123,14 +146,26 @@ async def check_latest_version(timeout: float = CHECK_TIMEOUT_SECONDS) -> Option Returns the tag_name (e.g. '0.2.18') or None on ANY failure — silent, never raises, never logs noise. The caller retries at the next TTL. """ + release = await fetch_latest_release(timeout) + if release is None: + return None + tag = release.get("tag_name") or "" + return tag.lstrip("v") if tag else None + + +async def fetch_latest_release(timeout: float = CHECK_TIMEOUT_SECONDS) -> Optional[dict]: + """Fetch the full releases/latest JSON (tag + assets + digests). + + Returns None on ANY failure (silent, never raises). The asset digest + field is used by download_release_asset for SHA256 verification. + """ try: async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: resp = await client.get(RELEASES_LATEST_URL) if resp.status_code != 200: return None data = resp.json() - tag = data.get("tag_name") or "" - return tag.lstrip("v") if tag else None + return data if isinstance(data, dict) else None except Exception: return None @@ -148,3 +183,156 @@ async def run_update_check_once(state: Optional[dict] = None) -> dict: state["latest_version"] = latest save_state(state) return {"checked": True, "latest_version": latest, "state": state} + + +# ── Auto-download (rant 2026-08-12T12:10:12) ────────────────────────────── + + +def platform_asset_name(version: str) -> Optional[str]: + """Map the current platform+arch to the make-installer asset name. + + Artifact naming produced by scripts/make-installer (see build-release.yml): + Windows: EMRG--windows-x64.exe + macOS: EMRG--macos-arm64.pkg / -x64.pkg (by machine arch) + Linux: EMRG--linux-x86_64.AppImage / -aarch64.AppImage + Returns None on unsupported platforms — the download is then skipped. + """ + ver = (version or "").lstrip("v") + if not ver: + return None + sysname = platform.system() + machine = (platform.machine() or "").lower() + if sysname == "Windows": + return f"EMRG-{ver}-windows-x64.exe" + if sysname == "Darwin": + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + return f"EMRG-{ver}-macos-{arch}.pkg" + if sysname == "Linux": + arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64" + return f"EMRG-{ver}-linux-{arch}.AppImage" + return None + + +def release_asset_url(version: str, asset_name: str) -> str: + """Direct download URL for a release asset (no API rate limits).""" + return f"{DOWNLOAD_BASE_URL}/v{(version or '').lstrip('v')}/{asset_name}" + + +def asset_sha256(release_data: dict, asset_name: str) -> Optional[str]: + """Extract the asset digest from the GitHub release JSON, if present. + + GitHub exposes `digest` (e.g. "sha256:ab12…") on release assets. Older + API responses may lack it → return None (caller skips verification and + logs — never blocks the download on a missing digest). + """ + if not isinstance(release_data, dict): + return None + for asset in release_data.get("assets") or []: + if not isinstance(asset, dict): + continue + if asset.get("name") != asset_name: + continue + digest = asset.get("digest") or "" + if digest.startswith("sha256:"): + return digest[len("sha256:"):].strip().lower() + return None # asset found but no usable digest → skip verification + return None # asset not in release metadata (should not happen) + + +def sha256_file(path: Path) -> str: + """Hex SHA256 of a file (streamed, memory-safe).""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +async def download_release_asset(version: str, timeout: float = DOWNLOAD_TIMEOUT_SECONDS) -> dict: + """Download the current platform's installer asset into ~/.emrg/updates/. + + Behavior (rant 2026-08-12T12:10:12): + - only the current platform's asset is fetched (platform_asset_name) + - stream + Range header → interrupted downloads resume from the last byte + - SHA256 verified against the release asset digest when available + (digest missing → skip + log, do NOT block); mismatch → delete, retried + at the next TTL + - silent on any failure (never raises) + + Returns a state-update dict on success ({downloaded_version, + downloaded_path, downloaded_sha}) or {} on failure. + """ + asset_name = platform_asset_name(version) + if not asset_name: + return {} + release = await fetch_latest_release() + if release is None: + return {} + digest = asset_sha256(release, asset_name) + + dest_dir = updates_dir() + try: + dest_dir.mkdir(parents=True, exist_ok=True) + except OSError: + return {} + dest = dest_dir / asset_name + part = dest_dir / f"{asset_name}.part" + normalized = (version or "").lstrip("v") + + # Already downloaded + verified → nothing to do. + if dest.exists(): + if digest: + if sha256_file(dest) == digest: + return { + "downloaded_version": normalized, + "downloaded_path": str(dest), + "downloaded_sha": digest, + } + try: + dest.unlink() # tampered → start over + except OSError: + pass + else: + # No digest available — accept the existing file (nothing to + # verify against) and record it. + return { + "downloaded_version": normalized, + "downloaded_path": str(dest), + "downloaded_sha": "", + } + + url = release_asset_url(version, asset_name) + try: + resume_from = part.stat().st_size if part.exists() else 0 + headers = {"Range": f"bytes={resume_from}-"} if resume_from > 0 else {} + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + async with client.stream("GET", url, headers=headers) as resp: + if resp.status_code == 206: + mode = "ab" # partial content → resume appending + elif resp.status_code == 200: + mode = "wb" # server ignored Range → full rewrite + else: + return {} + with open(part, mode) as f: + async for chunk in resp.aiter_bytes(): + f.write(chunk) + except Exception: + return {} # interrupted — .part kept so the next TTL resumes + + sha = sha256_file(part) + if digest and sha != digest: + # verification failure → delete the partial/tampered file, retry next TTL + try: + part.unlink() + except OSError: + pass + return {} + try: + os.replace(part, dest) + except OSError: + return {} + return { + "downloaded_version": normalized, + "downloaded_path": str(dest), + "downloaded_sha": sha, + } diff --git a/tests/test_update_check.py b/tests/test_update_check.py index d0d69fd..217be83 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -19,13 +19,18 @@ from emrg.update_check import ( DEFAULT_TTL_SECONDS, + asset_sha256, check_latest_version, + download_release_asset, is_newer, load_state, mark_prompted, parse_version, + platform_asset_name, + release_asset_url, run_update_check_once, save_state, + sha256_file, should_check, should_prompt, state_path, @@ -228,3 +233,328 @@ async def run(): return True assert asyncio.run(run()) is True + + +# ── auto-download (rant 2026-08-12T12:10:12) ────────────────────────────── + +def test_platform_asset_name_windows(monkeypatch): + monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Windows") + assert platform_asset_name("v0.2.27") == "EMRG-0.2.27-windows-x64.exe" + + +def test_platform_asset_name_macos_arm64(monkeypatch): + monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Darwin") + monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "arm64") + assert platform_asset_name("0.2.27") == "EMRG-0.2.27-macos-arm64.pkg" + + +def test_platform_asset_name_macos_x64(monkeypatch): + monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Darwin") + monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "x86_64") + assert platform_asset_name("0.2.27") == "EMRG-0.2.27-macos-x64.pkg" + + +def test_platform_asset_name_linux(monkeypatch): + monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Linux") + monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "x86_64") + assert platform_asset_name("0.2.27") == "EMRG-0.2.27-linux-x86_64.AppImage" + monkeypatch.setattr("emrg.update_check.platform.machine", lambda: "aarch64") + assert platform_asset_name("0.2.27") == "EMRG-0.2.27-linux-aarch64.AppImage" + + +def test_platform_asset_name_unsupported(monkeypatch): + monkeypatch.setattr("emrg.update_check.platform.system", lambda: "Plan9") + assert platform_asset_name("0.2.27") is None + assert platform_asset_name("") is None + + +def test_release_asset_url(): + assert release_asset_url("v0.2.27", "EMRG-0.2.27-windows-x64.exe") == ( + "https://github.com/argszero/emrg/releases/download/v0.2.27/" + "EMRG-0.2.27-windows-x64.exe" + ) + + +def test_asset_sha256_extracts_digest(): + release = {"assets": [ + {"name": "EMRG-0.2.27-windows-x64.exe", + "digest": "sha256:ffa9c7cc906e049a61e0a2ff7fd0d8365521d1e225af34de8a9bc022d76c11b7"}, + {"name": "other.txt", "digest": "sha256:beef"}, + ]} + assert asset_sha256(release, "EMRG-0.2.27-windows-x64.exe") == ( + "ffa9c7cc906e049a61e0a2ff7fd0d8365521d1e225af34de8a9bc022d76c11b7" + ) + + +def test_asset_sha256_missing_digest_field(): + # asset found but no digest → None (caller skips verification, never blocks) + release = {"assets": [{"name": "x.pkg"}]} + assert asset_sha256(release, "x.pkg") is None + # asset not in metadata → None + assert asset_sha256(release, "nope.pkg") is None + assert asset_sha256(None, "x.pkg") is None + + +def test_sha256_file(tmp_path): + p = tmp_path / "f.bin" + p.write_bytes(b"hello world") + import hashlib + assert sha256_file(p) == hashlib.sha256(b"hello world").hexdigest() + + +class _FakeStream: + """Async context manager mimicking httpx.Response inside client.stream().""" + + def __init__(self, status_code, chunks): + self.status_code = status_code + self._chunks = chunks + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def aiter_bytes(self): + for c in self._chunks: + yield c + + +_ASSET = "EMRG-0.2.99-windows-x64.exe" # pinned via platform_asset_name patch + + +def _release_json(digest=None, asset_name=_ASSET): + assets = [{"name": asset_name, "digest": f"sha256:{digest}" if digest else None}] + return {"tag_name": "v0.2.99", "assets": assets} + + +def _patch_download(tmp_path, monkeypatch, stream_resp, release=None): + """Wire httpx.AsyncClient mocks so download_release_asset is hermetic. + + Returns (client_mock, capture_dict) — capture["headers"] holds the Range + header the download attempted, for resume assertions. + """ + from unittest.mock import MagicMock + + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + release = release if release is not None else _release_json(digest="abcd") + client.get = AsyncMock(return_value=_Resp200(release)) + client.stream = MagicMock(return_value=stream_resp) + capture = {} + + orig_stream = client.stream + + def _wrapped_stream(*args, **kwargs): + capture["headers"] = kwargs.get("headers") or {} + return orig_stream(*args, **kwargs) + + client.stream = _wrapped_stream + + monkeypatch.setattr("emrg.update_check.httpx.AsyncClient", lambda *a, **k: client) + monkeypatch.setattr("emrg.update_check.updates_dir", lambda: tmp_path) + # Pin the platform asset name — tests must not depend on the host platform. + monkeypatch.setattr("emrg.update_check.platform_asset_name", lambda v: _ASSET) + return client, capture + + +class _Resp200: + status_code = 200 + + def __init__(self, data): + self._data = data + + def json(self): + return self._data + + +def test_download_success_verify_skipped_when_no_digest(tmp_path, monkeypatch): + from unittest.mock import MagicMock + + client, capture = _patch_download( + tmp_path, monkeypatch, _FakeStream(200, [b"PK\x03\x04", b"DATA"]), + release=_release_json(digest=None), # no digest → skip verify + ) + result = asyncio.run(download_release_asset("0.2.99")) + assert result["downloaded_version"] == "0.2.99" + assert result["downloaded_path"] == str(tmp_path / "EMRG-0.2.99-windows-x64.exe") + assert result["downloaded_sha"] == sha256_file(tmp_path / "EMRG-0.2.99-windows-x64.exe") + # .part consumed → only the final file remains + assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe.part").exists() + assert capture["headers"] == {}, "no Range header on a fresh download" + + +def test_download_verify_failure_deletes_part(tmp_path, monkeypatch): + client, capture = _patch_download( + tmp_path, monkeypatch, _FakeStream(200, [b"tampered-bytes"]), + release=_release_json(digest="0" * 64), # wrong digest + ) + result = asyncio.run(download_release_asset("0.2.99")) + assert result == {}, "verification failure → {} (retry next TTL)" + assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe").exists() + assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe.part").exists() + + +def test_download_resume_sends_range_and_appends(tmp_path, monkeypatch): + part = tmp_path / "EMRG-0.2.99-windows-x64.exe.part" + part.write_bytes(b"0123456789") + client, capture = _patch_download( + tmp_path, monkeypatch, _FakeStream(206, [b"abcdef"]), + release=_release_json(digest=None), + ) + result = asyncio.run(download_release_asset("0.2.99")) + assert capture["headers"] == {"Range": "bytes=10-"}, "resume sends Range from .part size" + final = tmp_path / "EMRG-0.2.99-windows-x64.exe" + assert final.read_bytes() == b"0123456789abcdef", "206 appends to the partial file" + + +def test_download_already_verified_skips_network(tmp_path, monkeypatch): + from unittest.mock import MagicMock + + dest = tmp_path / "EMRG-0.2.99-windows-x64.exe" + dest.write_bytes(b"good-bytes") + digest = sha256_file(dest) + client, capture = _patch_download( + tmp_path, monkeypatch, _FakeStream(200, [b"never-used"]), + release=_release_json(digest=digest), + ) + result = asyncio.run(download_release_asset("0.2.99")) + assert result["downloaded_version"] == "0.2.99" + assert result["downloaded_sha"] == digest + assert capture == {}, "no network call when the file is already verified" + + +def test_download_http_error_silent(tmp_path, monkeypatch): + client, capture = _patch_download( + tmp_path, monkeypatch, _FakeStream(404, [b""]), + release=_release_json(digest=None), + ) + result = asyncio.run(download_release_asset("0.2.99")) + assert result == {} + assert not (tmp_path / "EMRG-0.2.99-windows-x64.exe").exists() + + +def test_download_network_error_silent_keeps_part(tmp_path, monkeypatch): + from unittest.mock import MagicMock + + part = tmp_path / "EMRG-0.2.99-windows-x64.exe.part" + part.write_bytes(b"partial") + + def _boom(*a, **k): + raise OSError("connection reset") + + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.get = AsyncMock(return_value=_Resp200(_release_json(digest=None))) + client.stream = MagicMock(side_effect=_boom) + monkeypatch.setattr("emrg.update_check.httpx.AsyncClient", lambda *a, **k: client) + monkeypatch.setattr("emrg.update_check.updates_dir", lambda: tmp_path) + + result = asyncio.run(download_release_asset("0.2.99")) + assert result == {}, "network failure → {} (silent)" + assert part.exists(), ".part kept so the next TTL resumes" + + +def test_download_unsupported_platform_skips(tmp_path, monkeypatch): + monkeypatch.setattr( + "emrg.update_check.platform_asset_name", lambda v: None + ) + result = asyncio.run(download_release_asset("0.2.99")) + assert result == {} + + +def test_download_release_fetch_failure_silent(tmp_path, monkeypatch): + from unittest.mock import patch as mpatch + + with mpatch( + "emrg.update_check.fetch_latest_release", + AsyncMock(return_value=None), + ): + result = asyncio.run(download_release_asset("0.2.99")) + assert result == {} + + +# ── config defaults (rant 2026-08-12T12:10:12: ttl 24h → 1h + auto_download) ─ + +def test_update_config_defaults(): + from emrg.config import UpdateConfig + + cfg = UpdateConfig() + assert cfg.check is True + assert cfg.ttl_hours == 1, "default TTL 24h → 1h (rant 2026-08-12T12:10:12)" + assert cfg.auto_download is True + + +def test_load_update_config_defaults_missing_file(tmp_path, monkeypatch): + from emrg import config as config_mod + + monkeypatch.setattr(config_mod, "config_path", lambda: tmp_path / "missing.toml") + cfg = config_mod.load_update_config() + assert cfg.ttl_hours == 1 + assert cfg.auto_download is True + + +def test_load_update_config_parses_auto_download(tmp_path, monkeypatch): + from emrg import config as config_mod + + p = tmp_path / "config.toml" + p.write_text("[update]\ncheck = false\nauto_download = false\n", encoding="utf-8") + monkeypatch.setattr(config_mod, "config_path", lambda: p) + cfg = config_mod.load_update_config() + assert cfg.check is False + assert cfg.auto_download is False + assert cfg.ttl_hours == 1, "unset ttl_hours falls back to the new 1h default" + + +# ── daemon: _maybe_auto_download gating (rant 2026-08-12T12:10:12) ───────── + +def test_maybe_auto_download_disabled_by_config(): + import emrg.server.daemon as daemon_mod + + async def run(): + server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) + with patch("emrg.server.daemon.asyncio.create_task") as m_ct: + await server._maybe_auto_download("0.2.99", False) + return m_ct.call_count + + assert asyncio.run(run()) == 0, "auto_download=false → no download task" + + +def test_maybe_auto_download_skips_when_not_newer(): + import emrg.server.daemon as daemon_mod + + async def run(): + server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) + with patch("emrg.server.daemon.asyncio.create_task") as m_ct: + # running version is 0.2.27 (emrg.__version__); "0.2.20" is older + await server._maybe_auto_download("0.2.20", True) + return m_ct.call_count + + assert asyncio.run(run()) == 0 + + +def test_maybe_auto_download_skips_when_already_downloaded(): + import emrg.server.daemon as daemon_mod + + async def run(): + server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) + with patch("emrg.update_check.load_state", return_value={"downloaded_version": "0.2.99"}): + with patch("emrg.server.daemon.asyncio.create_task") as m_ct: + await server._maybe_auto_download("0.2.99", True) + return m_ct.call_count + + assert asyncio.run(run()) == 0, "same version already downloaded → skip" + + +def test_maybe_auto_download_spawns_task_for_newer(): + import emrg.server.daemon as daemon_mod + + async def run(): + server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer) + with patch("emrg.update_check.load_state", return_value={}): + with patch.object(server, "_auto_download_update", new=AsyncMock()) as m_dl: + await server._maybe_auto_download("0.2.99", True) + await asyncio.sleep(0) # let the spawned task run + return m_dl.await_count + + assert asyncio.run(run()) == 1, "newer version + not downloaded → spawn task"