diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index d547486c1..4c4e67837 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -3,6 +3,7 @@ import { ServerModel, ServerStatus } from './models/server-model' import { Storage } from '@ionic/storage' import { SyncDaemon } from './services/sync.service' import { AuthService, AuthState } from './services/auth.service' +import { GlobalAlertsNotifier } from './services/startup-alerts.notifier' import { ApiService } from './services/api/api.service' import { Router } from '@angular/router' import { BehaviorSubject, Observable } from 'rxjs' @@ -65,6 +66,7 @@ export class AppComponent { private readonly alertCtrl: AlertController, private readonly loader: LoaderService, private readonly emver: Emver, + private readonly globalAlertsNotifier: GlobalAlertsNotifier, readonly splitPane: SplitPaneTracker, ) { // set dark theme @@ -79,6 +81,7 @@ export class AppComponent { await this.storage.ready() await this.authService.restoreCache() await this.emver.init() + this.globalAlertsNotifier.init() this.authService.listen({ [AuthState.VERIFIED]: async () => { diff --git a/ui/src/app/components/install-wizard/prebaked-wizards.ts b/ui/src/app/components/install-wizard/prebaked-wizards.ts index 6807a25ad..d003b2dbd 100644 --- a/ui/src/app/components/install-wizard/prebaked-wizards.ts +++ b/ui/src/app/components/install-wizard/prebaked-wizards.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core' import { AppModel, AppStatus } from 'src/app/models/app-model' +import { exists } from 'src/app/util/misc.util' import { AppDependency, DependentBreakage, AppInstalledPreview } from '../../models/app-types' import { ApiService } from '../../services/api/api.service' import { InstallWizardComponent, SlideDefinition, TopbarParams } from './install-wizard.component' @@ -171,5 +172,5 @@ function validate (t: T, test: (t: T) => Boolean, desc: string) { } } -const exists = t => !!t + const defaultUninstallationWarning = serviceName => `Uninstalling ${ serviceName } will result in the deletion of its data.` \ No newline at end of file diff --git a/ui/src/app/components/update-os-banner/update-os-banner.component.ts b/ui/src/app/components/update-os-banner/update-os-banner.component.ts index bd8637940..7cca2d1b2 100644 --- a/ui/src/app/components/update-os-banner/update-os-banner.component.ts +++ b/ui/src/app/components/update-os-banner/update-os-banner.component.ts @@ -16,7 +16,7 @@ export class UpdateOsBannerComponent { private readonly alertCtrl: AlertController, private readonly loader: LoaderService, ) { - this.updateAvailable$ = this.osUpdateService.watchForUpdateAvailable() + this.updateAvailable$ = this.osUpdateService.watchForUpdateAvailable$() } ngOnInit () { } diff --git a/ui/src/app/services/os-update.service.ts b/ui/src/app/services/os-update.service.ts index 1dbe3d26b..57f97a83c 100644 --- a/ui/src/app/services/os-update.service.ts +++ b/ui/src/app/services/os-update.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core' import { NavController } from '@ionic/angular' -import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs' -import { catchError, map, take, tap } from 'rxjs/operators' +import { BehaviorSubject, combineLatest, forkJoin, interval, NextObserver, Observable, Observer, of } from 'rxjs' +import { catchError, concatMap, distinctUntilChanged, filter, map, take, tap } from 'rxjs/operators' import { ServerModel, ServerStatus } from '../models/server-model' import { ApiService } from './api/api.service' import { Emver } from './emver.service' @@ -10,26 +10,52 @@ import { Emver } from './emver.service' // call checkForUpdates in marketplace pages, can subscribe globally however @Injectable({ providedIn: 'root' }) export class OsUpdateService { - private readonly $updateAvailable$ = new BehaviorSubject(undefined) + // holds version latest if update available, undefined if not. + private readonly $updateAvailable$ = new BehaviorSubject(undefined) + + // same as above, but we only update this as a result of auto check + // this is because we only pop update alert when it comes from an auto check, not the checkForUpdates() call + private readonly $updateAvailableFromAutoCheck$ = new BehaviorSubject(undefined) + + watchForUpdateAvailable$ (): Observable { + return this.$updateAvailable$.asObservable().pipe(distinctUntilChanged()) + } + + watchForAutoCheckUpdateAvailable$ (): Observable { + return this.$updateAvailableFromAutoCheck$.asObservable().pipe(distinctUntilChanged()) + } constructor ( private readonly emver: Emver, private readonly serverModel: ServerModel, private readonly apiService: ApiService, private readonly navCtrl: NavController, - ) { } + ) { + // watch auto check flag and versionLatest for possible update + this.autoCheck$().subscribe(this.$updateAvailableFromAutoCheck$) - watchForUpdateAvailable (): Observable { - return this.$updateAvailable$.asObservable() + // if update is available from auto check, then it's available (not vice versa) + this.$updateAvailableFromAutoCheck$.subscribe(this.$updateAvailable$) } - // undefined when no update available, string for the versionLatest if there is + + private autoCheck$ (): Observable { + const { autoCheckUpdates } = this.serverModel.watch() + return combineLatest([autoCheckUpdates, interval(5000)]).pipe( + filter( ([check, _]) => check), + concatMap(() => this.apiService.getVersionLatest()), + filter( ({ canUpdate }) => canUpdate), + map(({ versionLatest }) => versionLatest), + ) + } + + // can call this imperatively and take the return value as gospel, or watch the $updateAvailable$ subject for the same info. checkForUpdates (): Promise { return forkJoin([ - this.apiService.getVersionLatest(), this.serverModel.watch().versionInstalled.pipe(take(1)), + this.apiService.getVersionLatest(), ]).pipe( - map(([vl, vi]) => this.emver.compare(vi, vl.versionLatest) === -1 ? vl.versionLatest : undefined), + map(([vi, vl]) => updateIsAvailable(this.emver, vi, vl.versionLatest)), catchError(e => { console.error(`OsUpdateService Error: ${e}`) return of(undefined) @@ -39,10 +65,20 @@ export class OsUpdateService { ).toPromise() } + async checkForAppsUpdate (): Promise { + const availableApps = await this.apiService.getAvailableApps() + return !!availableApps.find(app => this.emver.compare(app.versionInstalled, app.versionLatest) === -1) + } + async updateEmbassyOS (versionLatest: string): Promise { await this.apiService.updateAgent(versionLatest) this.serverModel.update({ status: ServerStatus.UPDATING }) this.$updateAvailable$.next(undefined) await this.navCtrl.navigateRoot('/embassy') } +} + +function updateIsAvailable (e: Emver, vi: string, vl: string): string | undefined { + if (!vi || !vl) return undefined + return e.compare(vi, vl) === -1 ? vl : undefined } \ No newline at end of file diff --git a/ui/src/app/services/startup-alerts.notifier.ts b/ui/src/app/services/startup-alerts.notifier.ts new file mode 100644 index 000000000..2c7fe6151 --- /dev/null +++ b/ui/src/app/services/startup-alerts.notifier.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@angular/core' +import { AlertController, ModalController, NavController } from '@ionic/angular' +import { combineLatest, EMPTY, iif, Observable, of } from 'rxjs' +import { concatMap, filter, take } from 'rxjs/operators' +import { OSWelcomePage } from '../modals/os-welcome/os-welcome.page' +import { ServerModel } from '../models/server-model' +import { exists } from '../util/misc.util' +import { ApiService } from './api/api.service' +import { ConfigService } from './config.service' +import { LoaderService } from './loader.service' +import { OsUpdateService } from './os-update.service' + +@Injectable({ providedIn: 'root' }) +export class GlobalAlertsNotifier { + constructor ( + private readonly osUpdateService: OsUpdateService, + private readonly alertCtrl: AlertController, + private readonly navCtrl: NavController, + private readonly loader: LoaderService, + private readonly config: ConfigService, + private readonly modalCtrl: ModalController, + private readonly server: ServerModel, + private readonly apiService: ApiService, + ) { + } + + init () { + of({ }).pipe( + concatMap(() => this.osWelcome$()), + concatMap(() => this.autoUpdateCheck$()), + ).subscribe() + } + + private osWelcome$ (): Observable { + const { welcomeAck, versionInstalled } = this.server.watch() + + return combineLatest([ welcomeAck, versionInstalled ]).pipe( + filter( ([wa, vi]) => !!vi && !wa), + take(1), // we will check and show welcome message at most once per app instance + concatMap(([_, vi]) => iif( + () => vi === this.config.version, + this.presentOsWelcome(vi), + EMPTY, + )), + ) + } + + private autoUpdateCheck$ (): Observable { + // this emits iff autoCheck is on and update available + return this.osUpdateService.watchForAutoCheckUpdateAvailable$().pipe( + filter(exists), + concatMap(async (vl) => { + const { update } = await this.presentAlertNewOS(vl) + if (update) { + return this.loader.displayDuringP( + this.osUpdateService.updateEmbassyOS(vl), + ).catch(e => alert(e)) + } + + try { + const newApps = await this.osUpdateService.checkForAppsUpdate() + if (newApps) { + return this.presentAlertNewApps() + } + } catch (e) { + console.error(`Exception checking for new apps: `, e) + } + }), + ) + } + + private async presentOsWelcome (vi: string): Promise { + const modal = await this.modalCtrl.create({ + backdropDismiss: false, + component: OSWelcomePage, + presentingElement: await this.modalCtrl.getTop(), + componentProps: { version: vi }, + }) + //kick this off async + this.apiService.acknowledgeOSWelcome(this.config.version).catch(e => { + console.error(`Unable to acknowledge OS welcome`, e) + }) + await modal.present() + } + + private async presentAlertNewApps () { + const alert = await this.alertCtrl.create({ + backdropDismiss: true, + header: 'Updates Available!', + message: 'New service updates are available in the Marketplace.', + buttons: [ + { + text: 'Cancel', + role: 'cancel', + }, + { + text: 'View in Marketplace', + handler: () => { + return this.navCtrl.navigateForward('/services/marketplace') + }, + }, + ], + }) + + await alert.present() + } + + private async presentAlertNewOS (versionLatest: string): Promise<{ cancel?: true, update?: true }> { + return new Promise(async resolve => { + const alert = await this.alertCtrl.create({ + backdropDismiss: true, + header: 'New EmbassyOS Version!', + message: `Update EmbassyOS to version ${versionLatest}?`, + buttons: [ + { + text: 'Not now', + role: 'cancel', + handler: () => resolve({ cancel: true }), + }, + { + text: 'Update', + handler: () => resolve({ update: true }), + }, + ], + }) + await alert.present() + }) + } +} \ No newline at end of file diff --git a/ui/src/app/services/sync.notifier.ts b/ui/src/app/services/sync.notifier.ts index 2360fc264..631fd79ce 100644 --- a/ui/src/app/services/sync.notifier.ts +++ b/ui/src/app/services/sync.notifier.ts @@ -6,6 +6,9 @@ import { OSWelcomePage } from '../modals/os-welcome/os-welcome.page' import { ApiService } from './api/api.service' import { Emver } from './emver.service' import { LoaderService } from './loader.service' +import { OsUpdateService } from './os-update.service' +import { filter, take, tap } from 'rxjs/operators' +import { exists } from '../util/misc.util' @Injectable({ providedIn: 'root', @@ -24,6 +27,7 @@ export class SyncNotifier { private readonly apiService: ApiService, private readonly loader: LoaderService, private readonly emver: Emver, + private readonly osUpdateService: OsUpdateService, ) { } async handleSpecial (server: Readonly): Promise { @@ -88,88 +92,6 @@ export class SyncNotifier { }) await modal.present() } - - private async handleUpdateCheck (server: Readonly) { - debugSync('handleUpdateCheck', server) - if (!server.autoCheckUpdates || this.checkedForUpdates) return - - this.checkedForUpdates = true - debugSync('handleUpdateCheck', 'checkedForUpdates=true') - if (server.versionLatest && this.emver.compare(server.versionInstalled, server.versionLatest) === -1) { - debugSync('handleUpdateCheck', 'OS Update') - // if cancel selected, move on to newApps - const { update } = await this.presentAlertNewOS(server.versionLatest) - debugSync('handleUpdateCheck', 'OS Update', 'response', update) - if (update) { - return this.updateEmbassyOS(server.versionLatest).catch(e => alert(e)) - } - } - - try { - debugSync('handleUpdateCheck', 'Apps Check') - - const availableApps = await this.apiService.getAvailableApps() - if (!!availableApps.find(app => this.emver.compare(app.versionInstalled, app.versionLatest) === -1)) { - debugSync('handleUpdateCheck', 'Apps Check', 'new apps found') - return this.presentAlertNewApps() - } - } catch (e) { - console.error(`Exception checking for new apps: `, e) - } - } - - private async presentAlertNewApps () { - const alert = await this.alertCtrl.create({ - backdropDismiss: true, - header: 'Updates Available!', - message: 'New service updates are available in the Marketplace.', - buttons: [ - { - text: 'Cancel', - role: 'cancel', - }, - { - text: 'View in Marketplace', - handler: () => { - return this.navCtrl.navigateForward('/services/marketplace') - }, - }, - ], - }) - - await alert.present() - } - - private async presentAlertNewOS (versionLatest: string): Promise<{ cancel?: true, update?: true }> { - return new Promise(async resolve => { - const alert = await this.alertCtrl.create({ - backdropDismiss: true, - header: 'New EmbassyOS Version!', - message: `Update EmbassyOS to version ${versionLatest}?`, - buttons: [ - { - text: 'Not now', - role: 'cancel', - handler: () => resolve({ cancel: true }), - }, - { - text: 'Update', - handler: () => resolve({ update: true }), - }, - ], - }) - await alert.present() - }) - } - - private async updateEmbassyOS (versionLatest: string) { - this.loader - .displayDuringAsync(async () => { - await this.apiService.updateAgent(versionLatest) - this.serverModel.update({ status: ServerStatus.UPDATING }) - }) - .catch(e => alert(e)) - } } // @TODO: remove diff --git a/ui/src/app/util/misc.util.ts b/ui/src/app/util/misc.util.ts index 6ac3dcc18..c268bed1a 100644 --- a/ui/src/app/util/misc.util.ts +++ b/ui/src/app/util/misc.util.ts @@ -158,4 +158,6 @@ export function uniqueBy (ts: T[], uniqueBy: (t: T) => string, prioritize: (t export function capitalizeFirstLetter (string: string): string { return string.charAt(0).toUpperCase() + string.slice(1) -} \ No newline at end of file +} + +export const exists = t => !!t \ No newline at end of file