feat: move all frontend projects under the same Angular workspace (#1141)

* feat: move all frontend projects under the same Angular workspace

* Refactor/angular workspace (#1154)

* update frontend build steps

Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2022-01-31 14:01:33 -07:00
committed by GitHub
parent 7e6c852ebd
commit 574539faec
504 changed files with 11569 additions and 78972 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,455 @@
import { Dump, Revision } from 'patch-db-client'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { DataModel, DependencyError, Manifest, URL } from 'src/app/services/patch-db/data-model'
export module RR {
// DB
export type GetRevisionsRes = Revision[] | Dump<DataModel>
export type GetDumpRes = Dump<DataModel>
export type SetDBValueReq = WithExpire<{ pointer: string, value: any }> // db.put.ui
export type SetDBValueRes = WithRevision<null>
// auth
export type LoginReq = { password: string, metadata: SessionMetadata } // auth.login - unauthed
export type loginRes = null
export type LogoutReq = { } // auth.logout
export type LogoutRes = null
// server
export type SetShareStatsReq = WithExpire<{ value: boolean }> // server.config.share-stats
export type SetShareStatsRes = WithRevision<null>
export type GetServerLogsReq = { cursor?: string, before_flag?: boolean, limit?: number }
export type GetServerLogsRes = LogsRes
export type GetServerMetricsReq = { } // server.metrics
export type GetServerMetricsRes = Metrics
export type UpdateServerReq = WithExpire<{ }> // server.update
export type UpdateServerRes = WithRevision<'updating' | 'no-updates'>
export type RestartServerReq = { } // server.restart
export type RestartServerRes = null
export type ShutdownServerReq = { } // server.shutdown
export type ShutdownServerRes = null
export type SystemRebuildReq = { } // server.rebuild
export type SystemRebuildRes = null
// sessions
export type GetSessionsReq = { } // sessions.list
export type GetSessionsRes = {
current: string
sessions: { [hash: string]: Session }
}
export type KillSessionsReq = WithExpire<{ ids: string[] }> // sessions.kill
export type KillSessionsRes = WithRevision<null>
// marketplace URLs
export type SetEosMarketplaceReq = WithExpire<{ url: string }> // marketplace.eos.set
export type SetEosMarketplaceRes = WithRevision<null>
export type SetPackageMarketplaceReq = WithExpire<{ url: string }> // marketplace.package.set
export type SetPackageMarketplaceRes = WithRevision<null>
// password
export type UpdatePasswordReq = { password: string } // password.set
export type UpdatePasswordRes = null
// notification
export type GetNotificationsReq = WithExpire<{ before?: number, limit?: number }> // notification.list
export type GetNotificationsRes = WithRevision<ServerNotification<number>[]>
export type DeleteNotificationReq = { id: number } // notification.delete
export type DeleteNotificationRes = null
export type DeleteAllNotificationsReq = { before: number } // notification.delete-before
export type DeleteAllNotificationsRes = null
// wifi
export type SetWifiCountryReq = { country: string }
export type SetWifiCountryRes = null
export type GetWifiReq = { }
export type GetWifiRes = {
ssids: {
[ssid: string]: number
},
connected?: string,
country: string,
ethernet: boolean,
'available-wifi': AvailableWifi[]
}
export type AddWifiReq = { // wifi.add
ssid: string
password: string
priority: number
connect: boolean
}
export type AddWifiRes = null
export type ConnectWifiReq = { ssid: string } // wifi.connect
export type ConnectWifiRes = null
export type DeleteWifiReq = { ssid: string } // wifi.delete
export type DeleteWifiRes = null
// ssh
export type GetSSHKeysReq = { } // ssh.list
export type GetSSHKeysRes = SSHKey[]
export type AddSSHKeyReq = { key: string } // ssh.add
export type AddSSHKeyRes = SSHKey
export type DeleteSSHKeyReq = { fingerprint: string } // ssh.delete
export type DeleteSSHKeyRes = null
// backup
export type GetBackupTargetsReq = { } // backup.target.list
export type GetBackupTargetsRes = { [id: string]: BackupTarget }
export type AddBackupTargetReq = { // backup.target.cifs.add
hostname: string
path: string
username: string
password: string | null
}
export type AddBackupTargetRes = { [id: string]: CifsBackupTarget }
export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update
export type UpdateBackupTargetRes = AddBackupTargetRes
export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove
export type RemoveBackupTargetRes = null
export type GetBackupInfoReq = { 'target-id': string, password: string } // backup.target.info
export type GetBackupInfoRes = BackupInfo
export type CreateBackupReq = WithExpire<{ // backup.create
'target-id': string
'old-password': string | null
password: string
}>
export type CreateBackupRes = WithRevision<null>
// package
export type GetPackagePropertiesReq = { id: string } // package.properties
export type GetPackagePropertiesRes<T extends number> = PackagePropertiesVersioned<T>
export type LogsRes = { entries: Log[], 'start-cursor'?: string, 'end-cursor'?: string }
export type GetPackageLogsReq = { id: string, cursor?: string, before_flag?: boolean, limit?: number } // package.logs
export type GetPackageLogsRes = LogsRes
export type GetPackageMetricsReq = { id: string } // package.metrics
export type GetPackageMetricsRes = Metric
export type InstallPackageReq = WithExpire<{ id: string, 'version-spec'?: string }> // package.install
export type InstallPackageRes = WithRevision<null>
export type DryUpdatePackageReq = { id: string, version: string } // package.update.dry
export type DryUpdatePackageRes = Breakages
export type GetPackageConfigReq = { id: string } // package.config.get
export type GetPackageConfigRes = { spec: ConfigSpec, config: object }
export type DrySetPackageConfigReq = { id: string, config: object } // package.config.set.dry
export type DrySetPackageConfigRes = Breakages
export type SetPackageConfigReq = WithExpire<DrySetPackageConfigReq> // package.config.set
export type SetPackageConfigRes = WithRevision<null>
export type RestorePackagesReq = WithExpire<{ // package.backup.restore
ids: string[]
'target-id': string
'old-password': string | null,
password: string
}>
export type RestorePackagesRes = WithRevision<null>
export type ExecutePackageActionReq = { id: string, 'action-id': string, input?: object } // package.action
export type ExecutePackageActionRes = ActionResponse
export type StartPackageReq = WithExpire<{ id: string }> // package.start
export type StartPackageRes = WithRevision<null>
export type DryStopPackageReq = StopPackageReq // package.stop.dry
export type DryStopPackageRes = Breakages
export type StopPackageReq = WithExpire<{ id: string }> // package.stop
export type StopPackageRes = WithRevision<null>
export type DryUninstallPackageReq = UninstallPackageReq // package.uninstall.dry
export type DryUninstallPackageRes = Breakages
export type UninstallPackageReq = WithExpire<{ id: string }> // package.uninstall
export type UninstallPackageRes = WithRevision<null>
export type DeleteRecoveredPackageReq = { id: string } // package.delete-recovered
export type DeleteRecoveredPackageRes = WithRevision<null>
export type DryConfigureDependencyReq = { 'dependency-id': string, 'dependent-id': string } // package.dependency.configure.dry
export type DryConfigureDependencyRes = {
'old-config': object
'new-config': object
spec: ConfigSpec
}
// marketplace
export type GetMarketplaceDataReq = { }
export type GetMarketplaceDataRes = MarketplaceData
export type GetMarketplaceEOSReq = {
'eos-version-compat': string
}
export type GetMarketplaceEOSRes = MarketplaceEOS
export type GetMarketplacePackagesReq = {
ids?: { id: string, version: string }[]
'eos-version-compat': string
// iff !ids
category?: string
query?: string
page?: string
'per-page'?: string
}
export type GetMarketplacePackagesRes = MarketplacePkg[]
export type GetReleaseNotesReq = { id: string }
export type GetReleaseNotesRes = { [version: string]: string }
export type GetLatestVersionReq = { ids: string[] }
export type GetLatestVersionRes = { [id: string]: string }
}
export type WithExpire<T> = { 'expire-id'?: string } & T
export type WithRevision<T> = { response: T, revision?: Revision }
export interface MarketplaceData {
categories: string[]
}
export interface MarketplaceEOS {
version: string
headline: string
'release-notes': { [version: string]: string }
}
export interface MarketplacePkg {
icon: URL
license: URL
instructions: URL
manifest: Manifest
categories: string[]
versions: string[]
'dependency-metadata': {
[id: string]: {
title: string
icon: URL
}
},
}
export interface Breakages {
[id: string]: TaggedDependencyError
}
export interface TaggedDependencyError {
dependency: string
error: DependencyError
}
export interface Log {
timestamp: string
message: string
}
export interface ActionResponse {
message: string
value: string | null
copyable: boolean
qr: boolean
}
export interface Metrics {
[key: string]: {
[key: string]: {
value: string | number | null
unit?: string
}
}
}
export interface Metric {
[key: string]: {
value: string | number | null
unit?: string
}
}
export interface Session {
'last-active': string
'user-agent': string
metadata: SessionMetadata
}
export interface SessionMetadata {
platforms: PlatformType[]
}
export type PlatformType = 'cli' | 'ios' | 'ipad' | 'iphone' | 'android' | 'phablet' | 'tablet' | 'cordova' | 'capacitor' | 'electron' | 'pwa' | 'mobile' | 'mobileweb' | 'desktop' | 'hybrid'
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
export interface EmbassyOSRecoveryInfo {
version: string
full: boolean
'password-hash': string | null
'wrapped-key': string | null
}
export interface DiskBackupTarget {
type: 'disk'
vendor: string | null
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
'embassy-os': EmbassyOSRecoveryInfo | null
}
export interface CifsBackupTarget {
type: 'cifs'
hostname: string
path: string
username: string
mountable: boolean
'embassy-os': EmbassyOSRecoveryInfo | null
}
export type RecoverySource = DiskRecoverySource | CifsRecoverySource
export interface DiskRecoverySource {
type: 'disk'
logicalname: string // partition logicalname
}
export interface CifsRecoverySource {
type: 'cifs'
hostname: string
path: string
username: string
password: string
}
export interface DiskInfo {
logicalname: string
vendor: string | null
model: string | null
partitions: PartitionInfo[]
capacity: number
guid: string | null
}
export interface PartitionInfo {
logicalname: string
label: string | null
capacity: number
used: number | null
'embassy-os': EmbassyOsDiskInfo | null
}
export interface EmbassyOsDiskInfo {
version: string
full: boolean
}
export interface BackupInfo {
version: string,
timestamp: string,
'package-backups': {
[id: string]: PackageBackupInfo
}
}
export interface PackageBackupInfo {
title: string
version: string
'os-version': string
timestamp: string
}
export interface ServerSpecs {
[key: string]: string | number
}
export interface SSHKey {
'created-at': string
alg: string
hostname: string
fingerprint: string
}
export type ServerNotifications = ServerNotification<any>[]
export interface ServerNotification<T extends number> {
id: number
'package-id': string | null
'created-at': string
code: T
level: NotificationLevel
title: string
message: string
data: NotificationData<T>
}
export enum NotificationLevel {
Success = 'success',
Info = 'info',
Warning = 'warning',
Error = 'error',
}
export type NotificationData<T> = T extends 0 ? null :
T extends 1 ? BackupReport :
any
export interface BackupReport {
server: {
attempted: boolean
error: string | null
}
packages: {
[id: string]: {
error: string | null
}
}
}
export interface AvailableWifi {
ssid: string
strength: number
security: string []
}

View File

@@ -0,0 +1,219 @@
import { Subject, Observable } from 'rxjs'
import { Http, Update, Operation, Revision, Source, Store, RPCResponse } from 'patch-db-client'
import { RR } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { RequestError } from '../http.service'
import { map } from 'rxjs/operators'
export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
protected readonly sync$ = new Subject<Update<DataModel>>()
/** PatchDb Source interface. Post/Patch requests provide a source of patches to the db. */
// sequenceStream '_' is not used by the live api, but is overridden by the mock
watch$ (_?: Store<DataModel>): Observable<RPCResponse<Update<DataModel>>> {
return this.sync$.asObservable().pipe(map( result => ({ result,
jsonrpc: '2.0'})))
}
// for getting static files: ex icons, instructions, licenses
abstract getStatic (url: string): Promise<string>
// db
abstract getRevisions (since: number): Promise<RR.GetRevisionsRes>
abstract getDump (): Promise<RR.GetDumpRes>
protected abstract setDbValueRaw (params: RR.SetDBValueReq): Promise<RR.SetDBValueRes>
setDbValue = (params: RR.SetDBValueReq) => this.syncResponse(
() => this.setDbValueRaw(params),
)()
// auth
abstract login (params: RR.LoginReq): Promise<RR.loginRes>
abstract logout (params: RR.LogoutReq): Promise<RR.LogoutRes>
abstract getSessions (params: RR.GetSessionsReq): Promise<RR.GetSessionsRes>
abstract killSessions (params: RR.KillSessionsReq): Promise<RR.KillSessionsRes>
// server
protected abstract setShareStatsRaw (params: RR.SetShareStatsReq): Promise<RR.SetShareStatsRes>
setShareStats = (params: RR.SetShareStatsReq) => this.syncResponse(
() => this.setShareStatsRaw(params),
)()
abstract getServerLogs (params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes>
abstract getServerMetrics (params: RR.GetServerMetricsReq): Promise<RR.GetServerMetricsRes>
abstract getPkgMetrics (params: RR.GetPackageMetricsReq): Promise<RR.GetPackageMetricsRes>
protected abstract updateServerRaw (params: RR.UpdateServerReq): Promise<RR.UpdateServerRes>
updateServer = (params: RR.UpdateServerReq) => this.syncResponse(
() => this.updateServerWrapper(params),
)()
async updateServerWrapper (params: RR.UpdateServerReq) {
const res = await this.updateServerRaw(params)
if (res.response === 'no-updates') {
throw new Error('Could ont find a newer version of EmbassyOS')
}
return res
}
abstract restartServer (params: RR.UpdateServerReq): Promise<RR.RestartServerRes>
abstract shutdownServer (params: RR.ShutdownServerReq): Promise<RR.ShutdownServerRes>
abstract systemRebuild (params: RR.SystemRebuildReq): Promise<RR.SystemRebuildRes>
// marketplace URLs
abstract getEos (params: RR.GetMarketplaceEOSReq): Promise<RR.GetMarketplaceEOSRes>
abstract getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise<RR.GetMarketplaceDataRes>
abstract getMarketplacePkgs (params: RR.GetMarketplacePackagesReq): Promise<RR.GetMarketplacePackagesRes>
abstract getReleaseNotes (params: RR.GetReleaseNotesReq): Promise<RR.GetReleaseNotesRes>
abstract getLatestVersion (params: RR.GetLatestVersionReq): Promise<RR.GetLatestVersionRes>
// protected abstract setPackageMarketplaceRaw (params: RR.SetPackageMarketplaceReq): Promise<RR.SetPackageMarketplaceRes>
// setPackageMarketplace = (params: RR.SetPackageMarketplaceReq) => this.syncResponse(
// () => this.setPackageMarketplaceRaw(params),
// )()
// password
// abstract updatePassword (params: RR.UpdatePasswordReq): Promise<RR.UpdatePasswordRes>
// notification
abstract getNotificationsRaw (params: RR.GetNotificationsReq): Promise<RR.GetNotificationsRes>
getNotifications = (params: RR.GetNotificationsReq) => this.syncResponse<RR.GetNotificationsRes['response'], any>(
() => this.getNotificationsRaw(params),
)()
abstract deleteNotification (params: RR.DeleteNotificationReq): Promise<RR.DeleteNotificationRes>
abstract deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise<RR.DeleteAllNotificationsRes>
// wifi
abstract getWifi (params: RR.GetWifiReq, timeout: number): Promise<RR.GetWifiRes>
abstract setWifiCountry (params: RR.SetWifiCountryReq): Promise<RR.SetWifiCountryRes>
abstract addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes>
abstract connectWifi (params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes>
abstract deleteWifi (params: RR.DeleteWifiReq): Promise<RR.ConnectWifiRes>
// ssh
abstract getSshKeys (params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes>
abstract addSshKey (params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes>
abstract deleteSshKey (params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes>
// backup
abstract getBackupTargets (params: RR.GetBackupTargetsReq): Promise<RR.GetBackupTargetsRes>
abstract addBackupTarget (params: RR.AddBackupTargetReq): Promise<RR.AddBackupTargetRes>
abstract updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise<RR.UpdateBackupTargetRes>
abstract removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise<RR.RemoveBackupTargetRes>
abstract getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes>
protected abstract createBackupRaw (params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
createBackup = (params: RR.CreateBackupReq) => this.syncResponse(
() => this.createBackupRaw(params),
)()
// package
abstract getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes<2>['data']>
abstract getPackageLogs (params: RR.GetPackageLogsReq): Promise<RR.GetPackageLogsRes>
protected abstract installPackageRaw (params: RR.InstallPackageReq): Promise<RR.InstallPackageRes>
installPackage = (params: RR.InstallPackageReq) => this.syncResponse(
() => this.installPackageRaw(params),
)()
abstract dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise<RR.DryUpdatePackageRes>
abstract getPackageConfig (params: RR.GetPackageConfigReq): Promise<RR.GetPackageConfigRes>
abstract drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise<RR.DrySetPackageConfigRes>
protected abstract setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise<RR.SetPackageConfigRes>
setPackageConfig = (params: RR.SetPackageConfigReq) => this.syncResponse(
() => this.setPackageConfigRaw(params),
)()
protected abstract restorePackagesRaw (params: RR.RestorePackagesReq): Promise<RR.RestorePackagesRes>
restorePackages = (params: RR.RestorePackagesReq) => this.syncResponse(
() => this.restorePackagesRaw(params),
)()
abstract executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes>
protected abstract startPackageRaw (params: RR.StartPackageReq): Promise<RR.StartPackageRes>
startPackage = (params: RR.StartPackageReq) => this.syncResponse(
() => this.startPackageRaw(params),
)()
abstract dryStopPackage (params: RR.DryStopPackageReq): Promise<RR.DryStopPackageRes>
protected abstract stopPackageRaw (params: RR.StopPackageReq): Promise<RR.StopPackageRes>
stopPackage = (params: RR.StopPackageReq) => this.syncResponse(
() => this.stopPackageRaw(params),
)()
abstract dryUninstallPackage (params: RR.DryUninstallPackageReq): Promise<RR.DryUninstallPackageRes>
protected abstract uninstallPackageRaw (params: RR.UninstallPackageReq): Promise<RR.UninstallPackageRes>
uninstallPackage = (params: RR.UninstallPackageReq) => this.syncResponse(
() => this.uninstallPackageRaw(params),
)()
abstract dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes>
protected abstract deleteRecoveredPackageRaw (params: RR.UninstallPackageReq): Promise<RR.UninstallPackageRes>
deleteRecoveredPackage = (params: RR.UninstallPackageReq) => this.syncResponse(
() => this.deleteRecoveredPackageRaw(params),
)()
// Helper allowing quick decoration to sync the response patch and return the response contents.
// Pass in a tempUpdate function which returns a UpdateTemp corresponding to a temporary
// state change you'd like to enact prior to request and expired when request terminates.
private syncResponse<T, F extends (...args: any[]) => Promise<{ response: T, revision?: Revision }>> (f: F, temp?: Operation): (...args: Parameters<F>) => Promise<T> {
return (...a) => {
// let expireId = undefined
// if (temp) {
// expireId = uuid.v4()
// this.sync.next({ patch: [temp], expiredBy: expireId })
// }
return f(a)
.catch((e: RequestError) => {
if (e.revision) this.sync$.next(e.revision)
throw e
})
.then(({ response, revision }) => {
if (revision) this.sync$.next(revision)
return response
})
}
}
}

View File

@@ -0,0 +1,289 @@
import { Injectable } from '@angular/core'
import { HttpService, Method } from '../http.service'
import { ApiService } from './embassy-api.service'
import { RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
@Injectable()
export class LiveApiService extends ApiService {
constructor (
private readonly http: HttpService,
) {
super();
(window as any).rpcClient = this
}
async getStatic (url: string): Promise<string> {
return this.http.httpRequest({
method: Method.GET,
url,
responseType: 'text',
})
}
// db
async getRevisions (since: number): Promise<RR.GetRevisionsRes> {
return this.http.rpcRequest({ method: 'db.revisions', params: { since } })
}
async getDump (): Promise<RR.GetDumpRes> {
return this.http.rpcRequest({ method: 'db.dump' })
}
async setDbValueRaw (params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
return this.http.rpcRequest({ method: 'db.put.ui', params })
}
// auth
async login (params: RR.LoginReq): Promise<RR.loginRes> {
return this.http.rpcRequest({ method: 'auth.login', params })
}
async logout (params: RR.LogoutReq): Promise<RR.LogoutRes> {
return this.http.rpcRequest({ method: 'auth.logout', params })
}
async getSessions (params: RR.GetSessionsReq): Promise<RR.GetSessionsRes> {
return this.http.rpcRequest({ method: 'auth.session.list', params })
}
async killSessions (params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> {
return this.http.rpcRequest({ method: 'auth.session.kill', params })
}
// server
async setShareStatsRaw (params: RR.SetShareStatsReq): Promise<RR.SetShareStatsRes> {
return this.http.rpcRequest( { method: 'server.config.share-stats', params })
}
async getServerLogs (params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
return this.http.rpcRequest( { method: 'server.logs', params })
}
async getServerMetrics (params: RR.GetServerMetricsReq): Promise<RR.GetServerMetricsRes> {
return this.http.rpcRequest({ method: 'server.metrics', params })
}
async updateServerRaw (params: RR.UpdateServerReq): Promise<RR.UpdateServerRes> {
return this.http.rpcRequest({ method: 'server.update', params })
}
async restartServer (params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
return this.http.rpcRequest({ method: 'server.restart', params })
}
async shutdownServer (params: RR.ShutdownServerReq): Promise<RR.ShutdownServerRes> {
return this.http.rpcRequest({ method: 'server.shutdown', params })
}
async systemRebuild (params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
return this.http.rpcRequest({ method: 'server.rebuild', params })
}
// marketplace URLs
async getEos (params: RR.GetMarketplaceEOSReq): Promise<RR.GetMarketplaceEOSRes> {
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/eos/latest',
params,
})
}
async getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise<RR.GetMarketplaceDataRes> {
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/package/data',
params,
})
}
async getMarketplacePkgs (params: RR.GetMarketplacePackagesReq): Promise<RR.GetMarketplacePackagesRes> {
if (params.query) params.category = undefined
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/package/index',
params: {
...params,
ids: JSON.stringify(params.ids),
},
})
}
async getReleaseNotes (params: RR.GetReleaseNotesReq): Promise<RR.GetReleaseNotesRes> {
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/package/release-notes',
params,
})
}
async getLatestVersion (params: RR.GetLatestVersionReq): Promise<RR.GetLatestVersionRes> {
return this.http.httpRequest({
method: Method.GET,
url: '/marketplace/latest-version',
params,
})
}
// async setPackageMarketplaceRaw (params: RR.SetPackageMarketplaceReq): Promise<RR.SetPackageMarketplaceRes> {
// return this.http.rpcRequest({ method: 'marketplace.package.set', params })
// }
// password
// async updatePassword (params: RR.UpdatePasswordReq): Promise<RR.UpdatePasswordRes> {
// return this.http.rpcRequest({ method: 'password.set', params })
// }
// notification
async getNotificationsRaw (params: RR.GetNotificationsReq): Promise<RR.GetNotificationsRes> {
return this.http.rpcRequest({ method: 'notification.list', params })
}
async deleteNotification (params: RR.DeleteNotificationReq): Promise<RR.DeleteNotificationRes> {
return this.http.rpcRequest({ method: 'notification.delete', params })
}
async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise<RR.DeleteAllNotificationsRes> {
return this.http.rpcRequest({ method: 'notification.delete-before', params })
}
// wifi
async getWifi (params: RR.GetWifiReq, timeout?: number): Promise<RR.GetWifiRes> {
return this.http.rpcRequest({ method: 'wifi.get', params, timeout })
}
async setWifiCountry (params: RR.SetWifiCountryReq): Promise<RR.SetWifiCountryRes> {
return this.http.rpcRequest({ method: 'wifi.country.set', params })
}
async addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
return this.http.rpcRequest({ method: 'wifi.add', params })
}
async connectWifi (params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
return this.http.rpcRequest({ method: 'wifi.connect', params })
}
async deleteWifi (params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
return this.http.rpcRequest({ method: 'wifi.delete', params })
}
// ssh
async getSshKeys (params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
return this.http.rpcRequest({ method: 'ssh.list', params })
}
async addSshKey (params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.add', params })
}
async deleteSshKey (params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.delete', params })
}
// backup
async getBackupTargets (params: RR.GetBackupTargetsReq): Promise<RR.GetBackupTargetsRes> {
return this.http.rpcRequest({ method: 'backup.target.list', params })
}
async addBackupTarget (params: RR.AddBackupTargetReq): Promise<RR.AddBackupTargetRes> {
params.path = params.path.replace('/\\/g', '/')
return this.http.rpcRequest({ method: 'backup.target.cifs.add', params })
}
async updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise<RR.UpdateBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.update', params })
}
async removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise<RR.RemoveBackupTargetRes> {
return this.http.rpcRequest({ method: 'backup.target.cifs.remove', params })
}
async getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes> {
return this.http.rpcRequest({ method: 'backup.target.info', params })
}
async createBackupRaw (params: RR.CreateBackupReq): Promise <RR.CreateBackupRes> {
return this.http.rpcRequest({ method: 'backup.create', params })
}
// package
async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes < 2 > ['data'] > {
return this.http.rpcRequest({ method: 'package.properties', params })
.then(parsePropertiesPermissive)
}
async getPackageLogs (params: RR.GetPackageLogsReq): Promise<RR.GetPackageLogsRes> {
return this.http.rpcRequest( { method: 'package.logs', params })
}
async getPkgMetrics (params: RR.GetPackageMetricsReq): Promise<RR.GetPackageMetricsRes> {
return this.http.rpcRequest({ method: 'package.metrics', params })
}
async installPackageRaw (params: RR.InstallPackageReq): Promise<RR.InstallPackageRes> {
return this.http.rpcRequest({ method: 'package.install', params })
}
async dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise<RR.DryUpdatePackageRes> {
return this.http.rpcRequest({ method: 'package.update.dry', params })
}
async getPackageConfig (params: RR.GetPackageConfigReq): Promise<RR.GetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.get', params })
}
async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise<RR.DrySetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set.dry', params })
}
async setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise<RR.SetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set', params })
}
async restorePackagesRaw (params: RR.RestorePackagesReq): Promise<RR.RestorePackagesRes> {
return this.http.rpcRequest({ method: 'package.backup.restore', params })
}
async executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> {
return this.http.rpcRequest({ method: 'package.action', params })
}
async startPackageRaw (params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
return this.http.rpcRequest({ method: 'package.start', params })
}
async dryStopPackage (params: RR.DryStopPackageReq): Promise<RR.DryStopPackageRes> {
return this.http.rpcRequest({ method: 'package.stop.dry', params })
}
async stopPackageRaw (params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
return this.http.rpcRequest({ method: 'package.stop', params })
}
async dryUninstallPackage (params: RR.DryUninstallPackageReq): Promise<RR.DryUninstallPackageRes> {
return this.http.rpcRequest({ method: 'package.uninstall.dry', params })
}
async deleteRecoveredPackageRaw (params: RR.DeleteRecoveredPackageReq): Promise<RR.DeleteRecoveredPackageRes> {
return this.http.rpcRequest({ method: 'package.delete-recovered', params })
}
async uninstallPackageRaw (params: RR.UninstallPackageReq): Promise<RR.UninstallPackageRes> {
return this.http.rpcRequest({ method: 'package.uninstall', params })
}
async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes> {
return this.http.rpcRequest({ method: 'package.dependency.configure.dry', params })
}
}

