import { Component, computed, OnDestroy, OnInit, signal, inject, ChangeDetectionStrategy, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; import { ELECTRON_BRIDGE_APP_UPDATE_STATUSES, ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; import { AppUpdateInstallService } from './services/app-update-install.service'; @Component({ selector: 'app-update-notification-panel', imports: [MatButtonModule, MatIconModule, TranslatePipe], template: ` @if (isVisible(); as visible) { system_update {{ 'SETTINGS.APP_UPDATE_TITLE' | translate }} close {{ labelKey() | translate : { version: status()?.latestVersion || status()?.currentVersion, } }} @if (status()?.progress; as progress) { } article {{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }} @if (status()?.status === appUpdateStatuses.Downloaded) { restart_alt {{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }} } @else { {{ primaryActionIcon() }} {{ primaryActionLabelKey() | translate }} } } `, changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` .app-update-notification { position: fixed; right: 20px; bottom: 20px; width: 320px; max-width: calc(100vw - 40px); z-index: 901; overflow: hidden; border: 1px solid var(--app-separator); border-radius: 12px; background: var(--app-widget-bg); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.18), 0 16px 48px rgba(0, 0, 0, 0.12); } .app-update-notification__header { display: flex; align-items: center; justify-content: space-between; padding: 8px 8px 8px 12px; border-bottom: 1px solid var(--app-separator); } .app-update-notification__header span { display: flex; align-items: center; gap: 8px; color: var(--app-heading-color); font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } .app-update-notification__header mat-icon { color: var(--app-selection-color); } .app-update-notification__body { display: grid; gap: 8px; padding: 12px; color: var(--app-body-color); font-size: 0.88rem; } .app-update-notification__body progress { width: 100%; } .app-update-notification__actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; padding: 0 12px 12px; } @media (max-width: 560px) { .app-update-notification { right: 12px; bottom: 12px; left: 12px; width: auto; max-width: none; } } `, ], }) 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; readonly status = signal(null); readonly dismissedVersion = signal(null); readonly labelKey = computed(() => this.status()?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded ? 'SETTINGS.APP_UPDATE_DOWNLOADED' : 'SETTINGS.APP_UPDATE_AVAILABLE' ); readonly primaryActionLabelKey = computed(() => this.status()?.supportedSelfUpdate === false ? 'SETTINGS.APP_UPDATE_OPEN_RELEASE' : 'SETTINGS.APP_UPDATE_DOWNLOAD' ); readonly primaryActionIcon = computed(() => this.status()?.supportedSelfUpdate === false ? 'open_in_new' : 'download' ); readonly isVisible = computed(() => { const status = this.status(); if (!status?.latestVersion) { return false; } if (this.dismissedVersion() === status.latestVersion) { return false; } return ( status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available || status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading || status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded ); }); ngOnInit(): void { if (!window.electron?.getAppUpdateStatus) { return; } this.unsubscribeStatus = window.electron.onAppUpdateStatusChange?.((status) => { this.status.set(status); }) ?? null; void window.electron.getAppUpdateStatus().then((status) => { this.status.set(status); }); } ngOnDestroy(): void { this.unsubscribeStatus?.(); this.unsubscribeStatus = null; } /** * Loaded on demand: the dialog renders Markdown with `marked`, which this * always-mounted panel would otherwise put on the initial path. A field so * specs can substitute it. */ loadReleaseNotesDialog = () => import('./settings/app-update-release-notes-dialog.component'); async openReleaseNotes(): Promise { const status = this.status(); const latestVersion = status?.latestVersion; let dialogModule: Awaited< ReturnType >; try { dialogModule = await this.loadReleaseNotesDialog(); } catch (error) { // The same notes are on the releases page; the next click retries. console.error('Could not load the release notes dialog:', error); if (status?.manualDownloadUrl) { window.open(status.manualDownloadUrl, '_blank', 'noreferrer'); } return; } const { AppUpdateReleaseNotesDialogComponent } = dialogModule; this.dialog.open(AppUpdateReleaseNotesDialogComponent, { autoFocus: false, data: { initialVersion: latestVersion, }, maxWidth: 'calc(100vw - 32px)', restoreFocus: true, width: '720px', }); } async downloadUpdate(): Promise { const status = this.status(); if (!status) { return; } if (!status.supportedSelfUpdate) { window.open(status.manualDownloadUrl, '_blank', 'noreferrer'); return; } if (!window.electron?.downloadAppUpdate) { return; } this.status.set(await window.electron.downloadAppUpdate()); } async installUpdate(): Promise { // 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(); if (status) { this.status.set(status); } } dismiss(): void { this.dismissedVersion.set(this.status()?.latestVersion ?? null); } }