diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts index a023820b3..c468105a7 100644 --- a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts @@ -736,6 +736,18 @@ test('@downloads @epg @xtream @electron downloads a completed archive into the l exact: true, }) ).toBeVisible(); + await app.mainWindow.evaluate(() => { + Object.defineProperty(navigator.clipboard, 'writeText', { + configurable: true, + value: () => + Promise.reject(new Error('simulated clipboard failure')), + }); + }); + await recoveryDialog + .getByRole('button', { name: 'Copy recovery path', exact: true }) + .click(); + await expect(recoveryDialog).toBeVisible(); + await expect(recoveryDialog).toContainText(capturedPath); expect(readFileSync(capturedPath, 'utf8')).toBe( 'foreign recovery content' ); diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index f89d7465b..628c155e3 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -121,6 +121,7 @@ the capture and its journal for recovery. Even after a foreign entry is restored its private recovery copy is never automatically unlinked: another process could remove the public link first. Remove/Clear return a structured recovery path and open a persistent, localized dialog with the full path, Copy recovery path and manual recovery instructions. +Copy keeps the dialog open, including on clipboard failure; Close dismisses it. After the user recovers it and explicitly removes that private copy, cleanup may release the journal. Ordinary owned-file cleanup remains automatic. Remove/Clear, Retry/Resume and fresh reservations retry identity-verified cleanup, including after restart and on diff --git a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts index 1c6a2c02f..02b1f3461 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts @@ -208,6 +208,7 @@ export class DownloadManagerActionsService { private showRecovery(path: string): void { this.dialogs.openConfirmDialog({ title: this.translate.instant('DOWNLOADS.RECOVERY.TITLE'), + keepOpenOnConfirm: true, cancelLabel: this.translate.instant('CLOSE'), width: '600px', message: this.translate.instant('DOWNLOADS.RECOVERY.MESSAGE', { diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 1d1412b5b..252d3eb83 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -668,6 +668,7 @@ describe('DownloadsComponent', () => { title: 'File recovery needed', message: expect.stringContaining(recoveryPath), confirmLabel: 'Copy recovery path', + keepOpenOnConfirm: true, cancelLabel: 'Close', }) ); diff --git a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts new file mode 100644 index 000000000..5efb3c49e --- /dev/null +++ b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { ConfirmDialogComponent } from './confirm-dialog.component'; + +describe('ConfirmDialogComponent actions', () => { + it.each([false, true])( + 'keeps the action in place only when requested: %s', + async (keepOpenOnConfirm) => { + const onConfirm = jest.fn(); + const close = jest.fn(); + await TestBed.configureTestingModule({ + imports: [ + ConfirmDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + title: 'Recovery', + message: '/saved/entry', + confirmLabel: 'Copy', + cancelLabel: 'Close', + keepOpenOnConfirm, + onConfirm, + }, + }, + { provide: MatDialogRef, useValue: { close } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ConfirmDialogComponent); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll( + 'button' + ) as NodeListOf; + buttons[1].click(); + if (keepOpenOnConfirm) { + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain( + '/saved/entry' + ); + } else { + expect(onConfirm).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledWith(true); + } + buttons[0].click(); + expect(close).toHaveBeenCalled(); + } + ); +}); diff --git a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts index cbcbc6a63..ffd13e5ab 100644 --- a/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts +++ b/libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts @@ -9,6 +9,8 @@ export interface ConfirmDialogData { width?: string; confirmLabel?: string; cancelLabel?: string; + /** Run the action in the dialog; only Close/backdrop/Escape dismiss it. */ + keepOpenOnConfirm?: boolean; onConfirm: () => void; } @@ -25,9 +27,23 @@ export interface ConfirmDialogData { - + @if (dialogData.keepOpenOnConfirm) { + + } @else { + + } `, }) diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts index 8f5d656d9..348d3ec79 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts @@ -83,4 +83,15 @@ describe('DialogService', () => { expect(onConfirm).toHaveBeenCalledTimes(1); }); + it('does not repeat a persistent dialog action on programmatic close', () => { + const onConfirm = jest.fn(); + dialog.open.mockReturnValue({ afterClosed: () => of(true) }); + service.openConfirmDialog({ + title: 'Recovery', + message: 'Path', + keepOpenOnConfirm: true, + onConfirm, + }); + expect(onConfirm).not.toHaveBeenCalled(); + }); }); diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts index b6cadcf5d..4e2bf4bca 100644 --- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts +++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts @@ -28,6 +28,8 @@ export class DialogService { }); dialogRef .afterClosed() - .subscribe((result) => (result ? data.onConfirm() : null)); + .subscribe((result) => + result && !data.keepOpenOnConfirm ? data.onConfirm() : null + ); } }