View File

@@ -0,0 +1,789 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '../../util/misc.util'
import { ApiService } from './embassy-api.service'
import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client'
import { DataModel, DependencyErrorType, InstallProgress, PackageDataEntry, PackageMainStatus, PackageState, ServerStatus } from 'src/app/services/patch-db/data-model'
import { CifsBackupTarget, Log, RR, WithRevision } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!../../../../../../assets/markdown/md-sample.md'
import { BehaviorSubject } from 'rxjs'
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
import { mockPatchData } from './mock-patch'
@Injectable()
export class MockApiService extends ApiService {
readonly mockPatch$ = new BehaviorSubject<Update<DataModel>>(undefined)
private readonly revertTime = 4000
sequence: number
constructor (
private readonly bootstrapper: LocalStorageBootstrap,
) {
super()
}
async getStatic (url: string): Promise<string> {
await pauseFor(2000)
return markdown
}
// db
async getRevisions (since: number): Promise<RR.GetRevisionsRes> {
return this.getDump()
}
async getDump (): Promise<RR.GetDumpRes> {
const cache = await this.bootstrapper.init()
return {
id: cache.sequence,
value: cache.data,
expireId: null,
}
}
async setDbValueRaw (params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/ui' + params.pointer,
value: params.value,
},
]
return this.withRevision(patch)
}
// auth
async login (params: RR.LoginReq): Promise<RR.loginRes> {
await pauseFor(2000)
setTimeout(() => {
this.mockPatch$.next({ id: 1, value: mockPatchData, expireId: null })
}, 2000)
return null
}
async logout (params: RR.LogoutReq): Promise<RR.LogoutRes> {
await pauseFor(2000)
return null
}
async getSessions (params: RR.GetSessionsReq): Promise<RR.GetSessionsRes> {
await pauseFor(2000)
return Mock.Sessions
}
async killSessions (params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> {
await pauseFor(2000)
return null
}
// server
async setShareStatsRaw (params: RR.SetShareStatsReq): Promise<RR.SetShareStatsRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/share-stats',
value: params.value,
},
]
return this.withRevision(patch)
}
async getServerLogs (params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
await pauseFor(2000)
let entries: Log[]
if (Math.random() < .2) {
entries = Mock.ServerLogs
} else {
const arrLength = params.limit ? Math.ceil(params.limit / Mock.ServerLogs.length) : 10
entries = new Array(arrLength).fill(Mock.ServerLogs).reduce((acc, val) => acc.concat(val), [])
}
return {
entries,
'start-cursor': 'startCursor',
'end-cursor': 'endCursor',
}
}
async getServerMetrics (params: RR.GetServerMetricsReq): Promise<RR.GetServerMetricsRes> {
await pauseFor(2000)
return Mock.getServerMetrics()
}
async getPkgMetrics (params: RR.GetServerMetricsReq): Promise<RR.GetPackageMetricsRes> {
await pauseFor(2000)
return Mock.getAppMetrics()
}
async updateServerRaw (params: RR.UpdateServerReq): Promise<RR.UpdateServerRes> {
await pauseFor(2000)
const initialProgress = {
size: 10000,
downloaded: 0,
}
setTimeout(() => {
this.updateOSProgress(initialProgress.size)
}, 500)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/update-progress',
value: initialProgress,
},
]
return this.withRevision(patch, 'updating')
}
async restartServer (params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
await pauseFor(2000)
return null
}
async shutdownServer (params: RR.ShutdownServerReq): Promise<RR.ShutdownServerRes> {
await pauseFor(2000)
return null
}
async systemRebuild (params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
await pauseFor(2000)
return null
}
// marketplace URLs
async getEos (params: RR.GetMarketplaceEOSReq): Promise<RR.GetMarketplaceEOSRes> {
await pauseFor(2000)
return Mock.MarketplaceEos
}
async getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise<RR.GetMarketplaceDataRes> {
await pauseFor(2000)
return {
categories: ['featured', 'bitcoin', 'lightning', 'data', 'messaging', 'social', 'alt coin'],
}
}
async getMarketplacePkgs (params: RR.GetMarketplacePackagesReq): Promise<RR.GetMarketplacePackagesRes> {
await pauseFor(2000)
return Mock.MarketplacePkgsList
}
async getReleaseNotes (params: RR.GetReleaseNotesReq): Promise<RR.GetReleaseNotesRes> {
await pauseFor(2000)
return Mock.ReleaseNotes
}
async getLatestVersion (params: RR.GetLatestVersionReq): Promise<RR.GetLatestVersionRes> {
await pauseFor(2000)
return params.ids.reduce((obj, id) => {
obj[id] = '1.3.0'
return obj
}, { })
}
// async setPackageMarketplaceRaw (params: RR.SetPackageMarketplaceReq): Promise<RR.SetPackageMarketplaceRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/server-info/package-marketplace',
// value: params.url,
// },
// ]
// return this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } })
// }
// password
// async updatePassword (params: RR.UpdatePasswordReq): Promise<RR.UpdatePasswordRes> {
// await pauseFor(2000)
// return null
// }
// notification
async getNotificationsRaw (params: RR.GetNotificationsReq): Promise<RR.GetNotificationsRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/unread-notification-count',
value: 0,
},
]
return this.withRevision(patch, Mock.Notifications)
}
async deleteNotification (params: RR.DeleteNotificationReq): Promise<RR.DeleteNotificationRes> {
await pauseFor(2000)
return null
}
async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise<RR.DeleteAllNotificationsRes> {
await pauseFor(2000)
return null
}
// wifi
async getWifi (params: RR.GetWifiReq): Promise < RR.GetWifiRes > {
await pauseFor(2000)
return Mock.Wifi
}
async setWifiCountry (params: RR.SetWifiCountryReq): Promise <RR.SetWifiCountryRes> {
await pauseFor(2000)
return null
}
async addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
await pauseFor(2000)
return null
}
async connectWifi (params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
await pauseFor(2000)
return null
}
async deleteWifi (params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
await pauseFor(2000)
return null
}
// ssh
async getSshKeys (params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
await pauseFor(2000)
return Mock.SshKeys
}
async addSshKey (params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
await pauseFor(2000)
return Mock.SshKey
}
async deleteSshKey (params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
await pauseFor(2000)
return null
}
// backup
async getBackupTargets (params: RR.GetBackupTargetsReq): Promise<RR.GetBackupTargetsRes> {
await pauseFor(2000)
return Mock.BackupTargets
}
async addBackupTarget (params: RR.AddBackupTargetReq): Promise<RR.AddBackupTargetRes> {
await pauseFor(2000)
const { hostname, path, username } = params
return {
'latfgvwdbhjsndmk': {
type: 'cifs',
hostname,
path: path.replace(/\\/g, '/'),
username,
mountable: true,
'embassy-os': null,
},
}
}
async updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise<RR.UpdateBackupTargetRes> {
await pauseFor(2000)
const { id, hostname, path, username } = params
return {
[id]: {
...Mock.BackupTargets[id] as CifsBackupTarget,
hostname,
path,
username,
},
}
}
async removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise<RR.RemoveBackupTargetRes> {
await pauseFor(2000)
return null
}
async getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes> {
await pauseFor(2000)
return Mock.BackupInfo
}
async createBackupRaw (params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
await pauseFor(2000)
const path = '/server-info/status'
const ids = ['bitcoind', 'lnd']
setTimeout(async () => {
for (let i = 0; i < ids.length; i++) {
const appPath = `/package-data/${ids[i]}/installed/status/main/status`
const appPatch = [
{
op: PatchOp.REPLACE,
path: appPath,
value: PackageMainStatus.BackingUp,
},
]
this.updateMock(appPatch)
await pauseFor(8000)
appPatch[0].value = PackageMainStatus.Stopped
this.updateMock(appPatch)
}
await pauseFor(1000)
// set server back to running
const lastPatch = [
{
op: PatchOp.REPLACE,
path,
value: ServerStatus.Running,
},
]
this.updateMock(lastPatch)
}, 500)
const originalPatch = [
{
op: PatchOp.REPLACE,
path,
value: ServerStatus.BackingUp,
},
]
return this.withRevision(originalPatch)
}
// package
async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes<2>['data']> {
await pauseFor(2000)
return parsePropertiesPermissive(Mock.PackageProperties)
}
async getPackageLogs (params: RR.GetPackageLogsReq): Promise<RR.GetPackageLogsRes> {
await pauseFor(2000)
let entries
if (Math.random() < .2) {
entries = Mock.PackageLogs
} else {
const arrLength = params.limit ? Math.ceil(params.limit / Mock.PackageLogs.length) : 10
entries = new Array(arrLength).fill(Mock.PackageLogs).reduce((acc, val) => acc.concat(val), [])
}
return {
entries,
'start-cursor': 'startCursor',
'end-cursor': 'endCursor',
}
}
async installPackageRaw (params: RR.InstallPackageReq): Promise<RR.InstallPackageRes> {
await pauseFor(2000)
const initialProgress: InstallProgress = {
size: 120,
downloaded: 0,
'download-complete': false,
validated: 0,
'validation-complete': false,
unpacked: 0,
'unpack-complete': false,
}
setTimeout(async () => {
this.updateProgress(params.id, initialProgress)
}, 1000)
const pkg: PackageDataEntry = {
...Mock.LocalPkgs[params.id],
state: PackageState.Installing,
'install-progress': initialProgress,
installed: undefined,
}
const patch = [
{
op: PatchOp.ADD,
path: `/package-data/${params.id}`,
value: pkg,
},
]
return this.withRevision(patch)
}
async dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise<RR.DryUpdatePackageRes> {
await pauseFor(2000)
return { }
}
async getPackageConfig (params: RR.GetPackageConfigReq): Promise<RR.GetPackageConfigRes> {
await pauseFor(2000)
return {
config: Mock.MockConfig,
spec: Mock.ConfigSpec,
}
}
async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise<RR.DrySetPackageConfigRes> {
await pauseFor(2000)
return { }
}
async setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise<RR.SetPackageConfigRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/package-data/${params.id}/installed/status/configured`,
value: true,
},
]
return this.withRevision(patch)
}
async restorePackagesRaw (params: RR.RestorePackagesReq): Promise<RR.RestorePackagesRes> {
await pauseFor(2000)
const patch: Operation[] = params.ids.map(id => {
const initialProgress: InstallProgress = {
size: 120,
downloaded: 120,
'download-complete': true,
validated: 0,
'validation-complete': false,
unpacked: 0,
'unpack-complete': false,
}
const pkg: PackageDataEntry = {
...Mock.LocalPkgs[id],
state: PackageState.Restoring,
'install-progress': initialProgress,
installed: undefined,
}
setTimeout(async () => {
this.updateProgress(id, initialProgress)
}, 2000)
return {
op: PatchOp.ADD,
path: `/package-data/${id}`,
value: pkg,
}
})
return this.withRevision(patch)
}
async executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> {
await pauseFor(2000)
return Mock.ActionResponse
}
async startPackageRaw (params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
const path = `/package-data/${params.id}/installed/status/main`
await pauseFor(2000)
setTimeout(async () => {
const patch2 = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: PackageMainStatus.Running,
},
{
op: PatchOp.REPLACE,
path: path + '/started',
value: new Date().toISOString(),
},
]
this.updateMock(patch2)
const patch3 = [
{
op: PatchOp.REPLACE,
path: path + '/health',
value: {
'ephemeral-health-check': {
result: 'starting',
},
'unnecessary-health-check': {
result: 'disabled',
},
},
},
]
this.updateMock(patch3)
await pauseFor(2000)
const patch4 = [
{
op: PatchOp.REPLACE,
path: path + '/health',
value: {
'ephemeral-health-check': {
result: 'starting',
},
'unnecessary-health-check': {
result: 'disabled',
},
'chain-state': {
result: 'loading',
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
result: 'success',
},
'rpc-interface': {
result: 'failure',
error: 'RPC interface unreachable.',
},
},
},
]
this.updateMock(patch4)
}, 2000)
const originalPatch = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: PackageMainStatus.Starting,
},
]
return this.withRevision(originalPatch)
}
async dryStopPackage (params: RR.DryStopPackageReq): Promise<RR.DryStopPackageRes> {
await pauseFor(2000)
return {
'lnd': {
dependency: 'bitcoind',
error: {
type: DependencyErrorType.NotRunning,
},
},
}
}
async stopPackageRaw (params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
await pauseFor(2000)
const path = `/package-data/${params.id}/installed/status/main`
setTimeout(() => {
const patch2 = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: PackageMainStatus.Stopped,
},
]
this.updateMock(patch2)
}, this.revertTime)
const patch = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: PackageMainStatus.Stopping,
},
{
op: PatchOp.REPLACE,
path: path + '/health',
value: { },
},
]
return this.withRevision(patch)
}
async dryUninstallPackage (params: RR.DryUninstallPackageReq): Promise<RR.DryUninstallPackageRes> {
await pauseFor(2000)
return { }
}
async uninstallPackageRaw (params: RR.UninstallPackageReq): Promise<RR.UninstallPackageRes> {
await pauseFor(2000)
setTimeout(async () => {
const patch2 = [
{
op: PatchOp.REMOVE,
path: `/package-data/${params.id}`,
} as RemoveOperation,
]
this.updateMock(patch2)
}, this.revertTime)
const patch = [
{
op: PatchOp.REPLACE,
path: `/package-data/${params.id}/state`,
value: PackageState.Removing,
},
]
return this.withRevision(patch)
}
async deleteRecoveredPackageRaw (params: RR.DeleteRecoveredPackageReq): Promise<RR.DeleteRecoveredPackageRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REMOVE,
path: `/recovered-packages/${params.id}`,
} as RemoveOperation,
]
return this.withRevision(patch)
}
async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes> {
await pauseFor(2000)
return {
'old-config': Mock.MockConfig,
'new-config': Mock.MockDependencyConfig,
spec: Mock.ConfigSpec,
}
}
private async updateProgress (id: string, initialProgress: InstallProgress): Promise<void> {
const phases = [
{ progress: 'downloaded', completion: 'download-complete'},
{ progress: 'validated', completion: 'validation-complete'},
{ progress: 'unpacked', completion: 'unpack-complete'},
]
for (let phase of phases) {
let i = initialProgress[phase.progress]
while (i < initialProgress.size) {
await pauseFor(250)
i = Math.min(i + 5, initialProgress.size)
initialProgress[phase.progress] = i
if (i === initialProgress.size) {
initialProgress[phase.completion] = true
}
const patch = [
{
op: PatchOp.REPLACE,
path: `/package-data/${id}/install-progress`,
value: initialProgress,
},
]
this.updateMock(patch)
}
}
setTimeout(() => {
const patch2: any = [
{
op: PatchOp.REPLACE,
path: `/package-data/${id}`,
value: { ...Mock.LocalPkgs[id] },
},
{
op: PatchOp.REMOVE,
path: `/package-data/${id}/install-progress`,
},
]
this.updateMock(patch2)
}, 1000)
}
private async updateOSProgress (size: number) {
let downloaded = 0
while (downloaded < size) {
await pauseFor(250)
downloaded += 500
const patch = [
{
op: PatchOp.REPLACE,
path: `/server-info/update-progress/downloaded`,
value: downloaded,
},
]
this.updateMock(patch)
}
const patch2 = [
{
op: PatchOp.REPLACE,
path: `/server-info/update-progress/downloaded`,
value: size,
},
]
this.updateMock(patch2)
setTimeout(async () => {
const patch3: Operation[] = [
{
op: PatchOp.REPLACE,
path: '/server-info/status',
value: ServerStatus.Updated,
},
{
op: PatchOp.REMOVE,
path: '/server-info/update-progress',
},
]
this.updateMock(patch3)
// quickly revert server to "running" for continued testing
await pauseFor(100)
const patch4 = [
{
op: PatchOp.REPLACE,
path: '/server-info/status',
value: ServerStatus.Running,
},
]
this.updateMock(patch4)
}, 1000)
}
private async updateMock (patch: Operation[]): Promise<void> {
if (!this.sequence) {
const { sequence } = await this.bootstrapper.init()
this.sequence = sequence
}
const revision = {
id: ++this.sequence,
patch,
expireId: null,
}
this.mockPatch$.next(revision)
}
private async withRevision<T> (patch: Operation[], response: T = null): Promise<WithRevision<T>> {
if (!this.sequence) {
const { sequence } = await this.bootstrapper.init()
this.sequence = sequence
}
const revision = {
id: ++this.sequence,
patch,
expireId: null,
}
return { response, revision }
}
}

View File

@@ -0,0 +1,620 @@
import { DataModel, DependencyErrorType, DockerIoFormat, HealthResult, Manifest, PackageMainStatus, PackageState, ServerStatus } from 'src/app/services/patch-db/data-model'
export const mockPatchData: DataModel = {
'ui': {
'name': 'Matt\'s Embassy',
'auto-check-updates': true,
'pkg-order': [],
'ack-welcome': '1.0.0',
'ack-share-stats': false,
},
'server-info': {
'id': 'embassy-abcdefgh',
'version': '0.3.0',
'last-backup': null,
'status': ServerStatus.Running,
'lan-address': 'https://embassy-abcdefgh.local',
'tor-address': 'http://myveryownspecialtoraddress.onion',
'eos-marketplace': 'https://beta-registry-0-3.start9labs.com',
'package-marketplace': null,
'share-stats': false,
'unread-notification-count': 4,
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'eos-version-compat': '>=0.3.0',
},
'recovered-packages': {
'btc-rpc-proxy': {
'title': 'Bitcoin Proxy',
'icon': 'assets/img/service-icons/btc-rpc-proxy.png',
'version': '0.2.2',
},
},
'package-data': {
'bitcoind': {
'state': PackageState.Installed,
'static-files': {
'license': '/public/package-data/bitcoind/0.20.0/LICENSE.md',
'icon': '/assets/img/service-icons/bitcoind.png',
'instructions': '/public/package-data/bitcoind/0.20.0/INSTRUCTIONS.md',
},
'manifest': {
'id': 'bitcoind',
'title': 'Bitcoin Core',
'version': '0.20.0',
'description': {
'short': 'A Bitcoin full node by Bitcoin Core.',
'long': 'Bitcoin is a decentralized consensus protocol and settlement network.',
},
'release-notes': 'Taproot, Schnorr, and more.',
'license': 'MIT',
'wrapper-repo': 'https://github.com/start9labs/bitcoind-wrapper',
'upstream-repo': 'https://github.com/bitcoin/bitcoin',
'support-site': 'https://bitcoin.org',
'marketing-site': 'https://bitcoin.org',
'donation-url': 'https://start9.com',
'alerts': {
'install': 'Bitcoin can take over a week to sync.',
'uninstall': 'Chain state will be lost, as will any funds stored on your Bitcoin Core waller that have not been backed up.',
'restore': null,
'start': null,
'stop': 'Stopping Bitcoin is bad for your health.',
},
'main': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': '.49m',
},
'health-checks': {
'chain-state': { 'name': 'Chain State', 'description': 'Checks the chainstate' },
'ephemeral-health-check': { 'name': 'Ephemeral Health Check', 'description': 'Checks to see if your new user registrations are on. If they are but you\'re not expecting any new user signups, you should disable this in Config, as anyone who knows your onion URL can create accounts on your server.' },
'p2p-interface': { 'name': 'P2P Interface', 'description': 'Checks to see if your new user registrations are on. If they are but you\'re not expecting any new user signups, you should disable this in Config, as anyone who knows your onion URL can create accounts on your server.' },
'rpc-interface': { 'name': 'RPC Interface', 'description': 'Checks the RPC Interface' },
'unnecessary-health-check': { 'name': 'Unneccessary Health Check', 'description': 'Is totally not necessary to do this health check.' },
} as any,
'config': {
'get': { },
'set': { },
} as any,
'volumes': { },
'min-os-version': '0.2.12',
'interfaces': {
'ui': {
'name': 'Node Visualizer',
'description': 'Web application for viewing information about your node and the Bitcoin network.',
'ui': true,
'tor-config': {
'port-mapping': { },
},
'lan-config': { },
'protocols': [],
},
'rpc': {
'name': 'RPC',
'description': 'Used by wallets to interact with your Bitcoin Core node.',
'ui': false,
'tor-config': {
'port-mapping': { },
},
'lan-config': { },
'protocols': [],
},
'p2p': {
'name': 'P2P',
'description': 'Used by other Bitcoin nodes to communicate and interact with your node.',
'ui': false,
'tor-config': {
'port-mapping': { },
},
'lan-config': { },
'protocols': [],
},
},
'backup': {
'create': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': null,
},
'restore': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': null,
},
},
'migrations': null,
'actions': {
'resync': {
'name': 'Resync Blockchain',
'description': 'Use this to resync the Bitcoin blockchain from genesis',
'warning': 'This will take a couple of days.',
'allowed-statuses': [
PackageMainStatus.Running,
PackageMainStatus.Stopped,
],
'implementation': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': null,
},
'input-spec': {
'reason': {
'type': 'string',
'name': 'Re-sync Reason',
'description': 'Your reason for re-syncing. Why are you doing this?',
'nullable': false,
'masked': false,
'copyable': false,
'pattern': '^[a-zA-Z]+$',
'pattern-description': 'Must contain only letters.',
},
'name': {
'type': 'string',
'name': 'Your Name',
'description': 'Tell the class your name.',
'nullable': true,
'masked': false,
'copyable': false,
'pattern': null,
'pattern-description': null,
'warning': 'You may loose all your money by providing your name.',
},
'notifications': {
'name': 'Notification Preferences',
'type': 'list',
'subtype': 'enum',
'description': 'how you want to be notified',
'range': '[1,3]',
'default': [
'email',
],
'spec': {
'value-names': {
'email': 'Email',
'text': 'Text',
'call': 'Call',
'push': 'Push',
'webhook': 'Webhook',
},
'values': [
'email',
'text',
'call',
'push',
'webhook',
],
},
},
'days-ago': {
'type': 'number',
'name': 'Days Ago',
'description': 'Number of days to re-sync.',
'nullable': false,
'default': 100,
'range': '[0, 9999]',
'integral': true,
},
'top-speed': {
'type': 'number',
'name': 'Top Speed',
'description': 'The fastest you can possibly run.',
'nullable': false,
'default': null,
'range': '[-1000, 1000]',
'integral': false,
'units': 'm/s',
},
'testnet': {
'name': 'Testnet',
'type': 'boolean',
'description': 'determines whether your node is running on testnet or mainnet',
'warning': 'Chain will have to resync!',
'default': false,
},
'randomEnum': {
'name': 'Random Enum',
'type': 'enum',
'value-names': {
'null': 'Null',
'good': 'Good',
'bad': 'Bad',
'ugly': 'Ugly',
},
'default': 'null',
'description': 'This is not even real.',
'warning': 'Be careful changing this!',
'values': [
'null',
'good',
'bad',
'ugly',
],
},
'emergency-contact': {
'name': 'Emergency Contact',
'type': 'object',
'unique-by': null,
'description': 'The person to contact in case of emergency.',
'spec': {
'name': {
'type': 'string',
'name': 'Name',
'description': null,
'nullable': false,
'masked': false,
'copyable': false,
'pattern': '^[a-zA-Z]+$',
'pattern-description': 'Must contain only letters.',
},
'email': {
'type': 'string',
'name': 'Email',
'description': null,
'nullable': false,
'masked': false,
'copyable': true,
},
},
},
'ips': {
'name': 'Whitelist IPs',
'type': 'list',
'subtype': 'string',
'description': 'external ip addresses that are authorized to access your Bitcoin node',
'warning': 'Any IP you allow here will have RPC access to your Bitcoin node.',
'range': '[1,10]',
'default': [
'192.168.1.1',
],
'spec': {
'pattern': '^[0-9]{1,3}([,.][0-9]{1,3})?$',
'pattern-description': 'Must be a valid IP address',
'masked': false,
'copyable': false,
},
},
'bitcoinNode': {
'name': 'Bitcoin Node Settings',
'type': 'union',
'unique-by': null,
'description': 'The node settings',
'default': 'internal',
'warning': 'Careful changing this',
'tag': {
'id': 'type',
'name': 'Type',
'variant-names': {
'internal': 'Internal',
'external': 'External',
},
},
'variants': {
'internal': {
'lan-address': {
'name': 'LAN Address',
'type': 'pointer',
'subtype': 'package',
'target': 'lan-address',
'package-id': 'bitcoind',
'description': 'the lan address',
'interface': '',
},
'friendly-name': {
'name': 'Friendly Name',
'type': 'string',
'description': 'the lan address',
'nullable': true,
'masked': false,
'copyable': false,
},
},
'external': {
'public-domain': {
'name': 'Public Domain',
'type': 'string',
'description': 'the public address of the node',
'nullable': false,
'default': 'bitcoinnode.com',
'pattern': '.*',
'pattern-description': 'anything',
'masked': false,
'copyable': true,
},
},
},
},
},
},
},
'permissions': { },
'dependencies': { },
},
'installed': {
'manifest': { } as Manifest,
'last-backup': null,
'status': {
'configured': true,
'main': {
'status': PackageMainStatus.Running,
'started': '2021-06-14T20:49:17.774Z',
'health': {
'ephemeral-health-check': {
'result': HealthResult.Starting,
},
'chain-state': {
'result': HealthResult.Loading,
'message': 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
'result': HealthResult.Success,
},
'rpc-interface': {
'result': HealthResult.Failure,
'error': 'RPC interface unreachable.',
},
'unnecessary-health-check': {
'result': HealthResult.Disabled,
},
},
},
'dependency-errors': { },
},
'interface-addresses': {
'ui': {
'tor-address': 'bitcoind-ui-address.onion',
'lan-address': 'bitcoind-ui-address.local',
},
'rpc': {
'tor-address': 'bitcoind-rpc-address.onion',
'lan-address': 'bitcoind-rpc-address.local',
},
'p2p': {
'tor-address': 'bitcoind-p2p-address.onion',
'lan-address': 'bitcoind-p2p-address.local',
},
},
'system-pointers': [],
'current-dependents': {
'lnd': {
'pointers': [],
'health-checks': [],
},
},
'current-dependencies': { },
'dependency-info': { },
},
},
'lnd': {
'state': PackageState.Installed,
'static-files': {
'license': '/public/package-data/lnd/0.11.1/LICENSE.md',
'icon': '/assets/img/service-icons/lnd.png',
'instructions': '/public/package-data/lnd/0.11.1/INSTRUCTIONS.md',
},
'manifest': {
'id': 'lnd',
'title': 'Lightning Network Daemon',
'version': '0.11.1',
'description': {
'short': 'A bolt spec compliant client.',
'long': 'More info about LND. More info about LND. More info about LND.',
},
'release-notes': 'Dual funded channels!',
'license': 'MIT',
'wrapper-repo': 'https://github.com/start9labs/lnd-wrapper',
'upstream-repo': 'https://github.com/lightningnetwork/lnd',
'support-site': 'https://lightning.engineering/',
'marketing-site': 'https://lightning.engineering/',
'donation-url': null,
'alerts': {
'install': null,
'uninstall': null,
'restore': 'If this is a duplicate instance of the same LND node, you may loose your funds.',
'start': 'Starting LND is good for your health.',
'stop': null,
},
'main': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': '0.5s',
},
'health-checks': { },
'config': {
'get': null,
'set': null,
},
'volumes': { },
'min-os-version': '0.2.12',
'interfaces': {
'rpc': {
'name': 'RPC interface',
'description': 'Good for connecting to your node at a distance.',
'ui': true,
'tor-config': {
'port-mapping': { },
},
'lan-config': {
'44': {
'ssl': true,
'mapping': 33,
},
},
'protocols': [],
},
'grpc': {
'name': 'GRPC',
'description': 'Certain wallet use grpc.',
'ui': false,
'tor-config': {
'port-mapping': { },
},
'lan-config': {
'66': {
'ssl': true,
'mapping': 55,
},
},
'protocols': [],
},
},
'backup': {
'create': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': null,
},
'restore': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': null,
},
},
'migrations': null,
'actions': {
'resync': {
'name': 'Resync Network Graph',
'description': 'Your node will resync its network graph.',
'warning': 'This will take a couple hours.',
'allowed-statuses': [
PackageMainStatus.Running,
],
'implementation': {
'type': 'docker',
'image': '',
'system': true,
'entrypoint': '',
'args': [],
'mounts': { },
'io-format': DockerIoFormat.Yaml,
'inject': false,
'shm-size': '',
'sigterm-timeout': null,
},
'input-spec': null,
},
},
'permissions': { },
'dependencies': {
'bitcoind': {
'version': '=0.21.0',
'description': 'LND needs bitcoin to live.',
'requirement': {
'type': 'opt-out',
'how': 'You can use an external node from your Embassy if you prefer.',
},
'config': null,
},
'btc-rpc-proxy': {
'version': '>=0.2.2',
'description': 'As long as Bitcoin is pruned, LND needs Bitcoin Proxy to fetch block over the P2P network.',
'requirement': {
'type': 'opt-in',
'how': 'To use Proxy\'s user management system, go to LND config and select Bitcoin Proxy under Bitcoin config.',
},
'config': null,
},
},
},
'installed': {
'manifest': { } as Manifest,
'last-backup': null,
'status': {
'configured': true,
'main': {
'status': PackageMainStatus.Stopped,
},
'dependency-errors': {
'btc-rpc-proxy': {
'type': DependencyErrorType.ConfigUnsatisfied,
'error': 'This is a config unsatisfied error',
},
},
},
'interface-addresses': {
'rpc': {
'tor-address': 'lnd-rpc-address.onion',
'lan-address': 'lnd-rpc-address.local',
},
'grpc': {
'tor-address': 'lnd-grpc-address.onion',
'lan-address': 'lnd-grpc-address.local',
},
},
'system-pointers': [],
'current-dependents': { },
'current-dependencies': {
'bitcoind': {
'pointers': [],
'health-checks': [],
},
'btc-rpc-proxy': {
'pointers': [],
'health-checks': [],
},
},
'dependency-info': {
'bitcoind': {
'manifest': {
'title': 'Bitcoin Core',
} as Manifest,
'icon': 'assets/img/service-icons/bitcoind.png',
},
'btc-rpc-proxy': {
'manifest': {
'title': 'Bitcoin Proxy',
} as Manifest,
'icon': 'assets/img/service-icons/btc-rpc-proxy.png',
},
},
},
},
},
}

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject, Observable } from 'rxjs'
import { distinctUntilChanged } from 'rxjs/operators'
import { Storage } from '@ionic/storage-angular'
export enum AuthState {
UNVERIFIED,
VERIFIED,
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly authState$: BehaviorSubject<AuthState> = new BehaviorSubject(undefined)
constructor (
private readonly storage: Storage,
) { }
async init (): Promise<void> {
const loggedIn = await this.storage.get(this.LOGGED_IN_KEY)
this.authState$.next( loggedIn ? AuthState.VERIFIED : AuthState.UNVERIFIED)
}
watch$ (): Observable<AuthState> {
return this.authState$.pipe(distinctUntilChanged())
}
async setVerified (): Promise<void> {
await this.storage.set(this.LOGGED_IN_KEY, true)
this.authState$.next(AuthState.VERIFIED)
}
async setUnverified (): Promise<void> {
this.authState$.next(AuthState.UNVERIFIED)
}
}

View File

@@ -0,0 +1,112 @@
import { Injectable } from '@angular/core'
import {
InterfaceDef,
PackageDataEntry,
PackageMainStatus,
PackageState,
} from './patch-db/data-model'
import { WorkspaceConfig } from '@shared'
const {
useMocks,
ui: { gitHash, patchDb, api, mocks },
} = require('../../../../../config.json') as WorkspaceConfig
@Injectable({
providedIn: 'root',
})
export class ConfigService {
origin = removePort(removeProtocol(window.origin))
version = require('../../../../../package.json').version
useMocks = useMocks
mocks = mocks
gitHash = gitHash
patchDb = patchDb
api = api
skipStartupAlerts = useMocks && mocks.skipStartupAlerts
isConsulate = window['platform'] === 'ios'
supportsWebSockets = !!window.WebSocket || this.isConsulate
isTor(): boolean {
return (
(useMocks && mocks.maskAs === 'tor') || this.origin.endsWith('.onion')
)
}
isLan(): boolean {
return (
(useMocks && mocks.maskAs === 'lan') || this.origin.endsWith('.local')
)
}
isLaunchable(
state: PackageState,
status: PackageMainStatus,
interfaces: Record<string, InterfaceDef>,
): boolean {
if (state !== PackageState.Installed) {
return false
}
return (
status === PackageMainStatus.Running &&
((hasTorUi(interfaces) && this.isTor()) ||
(hasLanUi(interfaces) && !this.isTor()))
)
}
launchableURL(pkg: PackageDataEntry): string {
return this.isTor()
? `http://${torUiAddress(pkg)}`
: `https://${lanUiAddress(pkg)}`
}
}
export function hasTorUi(interfaces: Record<string, InterfaceDef>): boolean {
const int = getUiInterfaceValue(interfaces)
return !!int?.['tor-config']
}
export function hasLanUi(interfaces: Record<string, InterfaceDef>): boolean {
const int = getUiInterfaceValue(interfaces)
return !!int?.['lan-config']
}
export function torUiAddress(pkg: PackageDataEntry): string {
const key = getUiInterfaceKey(pkg.manifest.interfaces)
return pkg.installed['interface-addresses'][key]['tor-address']
}
export function lanUiAddress(pkg: PackageDataEntry): string {
const key = getUiInterfaceKey(pkg.manifest.interfaces)
return pkg.installed['interface-addresses'][key]['lan-address']
}
export function hasUi(interfaces: Record<string, InterfaceDef>): boolean {
return hasTorUi(interfaces) || hasLanUi(interfaces)
}
export function removeProtocol(str: string): string {
if (str.startsWith('http://')) return str.slice(7)
if (str.startsWith('https://')) return str.slice(8)
return str
}
export function removePort(str: string): string {
return str.split(':')[0]
}
export function getUiInterfaceKey(
interfaces: Record<string, InterfaceDef>,
): string {
return Object.keys(interfaces).find(key => interfaces[key].ui)
}
export function getUiInterfaceValue(
interfaces: Record<string, InterfaceDef>,
): InterfaceDef {
return Object.values(interfaces).find(i => i.ui)
}

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject, combineLatest, fromEvent, merge, Subscription } from 'rxjs'
import { PatchConnection, PatchDbService } from './patch-db/patch-db.service'
import { distinctUntilChanged } from 'rxjs/operators'
import { ConfigService } from './config.service'
@Injectable({
providedIn: 'root',
})
export class ConnectionService {
private readonly networkState$ = new BehaviorSubject<boolean>(true)
private readonly connectionFailure$ = new BehaviorSubject<ConnectionFailure>(ConnectionFailure.None)
constructor (
private readonly configService: ConfigService,
private readonly patch: PatchDbService,
) { }
watchFailure$ () {
return this.connectionFailure$.asObservable()
}
start (): Subscription[] {
const sub1 = merge(fromEvent(window, 'online'), fromEvent(window, 'offline'))
.subscribe(event => {
this.networkState$.next(event.type === 'online')
})
const sub2 = combineLatest([
// 1
this.networkState$
.pipe(
distinctUntilChanged(),
),
// 2
this.patch.watchPatchConnection$()
.pipe(
distinctUntilChanged(),
),
// 3
this.patch.watch$('server-info', 'update-progress')
.pipe(
distinctUntilChanged(),
),
])
.subscribe(async ([network, patchConnection, progress]) => {
if (!network) {
this.connectionFailure$.next(ConnectionFailure.Network)
} else if (patchConnection !== PatchConnection.Disconnected) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!!progress && progress.downloaded === progress.size) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!this.configService.isTor()) {
this.connectionFailure$.next(ConnectionFailure.Lan)
} else {
this.connectionFailure$.next(ConnectionFailure.Tor)
}
})
return [sub1, sub2]
}
}
export enum ConnectionFailure {
None = 'none',
Network = 'network',
Tor = 'tor',
Lan = 'lan',
}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnDestroy } from "@angular/core";
import { ReplaySubject } from "rxjs";
/**
* Observable abstraction over ngOnDestroy to use with takeUntil
*/
@Injectable()
export class DestroyService extends ReplaySubject<void> implements OnDestroy {
ngOnDestroy() {
this.next();
this.complete();
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core'
import * as emver from '@start9labs/emver'
@Injectable({
providedIn: 'root',
})
export class Emver {
constructor () { }
compare (lhs: string, rhs: string): number {
if (!lhs || !rhs) return null
return emver.compare(lhs, rhs)
}
satisfies (version: string, range: string): boolean {
return emver.satisfies(version, range)
}
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@angular/core'
import { IonicSafeString, ToastController } from '@ionic/angular'
import { RequestError } from './http.service'
@Injectable({
providedIn: 'root',
})
export class ErrorToastService {
private toast: HTMLIonToastElement
constructor (
private readonly toastCtrl: ToastController,
) { }
async present (e: RequestError, link?: string): Promise<void> {
console.error(e)
if (this.toast) return
this.toast = await this.toastCtrl.create({
header: 'Error',
message: getErrorMessage(e, link),
duration: 0,
position: 'top',
cssClass: 'error-toast',
buttons: [
{
side: 'end',
icon: 'close',
handler: () => {
this.dismiss()
},
},
],
})
await this.toast.present()
}
async dismiss (): Promise<void> {
if (this.toast) {
await this.toast.dismiss()
this.toast = undefined
}
}
}
export function getErrorMessage (e: RequestError, link?: string): string | IonicSafeString {
let message: string | IonicSafeString
if (e.message) message = `${message ? message + ' ' : ''}${e.message}`
if (e.details) message = `${message ? message + ': ' : ''}${e.details}`
if (!message) {
message = 'Unknown Error.'
link = 'https://docs.start9.com/support/FAQ/index.html'
}
if (link) {
message = new IonicSafeString(`${message}<br /><br /><a href=${link} target="_blank" rel="noreferrer" style="color: white;">Get Help</a>`)
}
return message
}

View File

@@ -0,0 +1,431 @@
import { Injectable } from '@angular/core'
import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'
import { ConfigSpec, isValueSpecListOf, ListValueSpecNumber, ListValueSpecObject, ListValueSpecOf, ListValueSpecString, ListValueSpecUnion, UniqueBy, ValueSpec, ValueSpecEnum, ValueSpecList, ValueSpecNumber, ValueSpecObject, ValueSpecString, ValueSpecUnion } from '../pkg-config/config-types'
import { getDefaultString, Range } from '../pkg-config/config-utilities'
const Mustache = require('mustache')
@Injectable({
providedIn: 'root',
})
export class FormService {
constructor (
private readonly formBuilder: FormBuilder,
) { }
createForm (spec: ConfigSpec, current: { [key: string]: any } = { }): FormGroup {
return this.getFormGroup(spec, [], current)
}
getUnionObject (spec: ValueSpecUnion | ListValueSpecUnion, selection: string, current?: { [key: string]: any }): FormGroup {
const { variants, tag } = spec
const { name, description, warning } = isFullUnion(spec) ? spec : { ...spec.tag, warning: undefined }
const enumSpec: ValueSpecEnum = {
type: 'enum',
name,
description,
warning,
default: selection,
values: Object.keys(variants),
'value-names': tag['variant-names'],
}
return this.getFormGroup({ [spec.tag.id]: enumSpec, ...spec.variants[selection] }, [], current)
}
getListItem (spec: ValueSpecList, entry: any) {
const listItemValidators = this.getListItemValidators(spec)
if (isValueSpecListOf(spec, 'string')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'number')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'enum')) {
return this.formBuilder.control(entry)
} else if (isValueSpecListOf(spec, 'object')) {
return this.getFormGroup(spec.spec.spec, listItemValidators, entry)
} else if (isValueSpecListOf(spec, 'union')) {
return this.getUnionObject(spec.spec, spec.spec.default, entry)
}
}
private getListItemValidators (spec: ValueSpecList) {
if (isValueSpecListOf(spec, 'string')) {
return this.stringValidators(spec.spec)
} else if (isValueSpecListOf(spec, 'number')) {
return this.numberValidators(spec.spec)
}
}
private getFormGroup (config: ConfigSpec, validators: ValidatorFn[] = [], current: { [key: string]: any } = { }): FormGroup {
let group = { }
Object.entries(config).map(([key, spec]) => {
if (spec.type === 'pointer') return
group[key] = this.getFormEntry(spec, current ? current[key] : undefined)
})
return this.formBuilder.group(group, { validators } )
}
private getFormEntry (spec: ValueSpec, currentValue?: any): FormGroup | FormArray | FormControl {
let validators: ValidatorFn[]
let value: any
switch (spec.type) {
case 'string':
validators = this.stringValidators(spec)
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default ? getDefaultString(spec.default) : null
}
return this.formBuilder.control(value, validators)
case 'number':
validators = this.numberValidators(spec)
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default || null
}
return this.formBuilder.control(value, validators)
case 'object':
return this.getFormGroup(spec.spec, [], currentValue)
case 'list':
validators = this.listValidators(spec)
const mapped = (Array.isArray(currentValue) ? currentValue : spec.default as any[]).map(entry => {
return this.getListItem(spec, entry)
})
return this.formBuilder.array(mapped, validators)
case 'union':
return this.getUnionObject(spec, currentValue?.[spec.tag.id] || spec.default, currentValue)
case 'boolean':
case 'enum':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value)
}
}
private stringValidators (spec: ValueSpecString | ListValueSpecString): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (!(spec as ValueSpecString).nullable) {
validators.push(Validators.required)
}
if (spec.pattern) {
validators.push(Validators.pattern(spec.pattern))
}
return validators
}
private numberValidators (spec: ValueSpecNumber | ListValueSpecNumber): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(isNumber())
if (!(spec as ValueSpecNumber).nullable) {
validators.push(Validators.required)
}
if (spec.integral) {
validators.push(isInteger())
}
validators.push(numberInRange(spec.range))
return validators
}
private listValidators (spec: ValueSpecList): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listInRange(spec.range))
validators.push(listItemIssue())
if (!isValueSpecListOf(spec, 'enum')) {
validators.push(listUnique(spec))
}
return validators
}
}
function isFullUnion (spec: ValueSpecUnion | ListValueSpecUnion): spec is ValueSpecUnion {
return !!(spec as ValueSpecUnion).name
}
export function numberInRange (stringRange: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value
if (!value) return null
try {
Range.from(stringRange).checkIncludes(value)
return null
} catch (e) {
return { numberNotInRange: { value: `Number must be ${e.message}` } }
}
}
}
export function isNumber (): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return !control.value || control.value == Number(control.value) ?
null :
{ notNumber: { value: control.value } }
}
}
export function isInteger (): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return !control.value || control.value == Math.trunc(control.value) ?
null :
{ numberNotInteger: { value: control.value } }
}
}
export function listInRange (stringRange: string): ValidatorFn {
return (control: FormArray): ValidationErrors | null => {
try {
Range.from(stringRange).checkIncludes(control.value.length)
return null
} catch (e) {
return { listNotInRange: { value: `List must be ${e.message}` } }
}
}
}
export function listItemIssue (): ValidatorFn {
return (parentControl: FormArray): ValidationErrors | null => {
const problemChild = parentControl.controls.find(c => c.invalid)
if (problemChild) {
return { listItemIssue: { value: 'Invalid entries' } }
} else {
return null
}
}
}
export function listUnique (spec: ValueSpecList): ValidatorFn {
return (control: FormArray): ValidationErrors | null => {
const list = control.value
for (let idx = 0; idx < list.length; idx++) {
for (let idx2 = idx + 1; idx2 < list.length; idx2++) {
if (listItemEquals(spec, list[idx], list[idx2])) {
let display1: string
let display2: string
let uniqueMessage = isObjectOrUnion(spec.spec) ? uniqueByMessageWrapper(spec.spec['unique-by'], spec.spec, list[idx]) : ''
if (isObjectOrUnion(spec.spec) && spec.spec['display-as']) {
display1 = `"${(Mustache as any).render(spec.spec['display-as'], list[idx])}"`
display2 = `"${(Mustache as any).render(spec.spec['display-as'], list[idx2])}"`
} else {
display1 = `Entry ${idx + 1}`
display2 = `Entry ${idx2 + 1}`
}
return { listNotUnique: { value: `${display1} and ${display2} are not unique.${uniqueMessage}` } }
}
}
}
return null
}
}
function listItemEquals (spec: ValueSpecList, val1: any, val2: any): boolean {
switch (spec.subtype) {
case 'string':
case 'number':
case 'enum':
return val1 == val2
case 'object':
return listObjEquals(spec.spec['unique-by'], (spec.spec as ListValueSpecObject), val1, val2)
case 'union':
return unionEquals(spec.spec['unique-by'], spec.spec as ListValueSpecUnion, val1, val2)
default:
return false
}
}
function itemEquals (spec: ValueSpec, val1: any, val2: any): boolean {
switch (spec.type) {
case 'string':
case 'number':
case 'boolean':
case 'enum':
return val1 == val2
case 'object':
return objEquals(spec['unique-by'], (spec as ValueSpecObject), val1, val2)
case 'union':
return unionEquals(spec['unique-by'], (spec as ValueSpecUnion), val1, val2)
case 'list':
if (val1.length !== val2.length) {
return false
}
for (let idx = 0; idx < val1.length; idx++) {
if (listItemEquals(spec, val1[idx], val2[idx])) {
return false
}
}
return true
default:
return false
}
}
function listObjEquals (uniqueBy: UniqueBy, spec: ListValueSpecObject, val1: any, val2: any): boolean {
if (uniqueBy === null) {
return false
} else if (typeof uniqueBy === 'string') {
return itemEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (listObjEquals(subSpec, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!listObjEquals(subSpec, spec, val1, val2)) {
return false
}
}
return true
}
}
function objEquals (uniqueBy: UniqueBy, spec: ValueSpecObject, val1: any, val2: any): boolean {
if (uniqueBy === null) {
return false
} else if (typeof uniqueBy === 'string') {
return itemEquals(spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (objEquals(subSpec, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!objEquals(subSpec, spec, val1, val2)) {
return false
}
}
return true
}
}
function unionEquals (uniqueBy: UniqueBy, spec: ValueSpecUnion | ListValueSpecUnion, val1: any, val2: any): boolean {
const tagId = spec.tag.id
const variant = spec.variants[val1[tagId]]
if (uniqueBy === null) {
return false
} else if (typeof uniqueBy === 'string') {
if (uniqueBy === tagId) {
return val1[tagId] === val2[tagId]
} else {
return itemEquals(variant[uniqueBy], val1[uniqueBy], val2[uniqueBy])
}
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (unionEquals(subSpec, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!unionEquals(subSpec, spec, val1, val2)) {
return false
}
}
return true
}
}
function uniqueByMessageWrapper (uniqueBy: UniqueBy, spec: ListValueSpecObject | ListValueSpecUnion, obj: object) {
let configSpec: ConfigSpec
if (isUnion(spec)) {
const variantKey = obj[spec.tag.id]
configSpec = spec.variants[variantKey]
} else {
configSpec = spec.spec
}
const message = uniqueByMessage(uniqueBy, configSpec)
if (message) {
return ' Must be unique by: ' + message + '.'
}
}
function uniqueByMessage (uniqueBy: UniqueBy, configSpec: ConfigSpec, outermost = true): string {
let joinFunc
const subSpecs = []
if (uniqueBy === null) {
return null
} else if (typeof uniqueBy === 'string') {
return configSpec[uniqueBy] ? configSpec[uniqueBy].name : uniqueBy
} else if ('any' in uniqueBy) {
joinFunc = ' OR '
for (let subSpec of uniqueBy.any) {
subSpecs.push(uniqueByMessage(subSpec, configSpec, false))
}
} else if ('all' in uniqueBy) {
joinFunc = ' AND '
for (let subSpec of uniqueBy.all) {
subSpecs.push(uniqueByMessage(subSpec, configSpec, false))
}
}
const ret = subSpecs.filter(ss => ss).join(joinFunc)
return outermost || subSpecs.filter(ss => ss).length === 1 ? ret : '(' + ret + ')'
}
function isObjectOrUnion (spec: ListValueSpecOf<any>): spec is ListValueSpecObject | ListValueSpecUnion {
// only lists of objects and unions have unique-by
return spec['unique-by'] !== undefined
}
function isUnion (spec: any): spec is ListValueSpecUnion {
// only unions have tag
return !!spec.tag
}
export function convertValuesRecursive (configSpec: ConfigSpec, group: FormGroup) {
Object.entries(configSpec).forEach(([key, valueSpec]) => {
if (valueSpec.type === 'number') {
const control = group.get(key)
control.setValue(control.value ? Number(control.value) : null)
} else if (valueSpec.type === 'string') {
const control = group.get(key)
if (!control.value) control.setValue(null)
} else if (valueSpec.type === 'object') {
convertValuesRecursive(valueSpec.spec, group.get(key) as FormGroup)
} else if (valueSpec.type === 'union') {
const control = group.get(key) as FormGroup
const spec = valueSpec.variants[control.controls[valueSpec.tag.id].value]
convertValuesRecursive(spec, control)
} else if (valueSpec.type === 'list') {
const formArr = group.get(key) as FormArray
if (valueSpec.subtype === 'number') {
formArr.controls.forEach(control => {
control.setValue(control.value ? Number(control.value) : null)
})
} else if (valueSpec.subtype === 'string') {
formArr.controls.forEach(control => {
if (!control.value) control.setValue(null)
})
} else if (valueSpec.subtype === 'object') {
formArr.controls.forEach((formGroup: FormGroup) => {
const objectSpec = valueSpec.spec as ListValueSpecObject
convertValuesRecursive(objectSpec.spec, formGroup)
})
} else if (valueSpec.subtype === 'union') {
formArr.controls.forEach((formGroup: FormGroup) => {
const unionSpec = valueSpec.spec as ListValueSpecUnion
const spec = unionSpec.variants[formGroup.controls[unionSpec.tag.id].value]
convertValuesRecursive(spec, formGroup)
})
}
}
})
}

View File

@@ -0,0 +1,14 @@
import { ErrorHandler, Injectable } from '@angular/core'
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError (e: any): void {
console.error(e)
const chunkFailedMessage = /Loading chunk [\d]+ failed/
if (chunkFailedMessage.test(e.message)) {
window.location.reload()
}
}
}

