fix(settings): route every update-install entry point through one coordinator

The global app-update notification panel called installAppUpdate()
directly, bypassing the unload-guard suspension entirely — with a dirty
settings form its install was cancelled at the DOM layer and treated as
a reload. The whole install choreography (suspend registered unload
guards, invoke the updater, restore on non-downloaded reply / rejected
IPC / later error push, tolerant of push-vs-reply ordering) now lives in
the root AppUpdateInstallService; the settings facade and the panel both
delegate to it, and the settings unload guard registers its
suspend/resume hooks with it while settings is mounted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-09 13:46:02 +02:00
1 parent 9b2898e6b2
commit bb46a707ec
8 files changed
+349 -190

No files matched your search

+1 -1
View File
@@ -514,7 +514,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
`/workspace/xtreams/:id/downloads/:downloadId` and
`/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide
the workspace context panel.
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
**Service Architecture** (Factory Pattern):
@@ -14,6 +14,7 @@ import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { AppUpdateInstallService } from './services/app-update-install.service';
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
@Component({
@@ -176,6 +177,7 @@ import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-rele
})
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
private readonly dialog = inject(MatDialog);
private readonly installService = inject(AppUpdateInstallService);
private unsubscribeStatus: (() => void) | null = null;
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
@@ -268,11 +270,15 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
}
async installUpdate(): Promise<void> {
if (!window.electron?.installAppUpdate) {
return;
}
// Never window.electron.installAppUpdate() directly: the install
// quits the app, and AppUpdateInstallService stands the settings
// unload guard down first — this panel is global, so the settings
// form can be dirty right now.
const status = await this.installService.installAppUpdate();
this.status.set(await window.electron.installAppUpdate());
if (status) {
this.status.set(status);
}
}
dismiss(): void {
@@ -0,0 +1,179 @@
import { TestBed } from '@angular/core/testing';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import {
AppQuitUnloadGuardHooks,
AppUpdateInstallService,
} from './app-update-install.service';
const BASE_STATUS: ElectronBridgeAppUpdateStatus = {
currentVersion: '0.23.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle,
supportedSelfUpdate: true,
};
/**
* The install choreography shared by every entry point (settings About
* section, global update notification panel): suspend the unsaved-settings
* unload guard for the updater-driven quit, and restore it on every path
* that proves the quit is not happening.
*/
describe('AppUpdateInstallService', () => {
const originalElectron = window.electron;
let pushStatus: (status: ElectronBridgeAppUpdateStatus) => void;
let electronStub: {
installAppUpdate: jest.Mock;
onAppUpdateStatusChange: jest.Mock;
};
let guard: AppQuitUnloadGuardHooks & {
suspendForAppQuit: jest.Mock;
resumeAfterAbortedAppQuit: jest.Mock;
};
beforeEach(() => {
pushStatus = () => undefined;
electronStub = {
installAppUpdate: jest.fn().mockResolvedValue(BASE_STATUS),
onAppUpdateStatusChange: jest.fn(
(callback: (status: ElectronBridgeAppUpdateStatus) => void) => {
pushStatus = callback;
return jest.fn();
}
),
};
window.electron = electronStub as unknown as typeof window.electron;
guard = {
resumeAfterAbortedAppQuit: jest.fn(),
suspendForAppQuit: jest.fn(),
};
TestBed.configureTestingModule({});
});
afterEach(() => {
window.electron = originalElectron;
});
function createService(): AppUpdateInstallService {
return TestBed.inject(AppUpdateInstallService);
}
it('returns null and touches no guard without the desktop bridge', async () => {
const service = createService();
service.registerUnloadGuard(guard);
window.electron = undefined;
expect(await service.installAppUpdate()).toBeNull();
expect(guard.suspendForAppQuit).not.toHaveBeenCalled();
});
it('keeps the guard down while a real install quits the app', async () => {
electronStub.installAppUpdate.mockResolvedValue({
...BASE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
const service = createService();
service.registerUnloadGuard(guard);
await service.installAppUpdate();
expect(guard.suspendForAppQuit).toHaveBeenCalledTimes(1);
expect(guard.resumeAfterAbortedAppQuit).not.toHaveBeenCalled();
});
it('restores the guard when nothing was installed', async () => {
const service = createService();
service.registerUnloadGuard(guard);
// BASE_STATUS reply stays Idle: nothing installable, no quit.
await service.installAppUpdate();
expect(guard.suspendForAppQuit).toHaveBeenCalledTimes(1);
expect(guard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(1);
});
it('restores the guard when the install IPC rejects', async () => {
const failure = new Error('ipc failed');
electronStub.installAppUpdate.mockRejectedValue(failure);
const service = createService();
service.registerUnloadGuard(guard);
await expect(service.installAppUpdate()).rejects.toThrow(failure);
expect(guard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(1);
});
it('restores the guard when an error push follows a quitting install', async () => {
// electron-updater reports install failures as a later 'error'
// status push, not as a rejection of the install IPC.
electronStub.installAppUpdate.mockResolvedValue({
...BASE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
const service = createService();
service.registerUnloadGuard(guard);
await service.installAppUpdate();
pushStatus({
...BASE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error,
});
expect(guard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(1);
// One-shot: later unrelated pushes must not resume again.
pushStatus(BASE_STATUS);
expect(guard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(1);
});
it('survives an error push that beats the install reply', async () => {
// IPC ordering between the invoke reply and status pushes is not
// guaranteed; a stale 'downloaded' reply must not re-suspend the
// protection the failure already restored.
let resolveInstall: (
status: ElectronBridgeAppUpdateStatus
) => void = () => undefined;
electronStub.installAppUpdate.mockImplementation(
() =>
new Promise<ElectronBridgeAppUpdateStatus>((resolve) => {
resolveInstall = resolve;
})
);
const service = createService();
service.registerUnloadGuard(guard);
const install = service.installAppUpdate();
pushStatus({
...BASE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error,
});
expect(guard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(1);
resolveInstall({
...BASE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
await install;
expect(guard.suspendForAppQuit).toHaveBeenCalledTimes(1);
pushStatus({
...BASE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error,
});
expect(guard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(1);
});
it('stops reaching a guard after it unregisters', async () => {
const service = createService();
const unregister = service.registerUnloadGuard(guard);
unregister();
await service.installAppUpdate();
expect(guard.suspendForAppQuit).not.toHaveBeenCalled();
expect(guard.resumeAfterAbortedAppQuit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,91 @@
import { Injectable } from '@angular/core';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
/** What an unload guard must offer to survive an updater-driven quit. */
export interface AppQuitUnloadGuardHooks {
suspendForAppQuit(): void;
resumeAfterAbortedAppQuit(): void;
}
/**
* The one place that installs a downloaded update without the
* unsaved-settings unload guard fighting the quit. Every install entry
* point — the settings About section and the app-wide update notification
* panel — must go through it: installing quits the app and closes the
* window while the settings form may be dirty, and only a root service can
* reach the settings-scoped unload guard from both.
*
* The choreography: suspend every registered guard, invoke the updater,
* and restore the protection whenever the promised quit provably did not
* happen — a non-'downloaded' reply, a rejected IPC, or a later
* error-status push (electron-updater reports install failures that way
* rather than throwing). `installQuitPending` is armed before the IPC
* round trip because the ordering between the reply and status pushes is
* not guaranteed; the 'downloaded' reply never re-arms it, so a failure
* push that wins the race cannot be undone by the stale reply.
*/
@Injectable({ providedIn: 'root' })
export class AppUpdateInstallService {
private readonly guards = new Set<AppQuitUnloadGuardHooks>();
private installQuitPending = false;
constructor() {
// App-lifetime subscription on purpose: the failure push can arrive
// after the UI that requested the install is gone.
window.electron?.onAppUpdateStatusChange?.((status) => {
if (
this.installQuitPending &&
status.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
) {
this.abortInstallQuit();
}
});
}
/**
* The settings unload guard registers itself while settings is open.
* @returns the matching unregister
*/
registerUnloadGuard(hooks: AppQuitUnloadGuardHooks): () => void {
this.guards.add(hooks);
return () => this.guards.delete(hooks);
}
/**
* @returns the updater's reply, or null when the bridge is unavailable
*/
async installAppUpdate(): Promise<ElectronBridgeAppUpdateStatus | null> {
if (!window.electron?.installAppUpdate) {
return null;
}
this.guards.forEach((guard) => guard.suspendForAppQuit());
this.installQuitPending = true;
try {
const status = await window.electron.installAppUpdate();
if (
status?.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
) {
this.abortInstallQuit();
}
return status;
} catch (error) {
this.abortInstallQuit();
throw error;
}
}
/** The promised quit is not happening: restore the unload protection. */
private abortInstallQuit(): void {
this.installQuitPending = false;
this.guards.forEach((guard) => guard.resumeAfterAbortedAppQuit());
}
}
@@ -12,7 +12,6 @@ import { ElectronServiceStub } from '../services/electron.service.stub';
import { SettingsService } from '../services/settings.service';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
import { SettingsUnloadGuardService } from './settings-unload-guard.service';
import {
createElectronStub,
DEFAULT_APP_UPDATE_STATUS,
@@ -47,10 +46,6 @@ describe('SettingsAppUpdateFacade', () => {
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(MatDialog, { open: jest.fn() }),
{ provide: SettingsService, useClass: MockSettingsService },
MockProvider(SettingsUnloadGuardService, {
resumeAfterAbortedAppQuit: jest.fn(),
suspendForAppQuit: jest.fn(),
}),
],
imports: [TranslateModule.forRoot()],
});
@@ -75,15 +70,17 @@ describe('SettingsAppUpdateFacade', () => {
facade.init();
await flush();
const statusHandler = (
// The last subscriber is the facade's own — AppUpdateInstallService
// (injected by the facade) also subscribes at construction time.
const statusCalls = (
window.electron.onAppUpdateStatusChange as jest.Mock
).mock.calls[0][0] as (status: ElectronBridgeAppUpdateStatus) => void;
).mock.calls;
const statusHandler = statusCalls[statusCalls.length - 1][0] as (
status: ElectronBridgeAppUpdateStatus
) => void;
statusHandler(pushedStatus);
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes(
1
);
expect(facade.status()).toEqual(pushedStatus);
});
@@ -154,131 +151,6 @@ describe('SettingsAppUpdateFacade', () => {
expect(window.electron.installAppUpdate).toHaveBeenCalledTimes(1);
});
it('keeps the unload guard down while a real install quits the app', async () => {
// The updater closes the window while the settings form may still be
// dirty; an armed beforeunload would cancel that close at the DOM
// layer and strand the install.
const unloadGuard = TestBed.inject(SettingsUnloadGuardService);
(window.electron.installAppUpdate as jest.Mock).mockResolvedValue({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
await facade.installAppUpdate();
expect(unloadGuard.suspendForAppQuit).toHaveBeenCalledTimes(1);
expect(unloadGuard.resumeAfterAbortedAppQuit).not.toHaveBeenCalled();
});
it('restores the unload guard when the install did not quit', async () => {
const unloadGuard = TestBed.inject(SettingsUnloadGuardService);
// The default stub reply stays Idle: nothing installable, no quit.
await facade.installAppUpdate();
expect(unloadGuard.suspendForAppQuit).toHaveBeenCalledTimes(1);
expect(unloadGuard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(
1
);
});
it('restores the unload guard when the install IPC rejects', async () => {
const unloadGuard = TestBed.inject(SettingsUnloadGuardService);
const failure = new Error('ipc failed');
(window.electron.installAppUpdate as jest.Mock).mockRejectedValue(
failure
);
await expect(facade.installAppUpdate()).rejects.toThrow(failure);
expect(unloadGuard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(
1
);
});
it('restores the unload guard when an error push follows a quitting install', async () => {
// electron-updater reports install failures as a later 'error'
// status event, not as a rejection of the install IPC — the app is
// then still running with the protection suspended.
const unloadGuard = TestBed.inject(SettingsUnloadGuardService);
(window.electron.installAppUpdate as jest.Mock).mockResolvedValue({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
facade.init();
const pushStatus = (
window.electron.onAppUpdateStatusChange as jest.Mock
).mock.calls[0][0] as (
status: ElectronBridgeAppUpdateStatus
) => void;
await facade.installAppUpdate();
expect(unloadGuard.resumeAfterAbortedAppQuit).not.toHaveBeenCalled();
pushStatus({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error,
});
expect(unloadGuard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(
1
);
// The resume is one-shot: later unrelated pushes change nothing.
pushStatus(DEFAULT_APP_UPDATE_STATUS);
expect(unloadGuard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(
1
);
});
it('survives an error push that beats the install reply', async () => {
// IPC ordering between the invoke reply and status pushes is not
// guaranteed: the failure can arrive first, and the stale
// 'downloaded' reply must not re-suspend the protection the
// failure already restored.
const unloadGuard = TestBed.inject(SettingsUnloadGuardService);
let resolveInstall: (
status: ElectronBridgeAppUpdateStatus
) => void = () => undefined;
(window.electron.installAppUpdate as jest.Mock).mockImplementation(
() =>
new Promise<ElectronBridgeAppUpdateStatus>((resolve) => {
resolveInstall = resolve;
})
);
facade.init();
const pushStatus = (
window.electron.onAppUpdateStatusChange as jest.Mock
).mock.calls[0][0] as (
status: ElectronBridgeAppUpdateStatus
) => void;
const install = facade.installAppUpdate();
pushStatus({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error,
});
expect(unloadGuard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(
1
);
resolveInstall({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
});
await install;
expect(unloadGuard.suspendForAppQuit).toHaveBeenCalledTimes(1);
// A later push must find nothing left to resume.
pushStatus({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error,
});
expect(unloadGuard.resumeAfterAbortedAppQuit).toHaveBeenCalledTimes(
1
);
});
it('opens the manual release URL from unsupported update status', () => {
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null);
facade.status.set({
@@ -7,9 +7,9 @@ import {
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs';
import { AppUpdateInstallService } from '../services/app-update-install.service';
import { SettingsService } from '../services/settings.service';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
import { SettingsUnloadGuardService } from './settings-unload-guard.service';
const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60;
const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250;
@@ -25,7 +25,7 @@ export class SettingsAppUpdateFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsService = inject(SettingsService);
private readonly translate = inject(TranslateService);
private readonly unloadGuard = inject(SettingsUnloadGuardService);
private readonly installService = inject(AppUpdateInstallService);
/** Latest updater status, polled once and then pushed by the backend */
readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null);
@@ -37,8 +37,6 @@ export class SettingsAppUpdateFacade {
readonly updateMessage = signal('');
private unsubscribeStatus: (() => void) | null = null;
/** True after an install reply promised a quit that has not happened. */
private installQuitPending = false;
/** Subscribes to status pushes and kicks off the initial status load */
init(): void {
@@ -68,49 +66,20 @@ export class SettingsAppUpdateFacade {
}
async installAppUpdate(): Promise<void> {
if (!this.runtime.isElectron || !window.electron?.installAppUpdate) {
if (!this.runtime.isElectron) {
return;
}
// Installing quits the app; the unsaved-settings unload guard must
// not fight a quit the user just asked for — its `beforeunload`
// would cancel the updater's window close at the DOM layer and
// strand the install. A 'downloaded' reply means quitAndInstall ran
// and the app is going down; anything else — including a rejected
// IPC — means no quit happened, so the protection comes back.
// electron-updater can also report the failure as a later 'error'
// status push instead (bindStatusEvents handles that).
this.unloadGuard.suspendForAppQuit();
// Armed before the IPC round trip: the error push can beat the
// invoke reply, and the status listener must already see the
// pending quit to resume the guard. The 'downloaded' reply
// deliberately does not re-arm it — if the push won the race,
// re-arming would re-suspend a protection the failure just
// restored.
this.installQuitPending = true;
// Installing quits the app; AppUpdateInstallService owns the whole
// choreography of standing the unsaved-settings unload guard down
// for that quit and restoring it when the quit provably fails.
const status = await this.installService.installAppUpdate();
try {
const status = await window.electron.installAppUpdate();
if (status) {
this.status.set(status);
if (
status?.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
) {
this.abortInstallQuit();
}
} catch (error) {
this.abortInstallQuit();
throw error;
}
}
/** The promised quit is not happening: restore the unload protection. */
private abortInstallQuit(): void {
this.installQuitPending = false;
this.unloadGuard.resumeAfterAbortedAppQuit();
}
openManualAppUpdate(): void {
const manualDownloadUrl = this.status()?.manualDownloadUrl;
@@ -206,17 +175,6 @@ export class SettingsAppUpdateFacade {
this.unsubscribeStatus = window.electron.onAppUpdateStatusChange(
(status) => {
this.status.set(status);
// Any post-install status other than the quitting install's
// own proves the app is still running — the promised quit is
// not coming, so the unload protection returns.
if (
this.installQuitPending &&
status.status !==
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
) {
this.abortInstallQuit();
}
}
);
}
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { FormBuilder } from '@angular/forms';
import { AppUpdateInstallService } from '../services/app-update-install.service';
import { SettingsForm } from './settings-form.utils';
import {
SettingsUnloadGuardHost,
@@ -49,6 +50,11 @@ describe('SettingsUnloadGuardService', () => {
setWindowCloseGuard: jest.fn().mockResolvedValue(undefined),
confirmWindowClose: jest.fn().mockResolvedValue(undefined),
cancelWindowClose: jest.fn().mockResolvedValue(undefined),
installAppUpdate: jest.fn().mockResolvedValue({
currentVersion: '0.23.0',
status: 'idle',
supportedSelfUpdate: true,
}),
onWindowCloseRequested: jest.fn((callback: () => void) => {
closeRequestCallback = callback;
return unsubscribeCloseRequests;
@@ -165,6 +171,38 @@ describe('SettingsUnloadGuardService', () => {
const event = dispatchBeforeUnload();
expect(event.defaultPrevented).toBe(true);
});
it('stands down for any install entry point via the install service', async () => {
// The global update panel installs through the same root
// service; while settings is mounted, that must reach this
// guard. An Idle reply (nothing installable) suspends and
// restores in one round trip.
const installService = TestBed.inject(AppUpdateInstallService);
activateInElectron();
form.markAsDirty();
await installService.installAppUpdate();
expect(electronStub.setWindowCloseGuard).toHaveBeenLastCalledWith(
true
);
const event = dispatchBeforeUnload();
expect(event.defaultPrevented).toBe(true);
});
it('unregisters from the install service on destroy', async () => {
const installService = TestBed.inject(AppUpdateInstallService);
activateInElectron();
service.ngOnDestroy();
const callsAfterDestroy =
electronStub.setWindowCloseGuard.mock.calls.length;
await installService.installAppUpdate();
expect(electronStub.setWindowCloseGuard.mock.calls.length).toBe(
callsAfterDestroy
);
});
});
describe('intercepted window close (Electron)', () => {
@@ -1,4 +1,5 @@
import { inject, Injectable, NgZone, OnDestroy } from '@angular/core';
import { AppUpdateInstallService } from '../services/app-update-install.service';
import { SettingsForm } from './settings-form.utils';
export interface SettingsUnloadGuardHost {
@@ -39,8 +40,10 @@ export interface SettingsUnloadGuardHost {
@Injectable()
export class SettingsUnloadGuardService implements OnDestroy {
private readonly zone = inject(NgZone);
private readonly installService = inject(AppUpdateInstallService);
private host: SettingsUnloadGuardHost | null = null;
private unsubscribeCloseRequests: (() => void) | null = null;
private unregisterFromInstallService: (() => void) | null = null;
private confirmationPending = false;
/**
* Intent of the confirmation currently on screen. A close request
@@ -68,6 +71,16 @@ export class SettingsUnloadGuardService implements OnDestroy {
this.zone.run(() => void this.handleCloseRequest('close'));
}) ?? null;
// Whatever surface installs an app update — the settings About
// section or the app-wide notification panel — must be able to
// stand this guard down before the updater closes the window.
this.unregisterFromInstallService =
this.installService.registerUnloadGuard({
resumeAfterAbortedAppQuit: () =>
this.resumeAfterAbortedAppQuit(),
suspendForAppQuit: () => this.suspendForAppQuit(),
});
// Armed before the user can possibly stage an edit; a close with a
// pristine form auto-confirms through the dialog-less path, so the
// only visible effect of mount-long arming is race-free protection.
@@ -110,6 +123,8 @@ export class SettingsUnloadGuardService implements OnDestroy {
window.removeEventListener('beforeunload', this.beforeUnloadHandler);
this.unsubscribeCloseRequests?.();
this.unsubscribeCloseRequests = null;
this.unregisterFromInstallService?.();
this.unregisterFromInstallService = null;
this.suspended = false;
this.syncCloseGuard(false);
this.host = null;