diff --git a/ui/build-send-beta.sh b/ui/build-send-beta.sh index fbbc147cd..9d1a7bc8b 100755 --- a/ui/build-send-beta.sh +++ b/ui/build-send-beta.sh @@ -3,6 +3,7 @@ set -e echo "turn off mocks" echo "$( jq '.useMocks = false' use-mocks.json )" > use-mocks.json +echo "$( jq '.skipStartupAlerts = false' use-mocks.json )" > use-mocks.json echo "FILTER: rm -rf www" rm -rf www diff --git a/ui/src/app/models/app-types.ts b/ui/src/app/models/app-types.ts index 0c09065e6..4e4d4b0ca 100644 --- a/ui/src/app/models/app-types.ts +++ b/ui/src/app/models/app-types.ts @@ -34,6 +34,8 @@ export interface AppAvailableVersionSpecificInfo { // installed export interface AppInstalledPreview extends BaseApp { + lanAddress: string + lanEnabled: boolean torAddress: string versionInstalled: string ui: boolean diff --git a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html index a3175231e..970b8fda4 100644 --- a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html +++ b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.html @@ -20,10 +20,13 @@ hasFetchedFull: app.hasFetchedFull | async, iconURL: app.iconURL | async, title: app.title | async, - ui: app.ui | async + ui: app.ui | async, + lanAddress: app.lanAddress | async, + lanEnabled: app.lanEnabled | async, + launchDisabled: (app.status | async) !== 'RUNNING' || (!isTor && !($lanConnected$ | async)), + testingLanConnection: $testingLanConnection$ | async } as vars" class="ion-padding-bottom"> - @@ -83,7 +86,7 @@ - + LAUNCH @@ -97,20 +100,16 @@

Tor Address

-

{{ vars.torAddress }}

+

{{ vars.torAddress }}

- - -

LAN Address

-

{{ lanAddress }}

+

{{ vars.lanAddress }}

- - - + +
Backups diff --git a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.scss b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.scss index 8c1e254ae..6de287da0 100644 --- a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.scss +++ b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.scss @@ -59,3 +59,11 @@ right: -2px; --border-radius: 100px; } + +.lan-toggle { + width: 2em; + height: 1em; + --handle-width: 0.9em; + --handle-height: 0.9em; + margin-right: 10px; +} diff --git a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts index 62e48d0fd..9a20de05c 100644 --- a/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts +++ b/ui/src/app/pages/apps-routes/app-installed-show/app-installed-show.page.ts @@ -6,21 +6,20 @@ import { copyToClipboard } from 'src/app/util/web.util' import { AppModel, AppStatus } from 'src/app/models/app-model' import { AppInstalledFull } from 'src/app/models/app-types' import { ModelPreload } from 'src/app/models/model-preload' -import { chill, pauseFor } from 'src/app/util/misc.util' +import { chill, modulateTime, pauseFor } from 'src/app/util/misc.util' import { PropertySubject, peekProperties } from 'src/app/util/property-subject.util' import { AppBackupPage } from 'src/app/modals/app-backup/app-backup.page' import { LoaderService, markAsLoadingDuring$, markAsLoadingDuringP } from 'src/app/services/loader.service' -import { BehaviorSubject, Observable, of } from 'rxjs' +import { BehaviorSubject, combineLatest, from, merge, Observable, of, Subject } from 'rxjs' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' -import { catchError, concatMap, filter, switchMap, tap } from 'rxjs/operators' +import { catchError, concatMap, delay, filter, map, retryWhen, switchMap, take, tap } from 'rxjs/operators' import { Cleanup } from 'src/app/util/cleanup' import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component' import { Emver } from 'src/app/services/emver.service' import { displayEmver } from 'src/app/pipes/emver.pipe' import { ConfigService } from 'src/app/services/config.service' -import { ServerModel } from 'src/app/models/server-model' - +import { squash } from 'src/app/util/rxjs.util' @Component({ selector: 'app-installed-show', templateUrl: './app-installed-show.page.html', @@ -32,17 +31,21 @@ export class AppInstalledShowPage extends Cleanup { $error$ = new BehaviorSubject('') app: PropertySubject = { } as any - lanAddress = '' appId: string AppStatus = AppStatus showInstructions = false isConsulate: boolean isTor: boolean + // true iff service lan address has been tested and is accessible + $lanConnected$: BehaviorSubject = new BehaviorSubject(false) + // true during service lan address testing + $testingLanConnection$: BehaviorSubject = new BehaviorSubject(false) + dependencyDefintion = () => `Dependencies are other services which must be installed, configured appropriately, and started in order to start ${this.app.title.getValue()}` launchDefinition = `Launch A Service

This button appears only for services that can be accessed inside the browser. If a service does not have this button, you must access it using another interface, such as a mobile app, desktop app, or another service on the Embassy. Please view the instructions for a service for details on how to use it.

` launchOffDefinition = `Launch A Service

This button appears only for services that can be accessed inside the browser. Get your service running in order to launch!

` - launchLocalDefinition = `Launch A Service

This button appears only for services that can be accessed inside the browser. Visit your Embassy at its Tor address to launch this service!

` + launchLocalDefinition = `Launch A Service

This button appears only for services that can be accessed inside the browser. To launch this service over LAN, enable the toggle below by your service's LAN Address.

` @ViewChild(IonContent) content: IonContent @@ -59,7 +62,6 @@ export class AppInstalledShowPage extends Cleanup { private readonly appModel: AppModel, private readonly popoverController: PopoverController, private readonly emver: Emver, - private readonly serverModel: ServerModel, config: ConfigService, ) { super() @@ -69,21 +71,72 @@ export class AppInstalledShowPage extends Cleanup { async ngOnInit () { this.appId = this.route.snapshot.paramMap.get('appId') as string - const server = this.serverModel.peek() - this.lanAddress = `https://${this.appId}.${server.serverId}.local` this.cleanup( markAsLoadingDuring$(this.$loading$, this.preload.appFull(this.appId)) .pipe( tap(app => this.app = app), - concatMap(() => this.syncWhenDependencyInstalls()), //must be final in stack - catchError(e => of(this.setError(e))), + concatMap(app => + merge( + this.syncWhenDependencyInstalls(), + combineLatest([app.lanEnabled, this.$lanConnected$, app.status, this.$testingLanConnection$]).pipe( + filter(([_, __, s, alreadyConnecting]) => s === AppStatus.RUNNING && !alreadyConnecting), + concatMap(([enabled, connected]) => { + if (enabled && !connected) return markAsLoadingDuring$(this.$testingLanConnection$, this.testLanConnection()) + if (!enabled && connected) return of(this.$lanConnected$.next(false)) + return of() + }), + ), + ), + ), //must be final in stack + catchError(e => this.setError(e)), ).subscribe(), ) } + testLanConnection () : Observable { + if (!this.app.lanAddress) return of() + + return this.app.lanAddress.pipe( + switchMap(la => this.apiService.testConnection(la)), + retryWhen(errors => errors.pipe(delay(2500), take(20))), + catchError(() => of(false)), + take(1), + map(connected => this.$lanConnected$.next(connected)), + ) + } + + enableLan (): Observable { + return from(this.apiService.toggleAppLAN(this.appId, 'enable')).pipe(squash) + } + + disableLan (): Observable { + return from(this.apiService.toggleAppLAN(this.appId, 'disable')).pipe( + map(() => this.appModel.update({ id: this.appId, lanEnabled: false }), modulateTime(new Date(), 10, 'seconds')), + map(() => this.$lanConnected$.next(false)), + squash, + ) + } + + $lanToggled$ = new Subject() ionViewDidEnter () { markAsLoadingDuringP(this.$loadingDependencies$, this.getApp()) + this.cleanup( + combineLatest([this.$lanToggled$, this.app.lanEnabled, this.$testingLanConnection$]).pipe( + filter(([_, __, alreadyLoading]) => !alreadyLoading), + map(([e, _]) => [(e as any).detail.checked, _]), + // if the app is already in the desired state, we bail + // this can happen because ionChange triggers when the [checked] value changes + filter(([uiEnabled, appEnabled]) => (uiEnabled && !appEnabled) || (!uiEnabled && appEnabled)), + map(([enabled]) => enabled + ? this.enableLan().pipe(concatMap(() => this.testLanConnection())) + : this.disableLan(), + ), + concatMap(o => markAsLoadingDuring$(this.$testingLanConnection$, o).pipe( + catchError(e => this.setError(e)), + )), + ).subscribe({ error: e => console.error(e) }), + ) } async doRefresh (event: any) { @@ -113,7 +166,8 @@ export class AppInstalledShowPage extends Cleanup { const torAddress = this.app.torAddress.getValue() uiAddress = torAddress.startsWith('http') ? torAddress : `http://${torAddress}` } else { - uiAddress = this.lanAddress + const lanAddress = this.app.lanAddress.getValue() + uiAddress = lanAddress.startsWith('http') ? lanAddress : `http://${lanAddress}` } return window.open(uiAddress, '_blank') } @@ -183,8 +237,9 @@ export class AppInstalledShowPage extends Cleanup { } async copyLan () { + const app = peekProperties(this.app) let message = '' - await copyToClipboard(this.lanAddress).then(success => { message = success ? 'copied to clipboard!' : 'failed to copy' }) + await copyToClipboard(app.lanAddress).then(success => { message = success ? 'copied to clipboard!' : 'failed to copy' }) const toast = await this.toastCtrl.create({ header: message, @@ -324,8 +379,9 @@ export class AppInstalledShowPage extends Cleanup { return await popover.present() } - private setError (e: Error) { + private setError (e: Error): Observable { this.$error$.next(e.message) + return of() } private clearError () { diff --git a/ui/src/app/services/api/api.service.ts b/ui/src/app/services/api/api.service.ts index e5ffde85b..3bbd7846a 100644 --- a/ui/src/app/services/api/api.service.ts +++ b/ui/src/app/services/api/api.service.ts @@ -20,6 +20,7 @@ export abstract class ApiService { this.$unauthorizedApiResponse$.next() } + abstract testConnection (url: string): Promise abstract getCheckAuth (): Promise // Throws an error on failed auth. abstract postLogin (password: string): Promise // Throws an error on failed auth. abstract postLogout (): Promise // Throws an error on failed auth. @@ -28,6 +29,7 @@ export abstract class ApiService { abstract getServerMetrics (): Promise abstract getNotifications (page: number, perPage: number): Promise abstract deleteNotification (id: string): Promise + abstract toggleAppLAN (appId: string, toggle: 'enable' | 'disable'): Promise abstract updateAgent (version: any): Promise abstract acknowledgeOSWelcome (version: string): Promise abstract getAvailableApps (): Promise diff --git a/ui/src/app/services/api/live-api.service.ts b/ui/src/app/services/api/live-api.service.ts index a1b397f73..adf522823 100644 --- a/ui/src/app/services/api/live-api.service.ts +++ b/ui/src/app/services/api/live-api.service.ts @@ -5,11 +5,13 @@ import { AppAvailablePreview, AppAvailableFull, AppInstalledFull, AppInstalledPr import { S9Notification, SSHFingerprint, ServerModel, DiskInfo } from '../../models/server-model' import { ApiService, ReqRes } from './api.service' import { ApiServer, Unit } from './api-types' -import { HttpErrorResponse } from '@angular/common/http' +import { HttpClient, HttpErrorResponse } from '@angular/common/http' import { isUnauthorized } from 'src/app/util/web.util' import { Replace } from 'src/app/util/types.util' import { AppMetrics, parseMetricsPermissive } from 'src/app/util/metrics.util' import { modulateTime } from 'src/app/util/misc.util' +import { Observable, of, throwError } from 'rxjs' +import { catchError, mapTo } from 'rxjs/operators' @Injectable() export class LiveApiService extends ApiService { @@ -20,6 +22,10 @@ export class LiveApiService extends ApiService { private readonly serverModel: ServerModel, ) { super() } + testConnection (url: string): Promise { + return this.http.raw.get(url).pipe(mapTo(true as true), catchError(e => catchHttpStatusError(e))).toPromise() + } + // Used to check whether password or key is valid. If so, it will be used implicitly by all other calls. async getCheckAuth (): Promise { return this.http.serverRequest({ method: Method.GET, url: '/authenticate' }, { version: '' }) @@ -214,6 +220,10 @@ export class LiveApiService extends ApiService { }) } + async toggleAppLAN (appId: string, toggle: 'enable' | 'disable'): Promise { + return this.authRequest({ method: Method.POST, url: `/apps/${appId}/lan/${toggle}` }) + } + async addSSHKey (sshKey: string): Promise { const data: ReqRes.PostAddSSHKeyReq = { sshKey, @@ -275,3 +285,11 @@ const dryRunParam = (dryRun: boolean, first: boolean) => { return first ? `?dryrun` : `&dryrun` } +function catchHttpStatusError (error: HttpErrorResponse): Observable { + if (error.error instanceof ErrorEvent) { + // A client-side or network error occurred. Handle it accordingly. + return throwError('Not Connected') + } else { + return of(true) + } +} diff --git a/ui/src/app/services/api/mock-api.service.ts b/ui/src/app/services/api/mock-api.service.ts index e445a992c..f78464dbb 100644 --- a/ui/src/app/services/api/mock-api.service.ts +++ b/ui/src/app/services/api/mock-api.service.ts @@ -41,6 +41,18 @@ export class MockApiService extends ApiService { } } + testCounter = 0 + async testConnection (): Promise { + console.log('testing connection') + this.testCounter ++ + await pauseFor(1000) + if (this.testCounter > 5) { + return true + } else { + throw new Error('Not Connected') + } + } + async ejectExternalDisk (): Promise { await pauseFor(2000) return { } @@ -144,6 +156,10 @@ export class MockApiService extends ApiService { return mockAppDependentBreakages } + async toggleAppLAN (appId: string, toggle: 'enable' | 'disable'): Promise { + return { } + } + async restartApp (appId: string): Promise { return { } } diff --git a/ui/src/app/services/api/mock-app-fixures.ts b/ui/src/app/services/api/mock-app-fixures.ts index 3c115b05d..b4c8d8d59 100644 --- a/ui/src/app/services/api/mock-app-fixures.ts +++ b/ui/src/app/services/api/mock-app-fixures.ts @@ -24,6 +24,8 @@ export function toInstalledPreview (f: AppInstalledFull): AppInstalledPreview { iconURL: f.iconURL, torAddress: f.torAddress, ui: f.ui, + lanAddress: f.lanAddress, + lanEnabled: f.lanEnabled, } } @@ -47,8 +49,10 @@ export function toServiceBreakage (f: BaseApp): DependentBreakage { export const bitcoinI: AppInstalledFull = { id: 'bitcoind', versionInstalled: '0.18.1', + lanAddress: 'bitcoinLan.local', + lanEnabled: true, title: 'Bitcoin Core', - torAddress: 'sample-bitcoin-tor-address-and-some-more-tor-address.onion', + torAddress: '4acth47i6kxnvkewtm6q7ib2s3ufpo5sqbsnzjpbi7utijcltosqemad.onion', status: AppStatus.STOPPED, iconURL: 'assets/img/service-icons/bitcoind.png', instructions: 'some instructions', @@ -61,10 +65,12 @@ export const bitcoinI: AppInstalledFull = { export const lightningI: AppInstalledFull = { id: 'c-lightning', + lanAddress: 'lightningLan.local', + lanEnabled: true, status: AppStatus.RUNNING, title: 'C Lightning', versionInstalled: '1.0.0', - torAddress: 'sample-bitcoin-tor-address-and-some-more-tor-address.onion', + torAddress: '4acth47i6kxnvkewtm6q7ib2s3ufpo5sqbsnzjpbi7utijcltosqemad.onion', iconURL: 'assets/img/service-icons/bitwarden.png', instructions: 'some instructions', lastBackup: new Date().toISOString(), @@ -84,6 +90,8 @@ export const lightningI: AppInstalledFull = { export const cupsI: AppInstalledFull = { id: 'cups', + lanAddress: 'cupsLan.local', + lanEnabled: false, versionInstalled: '2.1.0', title: 'Cups Messenger', torAddress: 'sample-cups-tor-address.onion', diff --git a/ui/src/app/services/config.service.ts b/ui/src/app/services/config.service.ts index 46d14c46b..1fa99f7a0 100644 --- a/ui/src/app/services/config.service.ts +++ b/ui/src/app/services/config.service.ts @@ -1,5 +1,12 @@ import { Injectable } from '@angular/core' +const { useMocks, mockOver, skipStartupAlerts } = require('../../../use-mocks.json') as UseMocks + +type UseMocks = { + useMocks: boolean + mockOver: 'tor' | 'lan' + skipStartupAlerts: boolean +} @Injectable({ providedIn: 'root', }) @@ -8,17 +15,18 @@ export class ConfigService { version = require('../../../package.json').version api = { - useMocks: require('../../../use-mocks.json').useMocks, + useMocks, url: '/api', version: '/v0', root: '', // empty will default to same origin } + skipStartupAlerts = skipStartupAlerts isConsulateIos = window['platform'] === 'ios' isConsulateAndroid = window['platform'] === 'android' isTor () : boolean { - return this.api.useMocks || this.origin.endsWith('.onion') + return (this.api.useMocks && mockOver === 'tor') || this.origin.endsWith('.onion') } } diff --git a/ui/src/app/services/http.service.ts b/ui/src/app/services/http.service.ts index 3928a5eb4..3960217ff 100644 --- a/ui/src/app/services/http.service.ts +++ b/ui/src/app/services/http.service.ts @@ -13,6 +13,10 @@ export class HttpService { private readonly config: ConfigService, ) { } + get raw () : HttpClient { + return this.http + } + async serverRequest (options: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise { options.url = leadingSlash(`${this.config.api.url}${exists(overrides.version) ? overrides.version : this.config.api.version}${options.url}`) if ( this.config.api.root && this.config.api.root !== '' ) { diff --git a/ui/src/app/services/startup-alerts.notifier.ts b/ui/src/app/services/startup-alerts.notifier.ts index 7bd6c7a9f..cec5d8c8d 100644 --- a/ui/src/app/services/startup-alerts.notifier.ts +++ b/ui/src/app/services/startup-alerts.notifier.ts @@ -21,7 +21,30 @@ export class StartupAlertsNotifier { private readonly emver: Emver, private readonly osUpdateService: OsUpdateService, private readonly wizardBaker: WizardBaker, - ) { } + ) { + const welcome: Check = { + name: 'welcome', + shouldRun: s => this.shouldRunOsWelcome(s), + check: async s => s, + display: s => this.displayOsWelcome(s), + hasRun: this.config.skipStartupAlerts, + } + const osUpdate: Check = { + name: 'osUpdate', + shouldRun: s => this.shouldRunOsUpdateCheck(s), + check: s => this.osUpdateCheck(s), + display: vl => this.displayOsUpdateCheck(vl), + hasRun: this.config.skipStartupAlerts, + } + const apps: Check = { + name: 'apps', + shouldRun: s => this.shouldRunAppsCheck(s), + check: () => this.appsCheck(), + display: () => this.displayAppsCheck(), + hasRun: this.config.skipStartupAlerts, + } + this.checks = [welcome, osUpdate, apps] + } // This takes our three checks and filters down to those that should run. // Then, the reduce fires, quickly iterating through yielding a promise (previousDisplay) to the next element @@ -48,29 +71,7 @@ export class StartupAlertsNotifier { }, Promise.resolve(true)) } - welcome: Check = { - name: 'welcome', - shouldRun: s => this.shouldRunOsWelcome(s), - check: async s => s, - display: s => this.displayOsWelcome(s), - hasRun: false, - } - osUpdate: Check = { - name: 'osUpdate', - shouldRun: s => this.shouldRunOsUpdateCheck(s), - check: s => this.osUpdateCheck(s), - display: vl => this.displayOsUpdateCheck(vl), - hasRun: false, - } - apps: Check = { - name: 'apps', - shouldRun: s => this.shouldRunAppsCheck(s), - check: () => this.appsCheck(), - display: () => this.displayAppsCheck(), - hasRun: false, - } - - checks: Check[] = [this.welcome, this.osUpdate, this.apps] + checks: Check[] private shouldRunOsWelcome (s: S9Server): boolean { return !s.welcomeAck && s.versionInstalled === this.config.version diff --git a/ui/src/app/util/rxjs.util.ts b/ui/src/app/util/rxjs.util.ts index e4e2a9288..5179fa5be 100644 --- a/ui/src/app/util/rxjs.util.ts +++ b/ui/src/app/util/rxjs.util.ts @@ -1,5 +1,5 @@ import { Observable, from, interval, race, OperatorFunction, Observer, BehaviorSubject } from 'rxjs' -import { take, map, switchMap, delay, tap } from 'rxjs/operators' +import { take, map, switchMap, delay, tap, concatMap } from 'rxjs/operators' export function fromAsync$ (async: (s: S) => Promise, s: S): Observable export function fromAsync$ (async: () => Promise): Observable @@ -51,3 +51,15 @@ export function onCooldown (cooldown: number, o: () => Observable): Observ ), ) } + + +export function bindPipe (o: Observable, then: (t: T) => Observable): Observable +export function bindPipe (o: Observable, then1: (t: T) => Observable, then2: (s: S1) => Observable): Observable +export function bindPipe (o: Observable, then1: (t: T) => Observable, then2: (s: S1) => Observable, then3: (s: S2) => Observable): Observable +export function bindPipe (o: Observable, then1: (t: T) => Observable, then2: (s: S1) => Observable, then3: (s: S2) => Observable, then4: (s: S3) => Observable): Observable +export function bindPipe (o: Observable, ...thens: ((t: any) => Observable)[]): Observable { + const concatted = thens.map(m => concatMap(m)) + return concatted.reduce( (acc, next) => { + return acc.pipe(next) + }, o) +} \ No newline at end of file diff --git a/ui/use-mocks.json b/ui/use-mocks.json index 3425efc8a..dc204ef37 100644 --- a/ui/use-mocks.json +++ b/ui/use-mocks.json @@ -1,3 +1,5 @@ { - "useMocks": false + "useMocks": true, + "mockOver": "lan", + "skipStartupAlerts": true }