View File

@@ -0,0 +1,189 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
import { Observable, from, interval, race } from 'rxjs'
import { map, take } from 'rxjs/operators'
import { ConfigService } from './config.service'
import { Revision } from 'patch-db-client'
import { AuthService } from './auth.service'
@Injectable({
providedIn: 'root',
})
export class HttpService {
fullUrl: string
constructor (
private readonly http: HttpClient,
private readonly config: ConfigService,
private readonly auth: AuthService,
) {
const port = window.location.port
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}`
}
async rpcRequest<T> (rpcOpts: RPCOptions): Promise<T> {
const { url, version } = this.config.api
rpcOpts.params = rpcOpts.params || { }
const httpOpts: HttpOptions = {
method: Method.POST,
body: rpcOpts,
url: `/${url}/${version}`,
}
if (rpcOpts.timeout) httpOpts.timeout = rpcOpts.timeout
const res = await this.httpRequest<RPCResponse<T>>(httpOpts)
if (isRpcError(res)) {
if (res.error.code === 34) this.auth.setUnverified()
throw new RpcError(res.error)
}
if (isRpcSuccess(res)) return res.result
}
async httpRequest<T> (httpOpts: HttpOptions): Promise<T> {
if (httpOpts.withCredentials !== false) {
httpOpts.withCredentials = true
}
const urlIsRelative = httpOpts.url.startsWith('/')
const url = urlIsRelative ?
this.fullUrl + httpOpts.url :
httpOpts.url
Object.keys(httpOpts.params || { }).forEach(key => {
if (httpOpts.params[key] === undefined) {
delete httpOpts.params[key]
}
})
const options = {
responseType: httpOpts.responseType || 'json',
body: httpOpts.body,
observe: 'events',
reportProgress: false,
withCredentials: httpOpts.withCredentials,
headers: httpOpts.headers,
params: httpOpts.params,
timeout: httpOpts.timeout,
} as any
let req: Observable<{ body: T }>
switch (httpOpts.method) {
case Method.GET: req = this.http.get(url, options) as any; break
case Method.POST: req = this.http.post(url, httpOpts.body, options) as any; break
case Method.PUT: req = this.http.put(url, httpOpts.body, options) as any; break
case Method.PATCH: req = this.http.patch(url, httpOpts.body, options) as any; break
case Method.DELETE: req = this.http.delete(url, options) as any; break
}
return (httpOpts.timeout ? withTimeout(req, httpOpts.timeout) : req)
.toPromise()
.then(res => res.body)
.catch(e => { throw new HttpError(e) })
}
}
function RpcError (e: RPCError['error']): void {
const { code, message, data } = e
this.code = code
this.message = message
if (typeof data === 'string') {
this.details = e.data
this.revision = null
} else {
this.details = data.details
this.revision = data.revision
}
}
function HttpError (e: HttpErrorResponse): void {
const { status, statusText } = e
this.code = status
this.message = statusText
this.details = null
this.revision = null
}
function isRpcError<Error, Result> (arg: { error: Error } | { result: Result}): arg is { error: Error } {
return !!(arg as any).error
}
function isRpcSuccess<Error, Result> (arg: { error: Error } | { result: Result}): arg is { result: Result } {
return !!(arg as any).result
}
export interface RequestError {
code: number
message: string
details: string
revision: Revision | null
}
export enum Method {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
export interface RPCOptions {
method: string
params?: object
timeout?: number
}
interface RPCBase {
jsonrpc: '2.0'
id: string
}
export interface RPCRequest<T> extends RPCBase {
method: string
params?: T
}
export interface RPCSuccess<T> extends RPCBase {
result: T
}
export interface RPCError extends RPCBase {
error: {
code: number
message: string
data?: {
details: string
revision: Revision | null
debug: string | null
} | string
}
}
export type RPCResponse<T> = RPCSuccess<T> | RPCError
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
export interface HttpOptions {
method: Method
url: string
headers?: HttpHeaders | {
[header: string]: string | string[]
}
params?: HttpParams | {
[param: string]: string | string[]
}
responseType?: 'json' | 'text'
withCredentials?: boolean
body?: any
timeout?: number
}
function withTimeout<U> (req: Observable<U>, timeout: number): Observable<U> {
return race(
from(req.toPromise()), // this guarantees it only emits on completion, intermediary emissions are suppressed.
interval(timeout).pipe(take(1), map(() => { throw new Error('timeout') })),
)
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular'
import { DependentInfo } from 'src/app/util/misc.util'
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
@Injectable({
providedIn: 'root',
})
export class ModalService {
constructor(
private readonly route: ActivatedRoute,
private readonly modalCtrl: ModalController,
) {}
async presentModalConfig(componentProps: ComponentProps): Promise<void> {
const modal = await this.modalCtrl.create({
component: AppConfigPage,
componentProps,
})
await modal.present()
}
}
interface ComponentProps {
pkgId: string
dependentInfo?: DependentInfo
}

View File

@@ -0,0 +1,376 @@
import { ConfigSpec } from 'src/app/pkg-config/config-types'
export interface DataModel {
'server-info': ServerInfo
'package-data': { [id: string]: PackageDataEntry }
'recovered-packages': { [id: string]: RecoveredPackageDataEntry }
ui: UIData
}
export interface UIData {
name: string
'auto-check-updates': boolean
'pkg-order': string[]
'ack-welcome': string // EOS version
'ack-share-stats': boolean
}
export interface ServerInfo {
id: string
version: string
'last-backup': string | null
'lan-address': URL
'tor-address': URL
status: ServerStatus
'eos-marketplace': URL
'package-marketplace': URL | null // uses EOS marketplace if null
'share-stats': boolean
'unread-notification-count': number
'update-progress'?: {
size: number
downloaded: number
}
'eos-version-compat': string
'password-hash': string
}
export enum ServerStatus {
Running = 'running',
Updated = 'updated',
BackingUp = 'backing-up',
}
export interface RecoveredPackageDataEntry {
title: string,
icon: URL,
version: string,
}
export interface PackageDataEntry {
state: PackageState
'static-files': {
license: URL
instructions: URL
icon: URL
}
manifest: Manifest
installed?: InstalledPackageDataEntry, // exists when: installed, updating
'install-progress'?: InstallProgress, // exists when: installing, updating
}
export interface InstallProgress {
size: number | null
downloaded: number
'download-complete': boolean
validated: number
'validation-complete': boolean
unpacked: number
'unpack-complete': boolean
}
export interface InstalledPackageDataEntry {
status: Status
manifest: Manifest,
'last-backup': string | null
'system-pointers': any[]
'current-dependents': { [id: string]: CurrentDependencyInfo }
'current-dependencies': { [id: string]: CurrentDependencyInfo }
'dependency-info': {
[id: string]: {
manifest: Manifest
icon: URL
}
}
'interface-addresses': {
[id: string]: { 'tor-address': string, 'lan-address': string }
}
}
export interface CurrentDependencyInfo {
pointers: any[]
'health-checks': string[] // array of health check IDs
}
export enum PackageState {
Installing = 'installing',
Installed = 'installed',
Updating = 'updating',
Removing = 'removing',
Restoring = 'restoring',
}
export interface Manifest {
id: string
title: string
version: string
description: {
short: string
long: string
}
'release-notes': string
license: string // name
'wrapper-repo': URL
'upstream-repo': URL
'support-site': URL
'marketing-site': URL
'donation-url': URL | null
alerts: {
install: string | null
uninstall: string | null
restore: string | null
start: string | null
stop: string | null
}
main: ActionImpl
'health-checks': Record<string, ActionImpl & { name: string, description: string }>
config: ConfigActions | null
volumes: Record<string, Volume>
'min-os-version': string
interfaces: Record<string, InterfaceDef>
backup: BackupActions
migrations: Migrations
actions: Record<string, Action>
permissions: any // @TODO 0.3.1
dependencies: DependencyInfo
}
export interface ActionImpl {
type: 'docker'
image: string
system: boolean
entrypoint: string
args: string[]
mounts: { [id: string]: string }
'io-format': DockerIoFormat | null
inject: boolean
'shm-size': string
'sigterm-timeout': string | null
}
export enum DockerIoFormat {
Json = 'json',
Yaml = 'yaml',
Cbor = 'cbor',
Toml = 'toml',
}
export interface ConfigActions {
get: ActionImpl
set: ActionImpl
}
export type Volume = VolumeData
export interface VolumeData {
type: VolumeType.Data
readonly: boolean
}
export interface VolumeAssets {
type: VolumeType.Assets
}
export interface VolumePointer {
type: VolumeType.Pointer
'package-id': string
'volume-id': string
path: string
readonly: boolean
}
export interface VolumeCertificate {
type: VolumeType.Certificate
'interface-id': string
}
export interface VolumeBackup {
type: VolumeType.Backup
readonly: boolean
}
export enum VolumeType {
Data = 'data',
Assets = 'assets',
Pointer = 'pointer',
Certificate = 'certificate',
Backup = 'backup',
}
export interface InterfaceDef {
name: string
description: string
'tor-config': TorConfig | null
'lan-config': LanConfig | null
ui: boolean
protocols: string[]
}
export interface TorConfig {
'port-mapping': { [port: number]: number }
}
export type LanConfig = {
[port: number]: { ssl: boolean, mapping: number }
}
export interface BackupActions {
create: ActionImpl
restore: ActionImpl
}
export interface Migrations {
from: { [versionRange: string]: ActionImpl }
to: { [versionRange: string]: ActionImpl }
}
export interface Action {
name: string
description: string
warning: string | null
implementation: ActionImpl
'allowed-statuses': (PackageMainStatus.Stopped | PackageMainStatus.Running)[]
'input-spec': ConfigSpec
}
export interface Status {
configured: boolean
main: MainStatus
'dependency-errors': { [id: string]: DependencyError | null }
}
export type MainStatus = MainStatusStopped | MainStatusStopping | MainStatusStarting | MainStatusRunning | MainStatusBackingUp
export interface MainStatusStopped {
status: PackageMainStatus.Stopped
}
export interface MainStatusStopping {
status: PackageMainStatus.Stopping
}
export interface MainStatusStarting {
status: PackageMainStatus.Starting
}
export interface MainStatusRunning {
status: PackageMainStatus.Running
started: string // UTC date string
health: { [id: string]: HealthCheckResult }
}
export interface MainStatusBackingUp {
status: PackageMainStatus.BackingUp
started: string | null // UTC date string
}
export enum PackageMainStatus {
Starting = 'starting',
Running = 'running',
Stopping = 'stopping',
Stopped = 'stopped',
BackingUp = 'backing-up',
}
export type HealthCheckResult = HealthCheckResultStarting |
HealthCheckResultLoading |
HealthCheckResultDisabled |
HealthCheckResultSuccess |
HealthCheckResultFailure
export enum HealthResult {
Starting = 'starting',
Loading = 'loading',
Disabled = 'disabled',
Success = 'success',
Failure = 'failure',
}
export interface HealthCheckResultStarting {
result: HealthResult.Starting
}
export interface HealthCheckResultDisabled {
result: HealthResult.Disabled
}
export interface HealthCheckResultSuccess {
result: HealthResult.Success
}
export interface HealthCheckResultLoading {
result: HealthResult.Loading
message: string
}
export interface HealthCheckResultFailure {
result: HealthResult.Failure
error: string
}
export type DependencyError = DependencyErrorNotInstalled |
DependencyErrorNotRunning |
DependencyErrorIncorrectVersion |
DependencyErrorConfigUnsatisfied |
DependencyErrorHealthChecksFailed |
DependencyErrorTransitive
export enum DependencyErrorType {
NotInstalled = 'not-installed',
NotRunning = 'not-running',
IncorrectVersion = 'incorrect-version',
ConfigUnsatisfied = 'config-unsatisfied',
HealthChecksFailed = 'health-checks-failed',
InterfaceHealthChecksFailed = 'interface-health-checks-failed',
Transitive = 'transitive',
}
export interface DependencyErrorNotInstalled {
type: DependencyErrorType.NotInstalled
}
export interface DependencyErrorNotRunning {
type: DependencyErrorType.NotRunning
}
export interface DependencyErrorIncorrectVersion {
type: DependencyErrorType.IncorrectVersion
expected: string // version range
received: string // version
}
export interface DependencyErrorConfigUnsatisfied {
type: DependencyErrorType.ConfigUnsatisfied
error: string
}
export interface DependencyErrorHealthChecksFailed {
type: DependencyErrorType.HealthChecksFailed
check: HealthCheckResult
}
export interface DependencyErrorTransitive {
type: DependencyErrorType.Transitive
}
export interface DependencyInfo {
[id: string]: DependencyEntry
}
export interface DependencyEntry {
version: string
requirement: {
type: 'opt-in'
how: string
} | {
type: 'opt-out'
how: string
} | {
type: 'required'
}
description: string | null
config: {
check: ActionImpl,
'auto-configure': ActionImpl
}
}
export type URL = string

View File

@@ -0,0 +1,24 @@
import { Bootstrapper, DBCache } from 'patch-db-client'
import { DataModel } from './data-model'
import { Injectable } from '@angular/core'
import { Storage } from '@ionic/storage-angular'
@Injectable({
providedIn: 'root',
})
export class LocalStorageBootstrap implements Bootstrapper<DataModel> {
static CONTENT_KEY = 'patch-db-cache'
constructor (
private readonly storage: Storage,
) { }
async init (): Promise<DBCache<DataModel>> {
const cache: DBCache<DataModel> = await this.storage.get(LocalStorageBootstrap.CONTENT_KEY)
return cache || { sequence: 0, data: { } as DataModel }
}
async update (cache: DBCache<DataModel>): Promise<void> {
await this.storage.set(LocalStorageBootstrap.CONTENT_KEY, cache)
}
}

View File

@@ -0,0 +1,52 @@
import { MockSource, PollSource, WebsocketSource } from 'patch-db-client'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from './data-model'
import { LocalStorageBootstrap } from './local-storage-bootstrap'
import { PatchDbService } from './patch-db.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from '../auth.service'
import { MockApiService } from '../api/embassy-mock-api.service'
import { filter } from 'rxjs/operators'
import { exists } from 'src/app/util/misc.util'
import { Storage } from '@ionic/storage-angular'
export function PatchDbServiceFactory (
config: ConfigService,
embassyApi: ApiService,
bootstrapper: LocalStorageBootstrap,
auth: AuthService,
storage: Storage,
): PatchDbService {
const { useMocks, patchDb: { poll } } = config
if (useMocks) {
const source = new MockSource<DataModel>(
(embassyApi as MockApiService).mockPatch$.pipe(filter(exists)),
)
return new PatchDbService(
source,
source,
embassyApi,
bootstrapper,
auth,
storage,
)
} else {
const protocol = window.location.protocol === 'http:' ? 'ws' : 'wss'
const host = window.location.host
const wsSource = new WebsocketSource<DataModel>(
`${protocol}://${host}/ws/db`,
)
const pollSource = new PollSource<DataModel>({ ...poll }, embassyApi)
return new PatchDbService(
wsSource,
pollSource,
embassyApi,
bootstrapper,
auth,
storage,
)
}
}

