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);