= 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
}