View File

@@ -0,0 +1,185 @@
import { Inject, Injectable, InjectionToken } from '@angular/core'
import { Storage } from '@ionic/storage-angular'
import { Bootstrapper, PatchDB, Source, Store } from 'patch-db-client'
import { BehaviorSubject, Observable, of, Subscription } from 'rxjs'
import {
catchError,
debounceTime,
finalize,
mergeMap,
tap,
withLatestFrom,
} from 'rxjs/operators'
import { isEmptyObject, pauseFor } from 'src/app/util/misc.util'
import { ApiService } from '../api/embassy-api.service'
import { AuthService } from '../auth.service'
import { DataModel } from './data-model'
export const PATCH_HTTP = new InjectionToken<Source<DataModel>>('')
export const PATCH_SOURCE = new InjectionToken<Source<DataModel>>('')
export const BOOTSTRAPPER = new InjectionToken<Bootstrapper<DataModel>>('')
export const AUTH = new InjectionToken<AuthService>('')
export const STORAGE = new InjectionToken<Storage>('')
export enum PatchConnection {
Initializing = 'initializing',
Connected = 'connected',
Disconnected = 'disconnected',
}
@Injectable({
providedIn: 'root',
})
export class PatchDbService {
private readonly WS_SUCCESS = 'wsSuccess'
private patchConnection$ = new BehaviorSubject(PatchConnection.Initializing)
private wsSuccess$ = new BehaviorSubject(false)
private polling$ = new BehaviorSubject(false)
private patchDb: PatchDB<DataModel>
private subs: Subscription[] = []
private sources$: BehaviorSubject<Source<DataModel>[]> = new BehaviorSubject([
this.wsSource,
])
data: DataModel
errors = 0
getData () {
return this.patchDb.store.cache.data
}
get loaded (): boolean {
return this.patchDb?.store?.cache?.data && !isEmptyObject(this.patchDb.store.cache.data)
}
constructor (
@Inject(PATCH_SOURCE) private readonly wsSource: Source<DataModel>,
@Inject(PATCH_SOURCE) private readonly pollSource: Source<DataModel>,
@Inject(PATCH_HTTP) private readonly http: ApiService,
@Inject(BOOTSTRAPPER)
private readonly bootstrapper: Bootstrapper<DataModel>,
@Inject(AUTH) private readonly auth: AuthService,
@Inject(STORAGE) private readonly storage: Storage,
) { }
async init (): Promise<void> {
const cache = await this.bootstrapper.init()
this.sources$.next([this.wsSource, this.http])
this.patchDb = new PatchDB(this.sources$, this.http, cache)
this.patchConnection$.next(PatchConnection.Initializing)
this.data = this.patchDb.store.cache.data
}
async start (): Promise<void> {
await this.init()
this.subs.push(
// Connection Error
this.patchDb.connectionError$
.pipe(
debounceTime(420),
withLatestFrom(this.polling$),
mergeMap(async ([e, polling]) => {
if (polling) {
console.log('patchDB: POLLING FAILED', e)
this.patchConnection$.next(PatchConnection.Disconnected)
await pauseFor(2000)
this.sources$.next([this.pollSource, this.http])
return
}
console.log('patchDB: WEBSOCKET FAILED', e)
this.polling$.next(true)
this.sources$.next([this.pollSource, this.http])
}),
)
.subscribe({
complete: () => {
console.warn('patchDB: SYNC COMPLETE')
},
}),
// RPC ERROR
this.patchDb.rpcError$
.pipe(
tap(({ error }) => {
if (error.code === 34) {
console.log('patchDB: Unauthorized. Logging out.')
this.auth.setUnverified()
}
}),
)
.subscribe({
complete: () => {
console.warn('patchDB: SYNC COMPLETE')
},
}),
// GOOD CONNECTION
this.patchDb.cache$
.pipe(
debounceTime(420),
withLatestFrom(this.patchConnection$, this.wsSuccess$, this.polling$),
tap(async ([cache, connection, wsSuccess, polling]) => {
this.bootstrapper.update(cache)
if (connection === PatchConnection.Initializing) {
console.log(
polling
? 'patchDB: POLL CONNECTED'
: 'patchDB: WEBSOCKET CONNECTED',
)
this.patchConnection$.next(PatchConnection.Connected)
if (!wsSuccess && !polling) {
console.log('patchDB: WEBSOCKET SUCCESS')
this.storage.set(this.WS_SUCCESS, 'true')
this.wsSuccess$.next(true)
}
} else if (
connection === PatchConnection.Disconnected &&
wsSuccess
) {
console.log('patchDB: SWITCHING BACK TO WEBSOCKETS')
this.patchConnection$.next(PatchConnection.Initializing)
this.polling$.next(false)
this.sources$.next([this.wsSource, this.http])
}
}),
)
.subscribe({
complete: () => {
console.warn('patchDB: SYNC COMPLETE')
},
}),
)
}
stop (): void {
if (this.patchDb) {
console.log('patchDB: STOPPING')
this.patchConnection$.next(PatchConnection.Initializing)
this.patchDb.store.reset()
}
this.subs.forEach((x) => x.unsubscribe())
this.subs = []
}
watchPatchConnection$ (): Observable<PatchConnection> {
return this.patchConnection$.asObservable()
}
watch$: Store<DataModel>['watch$'] = (...args: (string | number)[]): Observable<DataModel> => {
const argsString = '/' + args.join('/')
console.log('patchDB: WATCHING ', argsString)
return this.patchDb.store.watch$(...(args as [])).pipe(
tap((data) => console.log('patchDB: NEW VALUE', argsString, data)),
catchError((e) => {
console.error('patchDB: WATCH ERROR', e)
return of(e.message)
}),
finalize(() => console.log('patchDB: UNSUBSCRIBING', argsString)),
)
}
}

