From 582422c5126ebcd764dbc6e01c0410ad8d8b41bb Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 09:51:59 +0300 Subject: [PATCH] refactor(runtime): gate settings backup file save by capability --- .../app/settings/settings.component.spec.ts | 59 +++++++++++++++++++ .../src/app/settings/settings.component.ts | 3 +- docs/architecture/pwa-self-hosted.md | 3 +- .../lib/runtime-capabilities.service.spec.ts | 18 ++++++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 0bb413b4b..c861510e6 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -911,6 +911,65 @@ describe('SettingsComponent', () => { expect(component.isExportingData()).toBe(false); }); + it('falls back to browser backup download when desktop file-save preload is incomplete', async () => { + fixture.destroy(); + const saveFileDialog = jest.fn().mockResolvedValue('/tmp/backup.json'); + window.electron = { + platform: 'linux', + saveFileDialog, + } as unknown as typeof window.electron; + + const createObjectURL = jest.fn().mockReturnValue('blob:backup'); + const revokeObjectURL = jest.fn(); + const originalCreateObjectURL = window.URL.createObjectURL; + const originalRevokeObjectURL = window.URL.revokeObjectURL; + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: createObjectURL, + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectURL, + }); + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(); + + try { + const partialFileSaveFixture = + TestBed.createComponent(SettingsComponent); + const partialFileSaveComponent = + partialFileSaveFixture.componentInstance; + partialFileSaveComponent.checkAppVersion = jest.fn(); + partialFileSaveComponent.fetchLocalIpAddresses = jest + .fn() + .mockResolvedValue(undefined); + partialFileSaveFixture.detectChanges(); + + expect(partialFileSaveComponent.isDesktop).toBe(true); + expect(partialFileSaveComponent.supportsDesktopFileSave).toBe( + false + ); + + await partialFileSaveComponent.exportData(); + + expect(saveFileDialog).not.toHaveBeenCalled(); + expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup'); + } finally { + clickSpy.mockRestore(); + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: originalCreateObjectURL, + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: originalRevokeObjectURL, + }); + } + }); + it('shows a failure snackbar and skips refresh when clearing EPG data rejects', async () => { (window.electron.clearEpgData as jest.Mock).mockRejectedValueOnce( new Error('boom') diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5d6839ba7..13c26f4fa 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -137,6 +137,7 @@ export class SettingsComponent implements OnInit, OnDestroy { /** Flag that indicates whether the app runs in electron environment */ readonly isDesktop = this.runtime.isElectron; + readonly supportsDesktopFileSave = this.runtime.supportsDesktopFileSave; readonly supportsEpg = this.runtime.supportsEpg; readonly supportsExternalPlayerPathSettings = this.runtime.supportsExternalPlayerPathSettings; @@ -699,7 +700,7 @@ export class SettingsComponent implements OnInit, OnDestroy { try { const backup = await this.playlistBackupService.exportBackup(); - if (this.isDesktop && window.electron?.saveFileDialog) { + if (this.supportsDesktopFileSave && window.electron) { const savePath = await window.electron.saveFileDialog( backup.defaultFileName, [ diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 5965ff39a..a1268e694 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -79,7 +79,8 @@ panels (`fetchEpg`, `getChannelPrograms`, `checkEpgFreshness`, `searchEpgPrograms`), `supportsPlaylistRefresh` requires the native playlist refresh/cancel/progress bridge, `supportsXtreamSectionNavigation` is available in PWA and in Electron when either the SQLite Xtream data source or the Xtream -API transport is available, and +API transport is available, `supportsDesktopFileSave` requires both +`saveFileDialog` and `writeFile`, and `supportsManagedExternalPlayers` requires the MPV and VLC preload launch and path-setting methods (`openInMpv`, `openInVlc`, `setMpvPlayerPath`, and `setVlcPlayerPath`); a partial Electron bridge must not expose desktop-only diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index a1d634b49..faf5e07ec 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -258,6 +258,24 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsDownloads).toBe(true); }); + it('requires both desktop file-save preload methods', () => { + testWindow.electron = { + saveFileDialog: jest.fn(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.isElectron).toBe(true); + expect(service.supportsDesktopFileSave).toBe(false); + + testWindow.electron = { + saveFileDialog: jest.fn(), + writeFile: jest.fn(), + }; + + expect(service.supportsDesktopFileSave).toBe(true); + }); + it('requires the complete playlist refresh preload surface', () => { testWindow.electron = { refreshPlaylist: jest.fn(),