import { customElement, DeesElement, type TemplateResult, html, property, css, } from '@design.estate/dees-element'; import { demoFunc } from './dees-updater.demo.js'; import { DeesStepper, type IStep, type IStepProgressState, } from '../../00group-layout/dees-stepper/dees-stepper.js'; export type TDeesUpdaterSuccessAction = 'close' | 'reload'; export interface IDeesUpdaterOptions { currentVersion?: string; updatedVersion?: string; moreInfoUrl?: string; changelogUrl?: string; successAction?: TDeesUpdaterSuccessAction; successDelayMs?: number; successActionLabel?: string; onSuccessAction?: () => Promise | void; } declare global { interface HTMLElementTagNameMap { 'dees-updater': DeesUpdater; } } @customElement('dees-updater') export class DeesUpdater extends DeesElement { public static demo = demoFunc; public static demoGroups = ['Utility']; public static async createAndShow(optionsArg: IDeesUpdaterOptions = {}) { const updater = new DeesUpdater(); updater.currentVersion = optionsArg.currentVersion ?? ''; updater.updatedVersion = optionsArg.updatedVersion ?? ''; updater.moreInfoUrl = optionsArg.moreInfoUrl ?? ''; updater.changelogUrl = optionsArg.changelogUrl ?? ''; updater.successAction = optionsArg.successAction ?? 'close'; updater.successDelayMs = optionsArg.successDelayMs ?? 10000; updater.successActionLabel = optionsArg.successActionLabel ?? ''; updater.onSuccessAction = optionsArg.onSuccessAction ?? null; document.body.appendChild(updater); await updater.show(); return updater; } @property({ type: String, }) accessor currentVersion = ''; @property({ type: String, }) accessor updatedVersion = ''; @property({ type: String, }) accessor moreInfoUrl = ''; @property({ type: String, }) accessor changelogUrl = ''; @property({ type: String, }) accessor successAction: TDeesUpdaterSuccessAction = 'close'; @property({ type: Number, }) accessor successDelayMs = 10000; @property({ type: String, }) accessor successActionLabel = ''; private stepper: DeesStepper | null = null; private progressStep: IStep | null = null; private showPromise: Promise | null = null; private onSuccessAction: (() => Promise | void) | null = null; public static styles = [ css` :host { display: none; } `, ]; public render(): TemplateResult { return html``; } public async connectedCallback(): Promise { await super.connectedCallback(); void this.show(); } public async show(): Promise { if (this.stepper?.isConnected) { return; } if (this.showPromise) { return this.showPromise; } this.showPromise = this.openStepperFlow(); try { await this.showPromise; } finally { this.showPromise = null; } } public updateProgress(progressStateArg: Partial) { if (!this.stepper || !this.progressStep) { return; } this.stepper.updateProgressStep(progressStateArg, this.progressStep); } public appendProgressLine(lineArg: string) { if (!this.stepper || !this.progressStep) { return; } this.stepper.appendProgressStepLine(lineArg, this.progressStep); } public markUpdateError(messageArg: string) { this.appendProgressLine(`Error: ${messageArg}`); this.updateProgress({ indeterminate: false, statusText: messageArg, }); } public async markUpdateReady() { if (!this.stepper || !this.progressStep) { return; } this.stepper.updateProgressStep( { percentage: 100, indeterminate: false, statusText: 'Update ready.', }, this.progressStep, ); this.stepper.appendProgressStepLine('Update ready', this.progressStep); if (this.stepper.selectedStep === this.progressStep) { this.stepper.goNext(); } } public async destroy() { const stepper = this.stepper; this.stepper = null; this.progressStep = null; if (stepper?.isConnected) { await stepper.destroy(); } if (this.parentElement) { this.parentElement.removeChild(this); } } private async openStepperFlow() { const { steps, progressStep } = this.createUpdaterSteps(); this.progressStep = progressStep; this.stepper = await DeesStepper.createAndShow({ steps, cancelable: false, }); } private createUpdaterSteps(): { steps: IStep[]; progressStep: IStep } { const infoMenuOptions = this.getLinkMenuOptions(); const progressStep: IStep = { title: 'Updating the application', content: this.renderProgressContent(), progressStep: { label: this.getProgressLabel(), percentage: 5, indeterminate: true, statusRows: 4, statusText: 'Preparing update...', terminalLines: ['Preparing update'], }, menuOptions: infoMenuOptions.length > 0 ? infoMenuOptions : undefined, }; const readyStep: IStep = { title: this.updatedVersion ? `Version ${this.updatedVersion} ready` : 'Update ready', content: this.renderReadyContent(), progressStep: { label: this.getSuccessCountdownLabel(this.getSuccessDelaySeconds()), percentage: 0, indeterminate: false, showPercentage: false, statusRows: 2, statusText: this.getSuccessCountdownStatus(this.getSuccessDelaySeconds()), }, validationFunc: async (stepper, _htmlElement, signal) => { await this.runSuccessCountdown(stepper, readyStep, signal); }, }; return { steps: [progressStep, readyStep], progressStep, }; } private getProgressLabel(): string { if (this.currentVersion && this.updatedVersion) { return `${this.currentVersion} -> ${this.updatedVersion}`; } if (this.updatedVersion) { return `Preparing ${this.updatedVersion}`; } return 'Application update'; } private getSuccessDelaySeconds(): number { return Math.max(1, Math.ceil(this.successDelayMs / 1000)); } private getSuccessActionDisplayLabel(): string { if (this.successActionLabel) { return this.successActionLabel; } if (this.onSuccessAction) { return 'Continuing automatically'; } if (this.successAction === 'reload') { return 'Reloading application'; } return 'Closing updater'; } private getSuccessCountdownLabel(secondsArg: number): string { return `${this.getSuccessActionDisplayLabel()} in ${secondsArg}s`; } private getSuccessCountdownStatus(secondsArg: number): string { const secondLabel = secondsArg === 1 ? 'second' : 'seconds'; return `${this.getSuccessActionDisplayLabel()} in ${secondsArg} ${secondLabel}.`; } private getSuccessActionNowLabel(): string { return `${this.getSuccessActionDisplayLabel()} now...`; } private getLinkMenuOptions() { const menuOptions: Array<{ name: string; action: () => Promise }> = []; if (this.moreInfoUrl) { menuOptions.push({ name: 'More info', action: async () => { this.openExternalUrl(this.moreInfoUrl); }, }); } if (this.changelogUrl) { menuOptions.push({ name: 'Changelog', action: async () => { this.openExternalUrl(this.changelogUrl); }, }); } return menuOptions; } private renderProgressContent(): TemplateResult { return html`

Downloading and applying the latest application release. ${this.currentVersion && this.updatedVersion ? html`Moving from ${this.currentVersion} to ${this.updatedVersion}.` : this.updatedVersion ? html`Preparing ${this.updatedVersion}.` : ''}

The updater advances automatically once the new build is installed and verified.

`; } private renderReadyContent(): TemplateResult { const successDelaySeconds = this.getSuccessDelaySeconds(); return html`

${this.updatedVersion ? html`Version ${this.updatedVersion} is ready to use.` : 'The new version is ready to use.'}

Configured next action: ${this.getSuccessActionDisplayLabel()}. It runs automatically in ${successDelaySeconds} seconds.

`; } private async runSuccessCountdown( stepperArg: DeesStepper, stepArg: IStep, signal?: AbortSignal, ): Promise { const totalDuration = Math.max(1000, this.successDelayMs); const startTime = Date.now(); while (!signal?.aborted) { const elapsed = Math.min(totalDuration, Date.now() - startTime); const remainingMilliseconds = Math.max(0, totalDuration - elapsed); const remainingSeconds = Math.max(0, Math.ceil(remainingMilliseconds / 1000)); stepperArg.updateProgressStep( { label: remainingMilliseconds > 0 ? this.getSuccessCountdownLabel(remainingSeconds) : this.getSuccessActionNowLabel(), percentage: (elapsed / totalDuration) * 100, indeterminate: false, showPercentage: false, statusText: remainingMilliseconds > 0 ? this.getSuccessCountdownStatus(remainingSeconds) : this.getSuccessActionNowLabel(), }, stepArg, ); if (remainingMilliseconds <= 0) { break; } const completed = await this.waitForCountdownTick(100, signal); if (!completed) { return; } } await this.runConfiguredSuccessAction(); } private async waitForCountdownTick(timeoutArg: number, signal?: AbortSignal): Promise { return new Promise((resolve) => { let completed = false; const finish = (result: boolean) => { if (completed) { return; } completed = true; if (signal) { signal.removeEventListener('abort', handleAbort); } resolve(result); }; const handleAbort = () => { window.clearTimeout(timeoutId); finish(false); }; const timeoutId = window.setTimeout(() => { finish(true); }, timeoutArg); if (signal) { signal.addEventListener('abort', handleAbort, { once: true }); } }); } private async runConfiguredSuccessAction(): Promise { if (this.onSuccessAction) { await this.onSuccessAction(); return; } if (this.successAction === 'reload') { await this.destroy(); window.location.reload(); return; } await this.destroy(); } private openExternalUrl(urlArg: string) { window.open(urlArg, '_blank', 'noopener,noreferrer'); } }