View File

@@ -0,0 +1,121 @@
import { isEmptyObject } from '../util/misc.util'
import {
PackageDataEntry,
PackageMainStatus,
PackageState,
Status,
} from './patch-db/data-model'
export interface PackageStatus {
primary: PrimaryStatus
dependency: DependencyStatus | null
health: HealthStatus | null
}
export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
let primary: PrimaryStatus
let dependency: DependencyStatus | null = null
let health: HealthStatus | null = null
if (pkg.state === PackageState.Installed) {
primary = getPrimaryStatus(pkg.installed.status)
dependency = getDependencyStatus(pkg)
health = getHealthStatus(pkg.installed.status)
} else {
primary = pkg.state as string as PrimaryStatus
}
return { primary, dependency, health }
}
function getPrimaryStatus(status: Status): PrimaryStatus {
if (!status.configured) {
return PrimaryStatus.NeedsConfig
} else {
return status.main.status as any as PrimaryStatus
}
}
function getDependencyStatus(pkg: PackageDataEntry): DependencyStatus {
const installed = pkg.installed
if (isEmptyObject(installed['current-dependencies'])) return null
const depErrors = installed.status['dependency-errors']
const depIds = Object.keys(depErrors).filter(key => !!depErrors[key])
return depIds.length ? DependencyStatus.Warning : DependencyStatus.Satisfied
}
function getHealthStatus(status: Status): HealthStatus {
if (status.main.status === PackageMainStatus.Running) {
const values = Object.values(status.main.health)
if (values.some(h => h.result === 'failure')) {
return HealthStatus.Failure
} else if (values.some(h => h.result === 'starting')) {
return HealthStatus.Starting
} else if (values.some(h => h.result === 'loading')) {
return HealthStatus.Loading
} else {
return HealthStatus.Healthy
}
}
}
export interface StatusRendering {
display: string
color: string
showDots?: boolean
}
export enum PrimaryStatus {
// state
Installing = 'installing',
Updating = 'updating',
Removing = 'removing',
Restoring = 'restoring',
// status
Starting = 'starting',
Running = 'running',
Stopping = 'stopping',
Stopped = 'stopped',
BackingUp = 'backing-up',
// config
NeedsConfig = 'needs-config',
}
export enum DependencyStatus {
Warning = 'warning',
Satisfied = 'satisfied',
}
export enum HealthStatus {
Failure = 'failure',
Starting = 'starting',
Loading = 'loading',
Healthy = 'healthy',
}
export const PrimaryRendering: { [key: string]: StatusRendering } = {
[PrimaryStatus.Installing]: { display: 'Installing', color: 'primary', showDots: true },
[PrimaryStatus.Updating]: { display: 'Updating', color: 'primary', showDots: true },
[PrimaryStatus.Removing]: { display: 'Removing', color: 'danger', showDots: true },
[PrimaryStatus.Restoring]: { display: 'Restoring', color: 'primary', showDots: true },
[PrimaryStatus.Stopping]: { display: 'Stopping', color: 'dark-shade', showDots: true },
[PrimaryStatus.Stopped]: { display: 'Stopped', color: 'dark-shade', showDots: false },
[PrimaryStatus.BackingUp]: { display: 'Backing Up', color: 'primary', showDots: true },
[PrimaryStatus.Starting]: { display: 'Starting', color: 'primary', showDots: true },
[PrimaryStatus.Running]: { display: 'Running', color: 'success', showDots: false },
[PrimaryStatus.NeedsConfig]: { display: 'Needs Config', color: 'warning' },
}
export const DependencyRendering: { [key: string]: StatusRendering } = {
[DependencyStatus.Warning]: { display: 'Issue', color: 'warning' },
[DependencyStatus.Satisfied]: { display: 'Satisfied', color: 'success' },
}
export const HealthRendering: { [key: string]: StatusRendering } = {
[HealthStatus.Failure]: { display: 'Failure', color: 'danger' },
[HealthStatus.Starting]: { display: 'Starting', color: 'primary' },
[HealthStatus.Loading]: { display: 'Loading', color: 'primary' },
[HealthStatus.Healthy]: { display: 'Healthy', color: 'success' },
}

