diff --git a/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts b/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts index dd1cde439..ee5a43a2b 100644 --- a/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts +++ b/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts @@ -258,6 +258,38 @@ describe('WindowCloseGuard', () => { expect(win.fireClose()).toBe(false); }); + it('completes a close left unanswered when the renderer disarms', () => { + // Teardown race: close intercepted, then the settings page is torn + // down and disarms before answering — the close must not be + // swallowed. + const { guard, win } = createArmedGuard(); + + win.fireClose(); + guard.setGuardActive(false); + + expect(win.close).toHaveBeenCalledTimes(1); + }); + + it('resumes an unanswered quit when the renderer disarms', () => { + const { app, guard, win } = createArmedGuard(); + + app.fireBeforeQuit(); + win.fireClose(); + guard.setGuardActive(false); + + expect(app.quit).toHaveBeenCalledTimes(1); + expect(win.close).not.toHaveBeenCalled(); + }); + + it('closes nothing on a plain disarm without a pending intent', () => { + const { app, guard, win } = createArmedGuard(); + + guard.setGuardActive(false); + + expect(win.close).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + }); + it('drops an unanswered quit intent when the renderer navigates away', () => { const { app, guard, win } = createArmedGuard(); diff --git a/apps/electron-backend/src/app/services/window-close-guard.service.ts b/apps/electron-backend/src/app/services/window-close-guard.service.ts index 9a3bbdad8..3cf80b911 100644 --- a/apps/electron-backend/src/app/services/window-close-guard.service.ts +++ b/apps/electron-backend/src/app/services/window-close-guard.service.ts @@ -101,6 +101,17 @@ export class WindowCloseGuard { setGuardActive(active: boolean): void { this.guardActive = active; + + // A disarm with an unanswered intercepted close is the teardown + // race: the renderer left settings between the interception and + // this disarm reaching us, so nobody remains to confirm and the + // user's close would be silently swallowed. The guard is mount-long + // in the renderer, so a disarm only ever means "settings is gone" — + // its form was already settled by the router guard, nothing is left + // to protect, and the intercepted intent can complete. + if (!active && this.pendingIntent !== null) { + this.confirmClose(); + } } /**