diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index c3fc94e..9a1a980 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -38,6 +38,7 @@ import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session import { MegaWebFallback } from "./mega-web-fallback"; import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; +import { runInstallWithResume } from "./update-install-flow"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { encryptBackup, decryptBackup } from "./backup-crypto"; import { buildBackupPayload, planBackupImport } from "./backup-payload"; @@ -459,16 +460,14 @@ public async checkDebridAccounts(): Promise { } public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise { - if (this.manager.isSessionRunning()) { - this.manager.stop({ parkForRestart: true }); - } - this.manager.persistNowSync(); - const cacheAgeMs = Date.now() - this.lastUpdateCheckAt; const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000 ? this.lastUpdateCheck : undefined; - const result = await installLatestUpdate(this.settings.updateRepo, cached, onProgress); + const result = await runInstallWithResume( + this.manager, + () => installLatestUpdate(this.settings.updateRepo, cached, onProgress) + ); if (result.started) { this.lastUpdateCheck = null; this.lastUpdateCheckAt = 0; diff --git a/src/main/update-install-flow.ts b/src/main/update-install-flow.ts new file mode 100644 index 0000000..c2c626e --- /dev/null +++ b/src/main/update-install-flow.ts @@ -0,0 +1,34 @@ +export interface InstallResumeManager { + isSessionRunning(): boolean; + stop(options: { parkForRestart: boolean }): void; + persistNowSync(): void; + start(): Promise | void; +} + +export async function runInstallWithResume( + manager: InstallResumeManager, + doInstall: () => Promise +): Promise { + const wasRunning = manager.isSessionRunning(); + if (wasRunning) { + manager.stop({ parkForRestart: true }); + } + manager.persistNowSync(); + + const resumeIfParked = async (): Promise => { + if (wasRunning && !manager.isSessionRunning()) { + await manager.start(); + } + }; + + try { + const result = await doInstall(); + if (!result.started) { + await resumeIfParked(); + } + return result; + } catch (error) { + await resumeIfParked(); + throw error; + } +} diff --git a/tests/update-install-flow.test.ts b/tests/update-install-flow.test.ts new file mode 100644 index 0000000..36dd959 --- /dev/null +++ b/tests/update-install-flow.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { runInstallWithResume, InstallResumeManager } from "../src/main/update-install-flow"; + +function makeManager(running: boolean): InstallResumeManager & { startCalls: number; stopCalls: number; persistCalls: number; sessionRunning: boolean } { + return { + sessionRunning: running, + startCalls: 0, + stopCalls: 0, + persistCalls: 0, + isSessionRunning() { + return this.sessionRunning; + }, + stop() { + this.stopCalls += 1; + this.sessionRunning = false; + }, + persistNowSync() { + this.persistCalls += 1; + }, + async start() { + this.startCalls += 1; + this.sessionRunning = true; + } + }; +} + +describe("runInstallWithResume", () => { + it("resumes a running session when the install returns started:false", async () => { + const m = makeManager(true); + const result = await runInstallWithResume(m, async () => ({ started: false })); + expect(result.started).toBe(false); + expect(m.stopCalls).toBe(1); + expect(m.startCalls).toBe(1); + expect(m.isSessionRunning()).toBe(true); + }); + + it("resumes a running session when the install THROWS, then rethrows", async () => { + const m = makeManager(true); + await expect( + runInstallWithResume(m, async () => { + throw new Error("network down"); + }) + ).rejects.toThrow("network down"); + expect(m.stopCalls).toBe(1); + expect(m.startCalls).toBe(1); + expect(m.isSessionRunning()).toBe(true); + }); + + it("does NOT resume when the install succeeds (started:true) — the app is about to quit", async () => { + const m = makeManager(true); + const result = await runInstallWithResume(m, async () => ({ started: true })); + expect(result.started).toBe(true); + expect(m.startCalls).toBe(0); + expect(m.isSessionRunning()).toBe(false); + }); + + it("does NOT resume when no session was running before the install", async () => { + const m = makeManager(false); + await runInstallWithResume(m, async () => ({ started: false })); + expect(m.stopCalls).toBe(0); + expect(m.startCalls).toBe(0); + }); +});