View File

@@ -0,0 +1,163 @@
import { Injectable } from '@angular/core'
import { AlertInput, AlertButton, IonicSafeString } from '@ionic/core'
import { ApiService } from './api/embassy-api.service'
import { ConfigSpec } from '../pkg-config/config-types'
import { AlertController, LoadingController } from '@ionic/angular'
import { ErrorToastService } from './error-toast.service'
@Injectable({
providedIn: 'root',
})
export class ServerConfigService {
constructor (
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly embassyApi: ApiService,
) { }
async presentAlert (key: string, current?: any): Promise<HTMLIonAlertElement> {
const spec = serverConfig[key]
let inputs: AlertInput[]
let buttons: AlertButton[] = [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Save',
handler: async (data: any) => {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Saving...',
cssClass: 'loader',
})
loader.present()
try {
await this.saveFns[key](data)
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
},
cssClass: 'enter-click',
},
]
switch (spec.type) {
case 'boolean':
inputs = [
{
name: 'enabled',
type: 'radio',
label: 'Enabled',
value: true,
checked: current,
},
{
name: 'disabled',
type: 'radio',
label: 'Disabled',
value: false,
checked: !current,
},
]
break
default:
return
}
const alert = await this.alertCtrl.create({
header: spec.name,
message: spec.description,
inputs,
buttons,
})
await alert.present()
return alert
}
// async presentModalForm (key: string) {
// const modal = await this.modalCtrl.create({
// component: AppActionInputPage,
// componentProps: {
// title: serverConfig[key].name,
// spec: (serverConfig[key] as ValueSpecObject).spec,
// },
// })
// modal.onWillDismiss().then(res => {
// if (!res.data) return
// this.saveFns[key](res.data)
// })
// await modal.present()
// }
saveFns: { [key: string]: (val: any) => Promise<any> } = {
'auto-check-updates': async (enabled: boolean) => {
return this.embassyApi.setDbValue({ pointer: '/auto-check-updates', value: enabled })
},
// 'eos-marketplace': async () => {
// return this.embassyApi.setEosMarketplace()
// },
// 'package-marketplace': async (url: string) => {
// return this.embassyApi.setPackageMarketplace({ url })
// },
'share-stats': async (enabled: boolean) => {
return this.embassyApi.setShareStats({ value: enabled })
},
// password: async (password: string) => {
// return this.embassyApi.updatePassword({ password })
// },
}
}
export const serverConfig: ConfigSpec = {
'auto-check-updates': {
type: 'boolean',
name: 'Auto Check for Updates',
description: 'If enabled, EmbassyOS will automatically check for updates of itself and any installed services. Updating will still require your approval and action. Updates will never be performed automatically.',
default: true,
},
// 'eos-marketplace': {
// type: 'boolean',
// name: 'Tor Only Marketplace',
// description: `Use Start9's Tor (instead of clearnet) Marketplace.`,
// warning: 'This will result in higher latency and slower download times.',
// default: false,
// },
// 'package-marketplace': {
// type: 'string',
// name: 'Package Marketplace',
// description: `Use for alternative embassy marketplace. Leave empty to use start9's marketplace.`,
// nullable: true,
// // @TODO regex for URL
// // pattern: '',
// 'pattern-description': 'Must be a valid URL.',
// masked: false,
// copyable: false,
// },
'share-stats': {
type: 'boolean',
name: 'Report Bugs',
description: new IonicSafeString(`If enabled, generic error codes will be anonymously transmitted over Tor to the Start9 team. This helps us identify and fix bugs quickly. <a href="https://docs.start9.com/user-manual/general/user-preferences/report-bugs.html" target="_blank" rel="noreferrer">Read more</a> `) as any,
default: false,
},
// password: {
// type: 'string',
// name: 'Change Password',
// description: `Your Embassy's master password, used for authentication and disk encryption.`,
// nullable: false,
// // @TODO regex for 12 chars
// // pattern: '',
// 'pattern-description': 'Must contain at least 12 characters.',
// warning: 'If you forget your master password, there is absolutely no way to recover your data. This can result in loss of money! Keep in mind, old backups will still be encrypted by the password used to encrypt them.',
// masked: false,
// copyable: false,
// },
}

