diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c9d14..937d83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +### 修复 + +- 设置面板内检查到新版本后直接显示「立即更新」按钮,安装完成后可在同一位置重启;此前更新入口只在底部横幅,被设置面板挡住,必须关闭设置才能操作。 + ### 安全 - 自动 Git 提交改用私有索引,并禁用仓库 hooks、fsmonitor、GPG 签名与外部 Markdown filter;共享/网络笔记目录里的仓库配置不能再借保存操作执行命令,也不会覆盖用户自己的暂存区。 diff --git a/index.html b/index.html index 78c1027..29f8340 100644 --- a/index.html +++ b/index.html @@ -228,6 +228,7 @@
当前版本 — +

diff --git a/src/dom.ts b/src/dom.ts index d0b8c48..78217cd 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -58,6 +58,7 @@ export const ui = { syncFootStatus: el("sync-foot-status"), syncConflicts: el("sync-conflicts"), checkUpdateButton: el("check-update"), + updateNowButton: el("update-now"), updateStatus: el("update-status"), appVersionLabel: el("app-version"), updateBanner: el("update-banner"), diff --git a/src/main.dom.test.ts b/src/main.dom.test.ts index fe0d7ac..6885589 100644 --- a/src/main.dom.test.ts +++ b/src/main.dom.test.ts @@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => ({ enable: vi.fn(async () => {}), disable: vi.fn(async () => {}), openDialog: vi.fn(async () => null), - check: vi.fn(async () => null), + check: vi.fn<() => Promise>(async () => null), relaunch: vi.fn(async () => {}), isVisible: vi.fn(async () => false), onFocusChanged: vi.fn(async () => () => {}), @@ -367,6 +367,87 @@ describe("设置", () => { }); }); +describe("更新", () => { + beforeEach(() => { + mocks.check.mockReset(); + mocks.check.mockResolvedValue(null); + mocks.relaunch.mockReset(); + }); + + it("检查到新版本后可在设置面板内直接更新并重启", async () => { + await boot({ flush_sync_state: () => null }); + node("#settings-btn").click(); + await flush(); + + const inline = node("#update-now"); + expect(inline.hidden).toBe(true); + + const downloadAndInstall = vi.fn(async (onEvent: (event: unknown) => void) => { + onEvent({ event: "Started", data: { contentLength: 100 } }); + onEvent({ event: "Progress", data: { chunkLength: 100 } }); + }); + mocks.check.mockResolvedValueOnce({ version: "0.6.0", downloadAndInstall }); + + node("#check-update").click(); + await flush(); + + expect(inline.hidden).toBe(false); + expect(inline.textContent).toBe("立即更新"); + expect(node("#update-status").textContent).toBe("发现新版本 0.6.0"); + + inline.click(); + await flush(); + + expect(downloadAndInstall).toHaveBeenCalledTimes(1); + expect(node("#update-status").textContent).toContain("已安装 0.6.0"); + expect(inline.textContent).toBe("重启"); + + inline.click(); + await flush(); + + expect(mocks.invoke).toHaveBeenCalledWith("flush_sync_state"); + expect(mocks.relaunch).toHaveBeenCalledTimes(1); + }); + + it("已是最新版本时不显示「立即更新」", async () => { + await boot(); + node("#settings-btn").click(); + await flush(); + + node("#check-update").click(); + await flush(); + + expect(node("#update-now").hidden).toBe(true); + expect(node("#update-status").textContent).toBe("已是最新版本"); + }); + + it("安装失败时按钮变成「重试」,再点一次重新下载", async () => { + await boot(); + node("#settings-btn").click(); + await flush(); + + const downloadAndInstall = vi.fn(async () => { + throw new Error("网络中断"); + }); + mocks.check.mockResolvedValueOnce({ version: "0.6.0", downloadAndInstall }); + + node("#check-update").click(); + await flush(); + + const inline = node("#update-now"); + inline.click(); + await flush(); + + expect(inline.textContent).toBe("重试"); + expect(node("#update-status").textContent).toBe("更新失败"); + + inline.click(); + await flush(); + + expect(downloadAndInstall).toHaveBeenCalledTimes(2); + }); +}); + describe("Git 自动提交", () => { async function openGitSettings(): Promise { node("#settings-btn").click(); diff --git a/src/updater.ts b/src/updater.ts index fa1cad0..4149c56 100644 --- a/src/updater.ts +++ b/src/updater.ts @@ -16,6 +16,16 @@ export function hideUpdateBanner(): void { ui.updateBanner.classList.add("hidden"); } +/** + * 设置面板里的更新入口。面板打开时底部的更新横幅会被遮住, + * 手动检查发现新版本后必须有一个能点的按钮。 + */ +function setInlineUpdateAction(label: string | null, disabled = false): void { + ui.updateNowButton.hidden = label === null; + ui.updateNowButton.disabled = disabled; + if (label !== null) ui.updateNowButton.textContent = label; +} + let pendingUpdate: Update | null = null; let updateReady = false; let updating = false; @@ -51,6 +61,7 @@ async function checkForUpdate(manual: boolean): Promise { if (manual) { ui.updateStatus.textContent = "更新已安装,重启后生效"; showUpdateBanner("更新已安装,重启后生效", "重启"); + setInlineUpdateAction("重启"); } return; } @@ -62,6 +73,8 @@ async function checkForUpdate(manual: boolean): Promise { try { const update = await check(); if (!update) { + pendingUpdate = null; + setInlineUpdateAction(null); if (manual) { ui.updateStatus.textContent = "已是最新版本"; toast("已是最新版本"); @@ -72,6 +85,7 @@ async function checkForUpdate(manual: boolean): Promise { pendingUpdate = update; ui.updateStatus.textContent = `发现新版本 ${update.version}`; showUpdateBanner(`发现新版本 ${update.version}`, "更新"); + setInlineUpdateAction("立即更新"); } catch (error) { // 启动时的静默检查失败不打扰用户;手动检查才提示 if (manual) { @@ -88,7 +102,10 @@ async function installUpdate(): Promise { if (!update || updating) return; updating = true; + ui.checkUpdateButton.disabled = true; ui.updateBannerAction.disabled = true; + setInlineUpdateAction("正在更新…", true); + ui.updateStatus.textContent = "正在下载更新…"; showUpdateBanner("正在下载更新…", null); try { @@ -101,47 +118,57 @@ async function installUpdate(): Promise { received += event.data.chunkLength; const percent = total > 0 ? Math.min(100, Math.round((received / total) * 100)) : null; - showUpdateBanner( - percent === null ? "正在下载更新…" : `正在下载更新… ${percent}%`, - null, - ); + const message = + percent === null ? "正在下载更新…" : `正在下载更新… ${percent}%`; + ui.updateStatus.textContent = message; + showUpdateBanner(message, null); } }); updateReady = true; ui.updateStatus.textContent = `已安装 ${update.version},重启后生效`; showUpdateBanner(`新版本 ${update.version} 已安装,重启后生效`, "重启"); + setInlineUpdateAction("重启"); } catch (error) { showUpdateBanner("更新失败,请稍后重试", "重试"); ui.updateStatus.textContent = "更新失败"; + setInlineUpdateAction("重试"); toast(String(error), true); } finally { updating = false; + ui.checkUpdateButton.disabled = false; ui.updateBannerAction.disabled = false; } } +/** 重启前把设置与同步状态落盘,避免更新后重复合并或重复上传。 */ +function restartToApplyUpdate(): void { + // 落盘失败也要重启,但必须告诉用户这次「尽力了」。 + void invoke("flush_sync_state") + .catch(async (error) => { + console.error("保存同步状态失败", error); + toast("同步状态未能落盘,仍将重启", true); + // 留一点时间让提示画出来,否则立刻重启等于没提示。 + await new Promise((resolve) => window.setTimeout(resolve, 1500)); + }) + .finally(() => void relaunch()); +} + /** 模块加载时绑定更新相关按钮。 */ export function initUpdater(): void { ui.checkUpdateButton.addEventListener("click", () => { void checkForUpdate(true); }); + ui.updateNowButton.addEventListener("click", () => { + if (updating) return; + if (updateReady) restartToApplyUpdate(); + else void installUpdate(); + }); + ui.updateBannerAction.addEventListener("click", () => { - if (updateReady) { - // 重启前把设置与同步状态落盘,避免更新后重复合并或重复上传。 - // 落盘失败也要重启,但必须告诉用户这次「尽力了」。 - void invoke("flush_sync_state") - .catch(async (error) => { - console.error("保存同步状态失败", error); - toast("同步状态未能落盘,仍将重启", true); - // 留一点时间让提示画出来,否则立刻重启等于没提示。 - await new Promise((resolve) => window.setTimeout(resolve, 1500)); - }) - .finally(() => void relaunch()); - } else { - void installUpdate(); - } + if (updateReady) restartToApplyUpdate(); + else void installUpdate(); }); ui.updateBannerDismiss.addEventListener("click", hideUpdateBanner);