feat(updater): support ignoring a specific version

This commit is contained in:
Abeautifulsnow
2026-08-11 21:39:44 +08:00
committed by GitHub
parent c6f9ddb5a4
commit 967084c498
16 changed files with 439 additions and 14 deletions
+4
View File
@@ -172,11 +172,13 @@ const {
updateDownloaded,
isInstallingUpdate,
updateReady,
isIgnoringUpdate,
activeTaskCount: activeUpdateTaskCount,
hasUpdateAvailable,
openUrl,
checkUpdates,
openLatestRelease,
ignoreCurrentVersion,
downloadAndInstallUpdate,
cancelDownload,
installDownloadedUpdate,
@@ -2857,12 +2859,14 @@ onUnmounted(() => {
:update-downloaded="updateDownloaded"
:is-installing-update="isInstallingUpdate"
:update-ready="updateReady"
:is-ignoring-update="isIgnoringUpdate"
:active-task-count="activeUpdateTaskCount"
@open-latest-release="openLatestRelease"
@download-and-install="downloadAndInstallUpdate"
@cancel-download="cancelDownload"
@install-downloaded="installDownloadedUpdate"
@restart="restartApp"
@ignore-version="ignoreCurrentVersion"
/>
<ExternalSqlFileChangeDialog :prompt="externalSqlFilePrompt" @decide="externalSqlFileChanges.resolvePrompt" />
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" />
+31 -1
View File
@@ -20,6 +20,7 @@ interface DialogState {
updateDownloaded: boolean;
isInstallingUpdate: boolean;
updateReady: boolean;
isIgnoringUpdate: boolean;
}
async function flushDialog() {
@@ -37,10 +38,12 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
updateDownloaded: false,
isInstallingUpdate: false,
updateReady: false,
isIgnoringUpdate: false,
...initialState,
});
const downloadAndInstall = vi.fn();
const cancelDownload = vi.fn();
const ignoreVersion = vi.fn();
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
@@ -81,10 +84,12 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
updateDownloaded: state.updateDownloaded,
isInstallingUpdate: state.isInstallingUpdate,
updateReady: state.updateReady,
isIgnoringUpdate: state.isIgnoringUpdate,
activeTaskCount,
"onDownload-and-install": downloadAndInstall,
"onCancel-download": cancelDownload,
"onInstall-downloaded": handleInstallDownloaded,
"onIgnore-version": ignoreVersion,
});
},
}),
@@ -94,7 +99,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
app.mount(container);
await flushDialog();
return { state, downloadAndInstall, cancelDownload, installDownloaded };
return { state, downloadAndInstall, cancelDownload, installDownloaded, ignoreVersion };
}
function buttonWithText(text: string): HTMLButtonElement | undefined {
@@ -275,3 +280,28 @@ describe("UpdateDialog close protection", () => {
expect(downloadAndInstall).not.toHaveBeenCalled();
});
});
describe("UpdateDialog ignore version", () => {
it("emits ignore-version when the ignore button is clicked", async () => {
const { ignoreVersion } = await mountDialog(0);
const ignoreButton = buttonWithText("Ignore this version");
expect(ignoreButton).toBeDefined();
ignoreButton?.click();
await flushDialog();
expect(ignoreVersion).toHaveBeenCalledOnce();
});
it("hides the ignore button once an update has been downloaded", async () => {
await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 });
expect(buttonWithText("Ignore this version")).toBeUndefined();
});
it("disables the ignore button while the setting is being persisted", async () => {
await mountDialog(0, { isIgnoringUpdate: true });
expect(buttonWithText("Ignore this version")?.disabled).toBe(true);
});
});
@@ -18,6 +18,7 @@ const props = defineProps<{
updateDownloaded: boolean;
isInstallingUpdate: boolean;
updateReady: boolean;
isIgnoringUpdate: boolean;
activeTaskCount: number;
}>();
@@ -27,6 +28,7 @@ const emit = defineEmits<{
"cancel-download": [];
"install-downloaded": [];
restart: [];
"ignore-version": [];
}>();
const { t } = useI18n();
@@ -35,6 +37,7 @@ const isDesktop = isTauriRuntime();
const renderedNotes = ref("");
// Only active file replacement (installation) must trap the dialog.
const isCloseBlocked = computed(() => props.isInstallingUpdate);
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.updateDownloaded && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
function handleCancel() {
handleOpenChange(false);
@@ -136,6 +139,10 @@ watch(
</div>
<DialogFooter>
<Button v-if="!isCloseBlocked" variant="outline" @click="handleCancel">{{ t("dangerDialog.cancel") }}</Button>
<Button v-if="canIgnoreVersion" variant="ghost" :disabled="isIgnoringUpdate" @click="emit('ignore-version')">
<Loader2 v-if="isIgnoringUpdate" class="h-4 w-4 animate-spin" />
{{ t("updates.ignoreVersion") }}
</Button>
<template v-if="updateInfo?.update_available">
<Button variant="outline" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
@@ -11,16 +11,21 @@ const apiMock = vi.hoisted(() => ({
installDownloadedUpdate: vi.fn<() => Promise<void>>(),
}));
const listenMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/lib/backend/tauriRuntime", () => ({
isTauriRuntime: () => true,
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: vi.fn() }),
useToast: () => ({ toast: toastMock }),
}));
const settingsStoreMock = vi.hoisted(() => ({
editorSettings: { updateDownloadSource: "official", ignoredUpdateVersion: "" },
updateEditorSettingsAndPersist: vi.fn<() => Promise<void>>(),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { updateDownloadSource: "official" } }),
useSettingsStore: () => settingsStoreMock,
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: listenMock,
@@ -47,6 +52,8 @@ let container: HTMLDivElement | undefined;
beforeEach(() => {
vi.clearAllMocks();
settingsStoreMock.editorSettings.ignoredUpdateVersion = "";
settingsStoreMock.updateEditorSettingsAndPersist.mockResolvedValue();
listenMock.mockResolvedValue(vi.fn());
apiMock.installDownloadedUpdate.mockResolvedValue();
});
@@ -116,3 +123,73 @@ describe("useAppUpdater download attempts", () => {
expect(updater.updateReady.value).toBe(true);
});
});
describe("useAppUpdater ignore version", () => {
it("persists the ignored latest version and closes the update dialog", async () => {
let updater!: ReturnType<typeof useAppUpdater>;
container = document.createElement("div");
document.body.append(container);
app = createApp(
defineComponent({
setup() {
updater = useAppUpdater();
return () => h("div");
},
}),
);
app.use(i18n);
app.mount(container);
updater.updateInfo.value = {
current_version: "0.5.69",
latest_version: "0.5.70",
update_available: true,
release_name: "DBX v0.5.70",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
release_notes: "",
};
updater.showUpdateDialog.value = true;
await updater.ignoreCurrentVersion();
expect(settingsStoreMock.updateEditorSettingsAndPersist).toHaveBeenCalledWith({ ignoredUpdateVersion: "0.5.70" });
expect(updater.showUpdateDialog.value).toBe(false);
expect(toastMock).toHaveBeenCalledWith("Version v0.5.70 ignored. You'll be reminded when the next version releases.", 5000);
});
it("keeps the dialog open and allows retry when persistence fails", async () => {
settingsStoreMock.updateEditorSettingsAndPersist.mockRejectedValueOnce(new Error("storage unavailable")).mockResolvedValueOnce();
let updater!: ReturnType<typeof useAppUpdater>;
container = document.createElement("div");
document.body.append(container);
app = createApp(
defineComponent({
setup() {
updater = useAppUpdater();
return () => h("div");
},
}),
);
app.use(i18n);
app.mount(container);
updater.updateInfo.value = {
current_version: "0.5.69",
latest_version: "0.5.70",
update_available: true,
release_name: "DBX v0.5.70",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
release_notes: "",
};
updater.showUpdateDialog.value = true;
await updater.ignoreCurrentVersion();
expect(updater.showUpdateDialog.value).toBe(true);
expect(updater.isIgnoringUpdate.value).toBe(false);
expect(toastMock).toHaveBeenLastCalledWith("Failed to save the ignored version: storage unavailable", 5000);
await updater.ignoreCurrentVersion();
expect(settingsStoreMock.updateEditorSettingsAndPersist).toHaveBeenCalledTimes(2);
expect(updater.showUpdateDialog.value).toBe(false);
});
});
+75 -1
View File
@@ -22,6 +22,62 @@ export function canDownloadAndInstallUpdate(info: api.UpdateInfo | null, isDeskt
return isDesktop && info?.update_available === true && info.manual_update_only !== true;
}
interface ParsedUpdateVersion {
core: [string, string, string];
prerelease: string[];
}
function normalizeUpdateVersion(version: string): string {
return version.trim().replace(/^[vV]/, "");
}
function parseUpdateVersion(version: string): ParsedUpdateVersion | null {
const match = normalizeUpdateVersion(version).match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
if (!match) return null;
return {
core: [match[1], match[2], match[3]],
prerelease: match[4]?.split(".") ?? [],
};
}
function compareNumericVersionIdentifier(left: string, right: string): number {
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
return left === right ? 0 : left < right ? -1 : 1;
}
function compareParsedUpdateVersions(left: ParsedUpdateVersion, right: ParsedUpdateVersion): number {
for (let index = 0; index < left.core.length; index += 1) {
const comparison = compareNumericVersionIdentifier(left.core[index], right.core[index]);
if (comparison !== 0) return comparison;
}
if (!left.prerelease.length || !right.prerelease.length) {
if (left.prerelease.length === right.prerelease.length) return 0;
return left.prerelease.length ? -1 : 1;
}
const length = Math.max(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < length; index += 1) {
const leftIdentifier = left.prerelease[index];
const rightIdentifier = right.prerelease[index];
if (leftIdentifier === undefined || rightIdentifier === undefined) return leftIdentifier === undefined ? -1 : 1;
if (leftIdentifier === rightIdentifier) continue;
const leftNumeric = /^\d+$/.test(leftIdentifier);
const rightNumeric = /^\d+$/.test(rightIdentifier);
if (leftNumeric && rightNumeric) return compareNumericVersionIdentifier(leftIdentifier, rightIdentifier);
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
return leftIdentifier < rightIdentifier ? -1 : 1;
}
return 0;
}
export function isUpdateIgnored(info: api.UpdateInfo | null, ignoredVersion: string | undefined): boolean {
const latest = info?.latest_version;
if (!latest || !ignoredVersion) return false;
const parsedLatest = parseUpdateVersion(latest);
const parsedIgnored = parseUpdateVersion(ignoredVersion);
if (!parsedLatest || !parsedIgnored) return normalizeUpdateVersion(latest) === normalizeUpdateVersion(ignoredVersion);
return compareParsedUpdateVersions(parsedLatest, parsedIgnored) <= 0;
}
export function normalizeUpdateDownloadSource(value: unknown): SettingsUpdateDownloadSource {
// Old persisted AtomGit preferences should retain their mainland mirror behavior.
if (value === "atomgit") return "cnb";
@@ -65,8 +121,9 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
const updateDownloaded = ref(false);
const isInstallingUpdate = ref(false);
const updateReady = ref(false);
const isIgnoringUpdate = ref(false);
const activeTaskCount = computed(() => Math.max(0, Math.trunc(options.getActiveTaskCount?.() ?? 0)));
const hasUpdateAvailable = computed(() => updateInfo.value?.update_available === true);
const hasUpdateAvailable = computed(() => updateInfo.value?.update_available === true && !isUpdateIgnored(updateInfo.value, settingsStore.editorSettings.ignoredUpdateVersion));
const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
let activeDownloadAttempt = 0;
let pendingCancellation: Promise<void> | undefined;
@@ -123,6 +180,21 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
openUrl(url);
}
async function ignoreCurrentVersion() {
const latest = updateInfo.value?.latest_version;
if (!latest || isIgnoringUpdate.value) return;
isIgnoringUpdate.value = true;
try {
await settingsStore.updateEditorSettingsAndPersist({ ignoredUpdateVersion: latest });
showUpdateDialog.value = false;
toast(t("updates.versionIgnored", { version: tagVersion(latest) }), 5000);
} catch (error) {
toast(t("updates.ignoreVersionFailed", { error: error instanceof Error ? error.message : String(error) }), 5000);
} finally {
isIgnoringUpdate.value = false;
}
}
function blockUpdateForActiveTasks(): boolean {
if (!shouldBlockAppUpdate(activeTaskCount.value)) return false;
toast(t("updates.activeTasksBlockUpdate", { count: activeTaskCount.value }), 5000);
@@ -250,6 +322,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
updateDownloaded,
isInstallingUpdate,
updateReady,
isIgnoringUpdate,
activeTaskCount,
hasUpdateAvailable,
latestReleaseUrl,
@@ -257,6 +330,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
checkUpdates,
formatUpdateError,
openLatestRelease,
ignoreCurrentVersion,
downloadAndInstallUpdate,
cancelDownload,
installDownloadedUpdate,
+3
View File
@@ -284,6 +284,9 @@ export default {
failed: "Failed to check updates: {error}",
rateLimited: "GitHub update checks are temporarily rate limited. You can still open the release page to check manually.",
openRelease: "Open Release",
ignoreVersion: "Ignore this version",
versionIgnored: "Version {version} ignored. You'll be reminded when the next version releases.",
ignoreVersionFailed: "Failed to save the ignored version: {error}",
downloadAndInstall: "Download & Install",
activeTasksBlockUpdate: "{count} task(s) are still running. Wait for them to finish before updating DBX.",
portableAutomaticUpdate: "DBX will download the signed portable ZIP, replace only DBX.exe after exit, and restart automatically. portable.dbx and data will be kept.",
+3
View File
@@ -285,6 +285,9 @@ export default withEnglishFallback({
failed: "Error al buscar actualizaciones: {error}",
rateLimited: "Las verificaciones de actualizaciones de GitHub están temporalmente limitadas. Puedes abrir la página de lanzamientos para verificar manualmente.",
openRelease: "Abrir lanzamiento",
ignoreVersion: "Ignorar esta versión",
versionIgnored: "Versión {version} ignorada. Se te recordará cuando se publique la próxima versión.",
ignoreVersionFailed: "No se pudo guardar la versión ignorada: {error}",
downloadAndInstall: "Descargar e instalar",
activeTasksBlockUpdate: "Hay {count} tarea(s) en ejecución. Espera a que terminen antes de actualizar DBX.",
portableAutomaticUpdate: "DBX descargará el ZIP portable firmado, reemplazará solo DBX.exe después de salir y se reiniciará automáticamente. portable.dbx y data se conservarán.",
+3
View File
@@ -284,6 +284,9 @@ export default withEnglishFallback({
failed: "Verifica aggiornamenti non riuscita: {error}",
rateLimited: "Le verifiche degli aggiornamenti di GitHub sono temporaneamente limitate. Puoi comunque aprire la pagina delle release per verificare manualmente.",
openRelease: "Apri Release",
ignoreVersion: "Ignora questa versione",
versionIgnored: "Versione {version} ignorata. Verrai avvisato al rilascio della prossima versione.",
ignoreVersionFailed: "Impossibile salvare la versione ignorata: {error}",
downloadAndInstall: "Scarica e Installa",
activeTasksBlockUpdate: "Ci sono {count} attività in esecuzione. Attendi che terminino prima di aggiornare DBX.",
portableAutomaticUpdate: "DBX scaricherà lo ZIP portatile firmato, sostituirà solo DBX.exe dopo l'uscita e si riavvierà automaticamente. portable.dbx e i dati verranno mantenuti.",
+3
View File
@@ -285,6 +285,9 @@ export default withEnglishFallback({
failed: "アップデートの確認に失敗しました: {error}",
rateLimited: "GitHubのアップデート確認が一時的に制限されています。リリースページで手動確認できます。",
openRelease: "リリースページを開く",
ignoreVersion: "このバージョンを無視",
versionIgnored: "バージョン {version} を無視しました。次のバージョンがリリースされたときに再度通知します。",
ignoreVersionFailed: "無視するバージョンを保存できませんでした: {error}",
downloadAndInstall: "ダウンロード & インストール",
activeTasksBlockUpdate: "{count} 件のタスクが実行中です。完了してから DBX を更新してください。",
portableAutomaticUpdate: "DBX は署名済みのポータブル ZIP をダウンロードし、終了後に DBX.exe のみを置き換えて自動的に再起動します。portable.dbx とデータは保持されます。",
+3
View File
@@ -284,6 +284,9 @@ export default withEnglishFallback({
failed: "업데이트 확인에 실패했습니다: {error}",
rateLimited: "GitHub 업데이트 확인이 일시적으로 제한되었습니다. 릴리스 페이지를 직접 열어 수동으로 확인할 수 있습니다.",
openRelease: "릴리스 열기",
ignoreVersion: "이 버전 무시",
versionIgnored: "{version} 버전을 무시했습니다. 다음 버전이 출시되면 다시 알려드립니다.",
ignoreVersionFailed: "무시할 버전을 저장하지 못했습니다: {error}",
downloadAndInstall: "다운로드 및 설치",
activeTasksBlockUpdate: "{count}개의 작업이 아직 실행 중입니다. DBX를 업데이트하려면 작업이 완료될 때까지 기다려 주세요.",
portableAutomaticUpdate: "DBX가 서명된 휴대용 ZIP을 다운로드하여 종료 후 DBX.exe만 교체하고 자동으로 다시 시작합니다. portable.dbx와 데이터는 보존됩니다.",
+3
View File
@@ -285,6 +285,9 @@ export default withEnglishFallback({
failed: "Falha ao verificar atualizações: {error}",
rateLimited: "As verificações de atualização do GitHub estão temporariamente limitadas. Você ainda pode abrir a página de lançamento para verificar manualmente.",
openRelease: "Abrir Lançamento",
ignoreVersion: "Ignorar esta versão",
versionIgnored: "Versão {version} ignorada. Você será lembrado quando a próxima versão for lançada.",
ignoreVersionFailed: "Não foi possível salvar a versão ignorada: {error}",
downloadAndInstall: "Baixar e Instalar",
activeTasksBlockUpdate: "Há {count} tarefa(s) em execução. Aguarde a conclusão antes de atualizar o DBX.",
portableAutomaticUpdate: "O DBX baixará o ZIP portátil assinado, substituirá apenas o DBX.exe após sair e reiniciará automaticamente. O portable.dbx e os dados serão mantidos.",
+3
View File
@@ -210,6 +210,9 @@ export default withEnglishFallback({
failed: "检查更新失败:{error}",
rateLimited: "GitHub 更新检查暂时触发频率限制。你仍然可以打开下载页手动查看。",
openRelease: "打开下载页",
ignoreVersion: "忽略此版本",
versionIgnored: "已忽略 {version},将在下个版本发布时再次提醒。",
ignoreVersionFailed: "保存忽略版本失败:{error}",
downloadAndInstall: "下载并安装",
activeTasksBlockUpdate: "有 {count} 个任务正在执行,请等待任务完成后再更新 DBX。",
portableAutomaticUpdate: "DBX 将下载已签名的便携版 ZIP,退出后仅替换 DBX.exe 并自动重启。portable.dbx 和 data 会保留。",
+3
View File
@@ -285,6 +285,9 @@ export default withEnglishFallback({
failed: "檢查更新失敗:{error}",
rateLimited: "GitHub 更新檢查暫時觸發頻率限制。你仍然可以開啟下載頁手動檢視。",
openRelease: "開啟下載頁",
ignoreVersion: "忽略此版本",
versionIgnored: "已忽略 {version},將在下個版本發布時再次提醒。",
ignoreVersionFailed: "儲存忽略版本失敗:{error}",
downloadAndInstall: "下載並安裝",
activeTasksBlockUpdate: "有 {count} 個任務正在執行,請等待任務完成後再更新 DBX。",
portableAutomaticUpdate: "DBX 將下載已簽署的可攜版 ZIP,結束後僅替換 DBX.exe 並自動重新啟動。portable.dbx 和 data 會保留。",
+118
View File
@@ -667,6 +667,124 @@ describe("settingsStore persisted settings initialization", () => {
});
});
describe("settingsStore editor settings persistence", () => {
beforeEach(() => {
vi.resetModules();
setActivePinia(createPinia());
});
it("rolls back a failed atomic update and allows retry", async () => {
const loadEditorSettings = vi.fn().mockResolvedValue({
ignoredUpdateVersion: "",
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
});
const saveEditorSettings = vi.fn().mockRejectedValueOnce(new Error("save failed")).mockResolvedValueOnce(undefined);
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initEditorSettings();
await expect(store.updateEditorSettingsAndPersist({ ignoredUpdateVersion: "0.5.70" })).rejects.toThrow("save failed");
expect(store.editorSettings.ignoredUpdateVersion).toBe("");
await store.updateEditorSettingsAndPersist({ ignoredUpdateVersion: "0.5.70" });
expect(store.editorSettings.ignoredUpdateVersion).toBe("0.5.70");
expect(saveEditorSettings).toHaveBeenCalledTimes(2);
expect(saveEditorSettings).toHaveBeenLastCalledWith(expect.objectContaining({ ignoredUpdateVersion: "0.5.70" }));
});
it("loads the persisted ignored version in a new store instance", async () => {
let persistedSettings: Record<string, unknown> = {
ignoredUpdateVersion: "",
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
};
const loadEditorSettings = vi.fn(async () => JSON.parse(JSON.stringify(persistedSettings)));
const saveEditorSettings = vi.fn(async (settings: Record<string, unknown>) => {
persistedSettings = JSON.parse(JSON.stringify(settings));
});
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const firstStore = useSettingsStore();
await firstStore.initEditorSettings();
await firstStore.updateEditorSettingsAndPersist({ ignoredUpdateVersion: "0.5.70" });
setActivePinia(createPinia());
const restartedStore = useSettingsStore();
await restartedStore.initEditorSettings();
expect(restartedStore.editorSettings.ignoredUpdateVersion).toBe("0.5.70");
});
it("serializes overlapping saves so an older snapshot cannot finish last", async () => {
let resolveFirstSave!: () => void;
const loadEditorSettings = vi.fn().mockResolvedValue({
ignoredUpdateVersion: "",
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
});
const saveEditorSettings = vi.fn().mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirstSave = resolve;
}),
);
saveEditorSettings.mockResolvedValueOnce(undefined);
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initEditorSettings();
const firstSave = store.updateEditorSettingsAndPersist({ ignoredUpdateVersion: "0.5.70" });
await vi.waitFor(() => expect(saveEditorSettings).toHaveBeenCalledOnce());
const secondSave = store.updateEditorSettingsAndPersist({ ignoredUpdateVersion: "0.5.71" });
expect(saveEditorSettings).toHaveBeenCalledOnce();
expect(store.editorSettings.ignoredUpdateVersion).toBe("0.5.70");
resolveFirstSave();
await Promise.all([firstSave, secondSave]);
expect(store.editorSettings.ignoredUpdateVersion).toBe("0.5.71");
expect(saveEditorSettings).toHaveBeenCalledTimes(2);
expect(saveEditorSettings.mock.calls[0][0]).toEqual(expect.objectContaining({ ignoredUpdateVersion: "0.5.70" }));
expect(saveEditorSettings.mock.calls[1][0]).toEqual(expect.objectContaining({ ignoredUpdateVersion: "0.5.71" }));
});
it("does not carry a failed atomic value into an already queued unrelated save", async () => {
let rejectFirstSave!: (error: Error) => void;
const loadEditorSettings = vi.fn().mockResolvedValue({
ignoredUpdateVersion: "",
theme: "system",
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
});
const saveEditorSettings = vi.fn().mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
rejectFirstSave = reject;
}),
);
saveEditorSettings.mockResolvedValueOnce(undefined);
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initEditorSettings();
const ignoredVersionSave = store.updateEditorSettingsAndPersist({ ignoredUpdateVersion: "0.5.70" });
await vi.waitFor(() => expect(saveEditorSettings).toHaveBeenCalledOnce());
store.updateEditorSettings({ theme: "xcode-dark" });
rejectFirstSave(new Error("save failed"));
await expect(ignoredVersionSave).rejects.toThrow("save failed");
await vi.waitFor(() => expect(saveEditorSettings).toHaveBeenCalledTimes(2));
expect(store.editorSettings).toMatchObject({ ignoredUpdateVersion: "", theme: "xcode-dark" });
expect(saveEditorSettings.mock.calls[1][0]).toEqual(expect.objectContaining({ ignoredUpdateVersion: "", theme: "xcode-dark" }));
});
});
describe("settingsStore sidebar connection sort persistence", () => {
beforeEach(() => {
vi.resetModules();
+65 -9
View File
@@ -590,6 +590,7 @@ export interface EditorSettings {
exportRowLimit: number;
queryExportKeysetOptimizationEnabled: boolean;
updateDownloadSource: UpdateDownloadSource;
ignoredUpdateVersion: string;
toolbarItems: ToolbarItems;
objectBrowserShowCheckbox: boolean;
objectBrowserViewMode: "list" | "grid";
@@ -779,6 +780,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
exportRowLimit: 100000,
queryExportKeysetOptimizationEnabled: true,
updateDownloadSource: "official",
ignoredUpdateVersion: "",
toolbarItems: { ...DEFAULT_TOOLBAR_ITEMS },
objectBrowserShowCheckbox: false,
objectBrowserViewMode: "list",
@@ -1153,6 +1155,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit,
queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled,
updateDownloadSource: normalizeUpdateDownloadSource(settings.updateDownloadSource),
ignoredUpdateVersion: typeof settings.ignoredUpdateVersion === "string" ? settings.ignoredUpdateVersion : DEFAULT_EDITOR_SETTINGS.ignoredUpdateVersion,
toolbarItems: normalizeToolbarItems(settings.toolbarItems),
objectBrowserShowCheckbox: typeof settings.objectBrowserShowCheckbox === "boolean" ? settings.objectBrowserShowCheckbox : DEFAULT_EDITOR_SETTINGS.objectBrowserShowCheckbox,
objectBrowserViewMode: settings.objectBrowserViewMode === "grid" ? "grid" : DEFAULT_EDITOR_SETTINGS.objectBrowserViewMode,
@@ -1197,10 +1200,6 @@ function editorSettingsPatchSnapshot(settings: Partial<EditorSettings>): Partial
return JSON.parse(JSON.stringify(settings)) as Partial<EditorSettings>;
}
function saveEditorSettings(settings: EditorSettings) {
void api.saveEditorSettings(editorSettingsSnapshot(settings)).catch(() => {});
}
export interface SettingsNavigationRequest {
id: number;
tab: string;
@@ -1224,11 +1223,37 @@ export const useSettingsStore = defineStore("settings", () => {
const isEditorSettingsLoaded = ref(false);
let initEditorSettingsPromise: Promise<void> | null = null;
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
let editorSettingsSaveQueue: Promise<void> | null = null;
let editorSettingsAtomicUpdateQueue: Promise<void> = Promise.resolve();
let editorSettingsPatchRevision = 0;
const editorSettingsFieldRevisions = new Map<keyof EditorSettings, number>();
let pendingAiChatSelection: AiChatSelectionState | null = null;
let aiChatSelectionSaveRunning = false;
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
function enqueueEditorSettingsSave(): Promise<void> {
const saveCurrentSettings = () => api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
const save = editorSettingsSaveQueue ? editorSettingsSaveQueue.catch(() => {}).then(saveCurrentSettings) : saveCurrentSettings();
const trackedSave = save.finally(() => {
if (editorSettingsSaveQueue === trackedSave) editorSettingsSaveQueue = null;
});
editorSettingsSaveQueue = trackedSave;
return trackedSave;
}
function saveEditorSettings() {
void enqueueEditorSettingsSave().catch(() => {});
}
function markEditorSettingsPatch(partial: Partial<EditorSettings>): number {
const revision = ++editorSettingsPatchRevision;
for (const key of Object.keys(partial) as (keyof EditorSettings)[]) {
if (partial[key] !== undefined) editorSettingsFieldRevisions.set(key, revision);
}
return revision;
}
function requestSettingsNavigation(tab: string, section?: string) {
settingsNavigationRequest.value = {
id: Date.now(),
@@ -1246,7 +1271,7 @@ export const useSettingsStore = defineStore("settings", () => {
pendingEditorSettingsPatches = [];
for (const patch of pendingPatches) applyEditorSettingsPatch(patch);
isEditorSettingsLoaded.value = true;
if (pendingPatches.length) saveEditorSettings(editorSettings.value);
if (pendingPatches.length) saveEditorSettings();
}
async function initEditorSettings() {
@@ -1265,7 +1290,7 @@ export const useSettingsStore = defineStore("settings", () => {
const savedUpdateDownloadSource = (saved as { updateDownloadSource?: unknown }).updateDownloadSource;
if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration) {
// Persist one-time migrations so removed or unsafe defaults cannot reappear.
await api.saveEditorSettings(normalized).catch(() => {});
await enqueueEditorSettingsSave().catch(() => {});
}
completeEditorSettingsInitialization();
return;
@@ -1275,7 +1300,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (legacy) {
editorSettings.value = legacy;
try {
await api.saveEditorSettings(legacy);
await enqueueEditorSettingsSave();
// Existing desktop users keep settings in localStorage; remove them only
// after the async store has accepted the migrated value.
clearLegacyEditorSettings();
@@ -1666,6 +1691,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit)));
if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;
if (partial.updateDownloadSource !== undefined) editorSettings.value.updateDownloadSource = normalizeUpdateDownloadSource(partial.updateDownloadSource);
if (partial.ignoredUpdateVersion !== undefined) editorSettings.value.ignoredUpdateVersion = typeof partial.ignoredUpdateVersion === "string" ? partial.ignoredUpdateVersion : "";
if (partial.toolbarItems !== undefined) editorSettings.value.toolbarItems = normalizeToolbarItems(partial.toolbarItems);
if (partial.objectBrowserShowCheckbox !== undefined) editorSettings.value.objectBrowserShowCheckbox = partial.objectBrowserShowCheckbox === true;
if (partial.objectBrowserViewMode !== undefined) editorSettings.value.objectBrowserViewMode = partial.objectBrowserViewMode === "grid" ? "grid" : "list";
@@ -1677,16 +1703,45 @@ export const useSettingsStore = defineStore("settings", () => {
function updateEditorSettings(partial: Partial<EditorSettings>) {
applyEditorSettingsPatch(partial);
markEditorSettingsPatch(partial);
if (!isEditorSettingsLoaded.value) {
pendingEditorSettingsPatches.push(editorSettingsPatchSnapshot(partial));
return;
}
saveEditorSettings(editorSettings.value);
saveEditorSettings();
}
async function persistEditorSettings(): Promise<void> {
await initEditorSettings();
await api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
await enqueueEditorSettingsSave();
}
function updateEditorSettingsAndPersist(partial: Partial<EditorSettings>): Promise<void> {
const update = editorSettingsAtomicUpdateQueue
.catch(() => {})
.then(async () => {
await initEditorSettings();
const previous = editorSettingsSnapshot(editorSettings.value);
applyEditorSettingsPatch(partial);
const revision = markEditorSettingsPatch(partial);
try {
await enqueueEditorSettingsSave();
} catch (error) {
const restored = editorSettingsSnapshot(editorSettings.value) as unknown as Record<string, unknown>;
const previousSettings = previous as unknown as Record<string, unknown>;
let changed = false;
for (const key of Object.keys(partial) as (keyof EditorSettings)[]) {
if (partial[key] === undefined || editorSettingsFieldRevisions.get(key) !== revision) continue;
restored[key] = previousSettings[key];
editorSettingsFieldRevisions.set(key, ++editorSettingsPatchRevision);
changed = true;
}
if (changed) editorSettings.value = normalizeEditorSettings(restored);
throw error;
}
});
editorSettingsAtomicUpdateQueue = update.catch(() => {});
return update;
}
function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) {
@@ -1752,6 +1807,7 @@ export const useSettingsStore = defineStore("settings", () => {
mcpGlobalPolicy,
initEditorSettings,
updateEditorSettings,
updateEditorSettingsAndPersist,
persistEditorSettings,
initDesktopSettings,
updateDesktopSettings,
+36 -1
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { canDownloadAndInstallUpdate, normalizeUpdateDownloadSource, resolveUpdateReleaseUrl, tagVersion } from "../../apps/desktop/src/composables/useAppUpdater.ts";
import { canDownloadAndInstallUpdate, isUpdateIgnored, normalizeUpdateDownloadSource, resolveUpdateReleaseUrl, tagVersion } from "../../apps/desktop/src/composables/useAppUpdater.ts";
import { downloadAndInstallUpdateWhenIdle, installDownloadedUpdateWhenIdle } from "../../apps/desktop/src/lib/app/appUpdateInstallFlow.ts";
import { countActiveUpdateBlockingTasks, shouldBlockAppUpdate } from "../../apps/desktop/src/lib/app/appUpdateTaskGuard.ts";
import type { UpdateInfo } from "../../apps/desktop/src/lib/backend/api.ts";
@@ -34,6 +34,39 @@ test("blocks in-app update installation outside desktop runtime or without an up
assert.equal(canDownloadAndInstallUpdate(null, true), false);
});
test("keeps the ignored state for the same or an older stable version", () => {
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "0.5.26" }), "0.5.26"), true);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "0.5.25" }), "0.5.26"), true);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "0.5.27" }), "0.5.26"), false);
});
test("keeps the ignored state when an update source lags behind", () => {
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "0.5.69" }), "0.5.70"), true);
});
test("does not ignore updates when the stored version is absent", () => {
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "0.5.26" }), ""), false);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "0.5.26" }), undefined), false);
assert.equal(isUpdateIgnored(null, "0.5.26"), false);
});
test("normalizes v-prefixed ignored versions before comparing", () => {
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "0.5.26" }), "v0.5.26"), true);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: " v0.5.26 " }), " 0.5.26 "), true);
});
test("uses SemVer precedence for prereleases and ignores build metadata", () => {
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "1.0.0-beta.1" }), "1.0.0-beta.2"), true);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "1.0.0-beta.11" }), "1.0.0-beta.2"), false);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "1.0.0" }), "1.0.0-rc.1"), false);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "1.0.0+mirror.2" }), "1.0.0+github.1"), true);
});
test("falls back conservatively when either version is not valid SemVer", () => {
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "release-next" }), "vrelease-next"), true);
assert.equal(isUpdateIgnored(updateInfo({ latest_version: "release-next" }), "release-later"), false);
});
test("normalizes update download source", () => {
assert.equal(normalizeUpdateDownloadSource("official"), "official");
assert.equal(normalizeUpdateDownloadSource("cnb"), "cnb");
@@ -104,4 +137,6 @@ test("wires the active task guard into update installation and restart", () => {
assert.equal(updaterSource.match(/if \(blockUpdateForActiveTasks\(\)\) return;/g)?.length, 2);
assert.match(dialogSource, /role="alert"[\s\S]*updates\.activeTasksBlockUpdate/);
assert.equal(dialogSource.match(/:disabled="activeTaskCount > 0"/g)?.length, 3);
assert.match(dialogSource, /ignore-version/);
assert.match(updaterSource, /ignoreCurrentVersion/);
});