View File

@@ -0,0 +1,9 @@
import { BehaviorSubject } from 'rxjs'
import { Injectable } from '@angular/core'
@Injectable({
providedIn: 'root',
})
export class SplitPaneTracker {
sidebarOpen$: BehaviorSubject<boolean> = new BehaviorSubject(false)
}

View File

@@ -0,0 +1,253 @@
import { Injectable } from '@angular/core'
import { AlertController, IonicSafeString, ModalController, NavController } from '@ionic/angular'
import { wizardModal } from '../components/install-wizard/install-wizard.component'
import { WizardBaker } from '../components/install-wizard/prebaked-wizards'
import { OSWelcomePage } from '../modals/os-welcome/os-welcome.page'
import { displayEmver } from '../pipes/emver.pipe'
import { RR } from './api/api.types'
import { ConfigService } from './config.service'
import { Emver } from './emver.service'
import { MarketplaceService } from '../pages/marketplace-routes/marketplace.service'
import { DataModel } from './patch-db/data-model'
import { PatchDbService } from './patch-db/patch-db.service'
import { filter, take } from 'rxjs/operators'
import { isEmptyObject } from '../util/misc.util'
import { ApiService } from './api/embassy-api.service'
import { Subscription } from 'rxjs'
import { ServerConfigService } from './server-config.service'
@Injectable({
providedIn: 'root',
})
export class StartupAlertsService {
private checks: Check<any>[]
constructor (
private readonly alertCtrl: AlertController,
private readonly navCtrl: NavController,
private readonly config: ConfigService,
private readonly modalCtrl: ModalController,
private readonly marketplaceService: MarketplaceService,
private readonly api: ApiService,
private readonly emver: Emver,
private readonly wizardBaker: WizardBaker,
private readonly patch: PatchDbService,
private readonly serverConfig: ServerConfigService,
) {
const osWelcome: Check<boolean> = {
name: 'osWelcome',
shouldRun: () => this.shouldRunOsWelcome(),
check: async () => true,
display: () => this.displayOsWelcome(),
}
const shareStats: Check<boolean> = {
name: 'shareStats',
shouldRun: () => this.shouldRunShareStats(),
check: async () => true,
display: () => this.displayShareStats(),
}
const osUpdate: Check<RR.GetMarketplaceEOSRes | undefined> = {
name: 'osUpdate',
shouldRun: () => this.shouldRunOsUpdateCheck(),
check: () => this.osUpdateCheck(),
display: pkg => this.displayOsUpdateCheck(pkg),
}
const pkgsUpdate: Check<boolean> = {
name: 'pkgsUpdate',
shouldRun: () => this.shouldRunAppsCheck(),
check: () => this.appsCheck(),
display: () => this.displayAppsCheck(),
}
this.checks = [osWelcome, shareStats, osUpdate, pkgsUpdate]
}
// 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
// Each promise fires more or less concurrently, so each c.check(server) is run concurrently
// Then, since we await previousDisplay before c.display(res), each promise executing gets hung awaiting the display of the previous run
runChecks (): Subscription {
return this.patch.watch$()
.pipe(
filter(data => !isEmptyObject(data)),
take(1),
)
.subscribe(async () => {
await this.checks
.filter(c => !this.config.skipStartupAlerts && c.shouldRun())
// returning true in the below block means to continue to next modal
// returning false means to skip all subsequent modals
.reduce(async (previousDisplay, c) => {
let checkRes: any
try {
checkRes = await c.check()
} catch (e) {
console.error(`Exception in ${c.name} check:`, e)
return true
}
const displayRes = await previousDisplay
if (!checkRes) return true
if (displayRes) return c.display(checkRes)
}, Promise.resolve(true))
})
}
// ** should run **
private shouldRunOsWelcome (): boolean {
return this.patch.getData().ui['ack-welcome'] !== this.config.version
}
private shouldRunShareStats (): boolean {
return !this.patch.getData().ui['ack-share-stats']
}
private shouldRunOsUpdateCheck (): boolean {
return this.patch.getData().ui['auto-check-updates']
}
private shouldRunAppsCheck (): boolean {
return this.patch.getData().ui['auto-check-updates']
}
// ** check **
private async osUpdateCheck (): Promise<RR.GetMarketplaceEOSRes | undefined> {
const res = await this.api.getEos({
'eos-version-compat': this.patch.getData()['server-info']['eos-version-compat'],
})
if (this.emver.compare(this.config.version, res.version) === -1) {
return res
} else {
return undefined
}
}
private async appsCheck (): Promise<boolean> {
const updates = await this.marketplaceService.getUpdates(this.patch.getData()['package-data'])
return !!updates.length
}
// ** display **
private async displayOsWelcome (): Promise<boolean> {
return new Promise(async resolve => {
const modal = await this.modalCtrl.create({
component: OSWelcomePage,
presentingElement: await this.modalCtrl.getTop(),
componentProps: {
version: this.config.version,
},
})
modal.onWillDismiss().then(() => {
this.api.setDbValue({ pointer: '/ack-welcome', value: this.config.version })
.catch()
return resolve(true)
})
await modal.present()
})
}
private async displayShareStats (): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.serverConfig.presentAlert('share-stats', this.patch.getData()['server-info']['share-stats'])
alert.onDidDismiss().then(() => {
this.api.setDbValue({ pointer: '/ack-share-stats', value: this.config.version })
.catch()
return resolve(true)
})
})
}
private async displayOsUpdateCheck (eos: RR.GetMarketplaceEOSRes): Promise<boolean> {
const { update } = await this.presentAlertNewOS(eos.version)
if (update) {
const { cancelled } = await wizardModal(
this.modalCtrl,
this.wizardBaker.updateOS({
version: eos.version,
headline: eos.headline,
releaseNotes: eos['release-notes'],
}),
)
if (cancelled) return true
return false
}
return true
}
private async displayAppsCheck (): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Updates Available!',
message: new IonicSafeString(
`<div style="display: flex; flex-direction: column; justify-content: space-around; min-height: 100px">
<div>New service updates are available in the Marketplace.</div>
<div style="font-size:x-small">You can disable these checks in your Embassy Config</div>
</div>
`,
),
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => resolve(true),
},
{
text: 'View in Marketplace',
handler: () => {
this.navCtrl.navigateForward('/marketplace').then(() => resolve(false))
},
cssClass: 'enter-click',
},
],
})
await alert.present()
})
}
// more
private async presentAlertNewOS (versionLatest: string): Promise<{ cancel?: true, update?: true }> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'New EmbassyOS Version!',
message: new IonicSafeString(
`<div style="display: flex; flex-direction: column; justify-content: space-around; min-height: 100px">
<div>Update EmbassyOS to version ${displayEmver(versionLatest)}?</div>
<div style="font-size:x-small">You can disable these checks in your Embassy Config</div>
</div>
`,
),
buttons: [
{
text: 'Not now',
role: 'cancel',
handler: () => resolve({ cancel: true }),
},
{
text: 'Update',
handler: () => resolve({ update: true }),
cssClass: 'enter-click',
},
],
})
await alert.present()
})
}
}
type Check<T> = {
// validates whether a check should run based on server properties
shouldRun: () => boolean
// executes a check, often requiring api call. It should return a false-y value if there should be no display.
check: () => Promise<T>
// display an alert based on the result of the check.
// return false if subsequent modals should not be displayed
display: (a: T) => Promise<boolean>
// for logging purposes
name: string
}

View File

@@ -0,0 +1,22 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { PackageDataEntry } from './patch-db/data-model'
import { ConfigService } from './config.service'
@Injectable({
providedIn: 'root',
})
export class UiLauncherService {
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly config: ConfigService,
) {}
launch(pkg: PackageDataEntry): void {
this.document.defaultView.open(
this.config.launchableURL(pkg),
'_blank',
'noreferrer',
)
}
}