0.3.0 refactor

ui: adds overlay layer to patch-db-client

ui: getting towards mocks

ui: cleans up factory init

ui: nice type hack

ui: live api for patch

ui: api service source + http

starts up

ui: api source + http

ui: rework patchdb config, pass stashTimeout into patchDbModel

wires in temp patching into api service

ui: example of wiring patchdbmodel into page

begin integration

remove unnecessary method

linting

first data rendering

rework app initialization

http source working for ssh delete call

temp patches working

entire Embassy tab complete

not in kansas anymore

ripping, saving progress

progress for API request response types and endoint defs

Update data-model.ts

shambles, but in a good way

progress

big progress

progress

installed list working

big progress

progress

progress

begin marketplace redesign

Update api-types.ts

Update api-types.ts

marketplace improvements

cosmetic

dependencies and recommendations

begin nym auth approach

install wizard

restore flow and donations
This commit is contained in:
Aaron Greenspan
2021-02-16 13:45:09 -07:00
committed by Aiden McClelland
parent fd685ae32c
commit 594d93eb3b
238 changed files with 15137 additions and 21331 deletions

View File

@@ -1,107 +0,0 @@
//////////////// Install/Uninstall ////////////////////////////////////////////////
type AppDependentBreakage = {
// id of the dependent app which will or did break (Stopped) given the action.
id: string
title: string
iconUrl: string
}
POST /apps/:appId/install(?dryrun)
body: {
version: string, //semver
}
response : ApiAppInstalledFull & { breakages: AppDependentBreakage[] }
POST /apps/:appId/uninstall(?dryrun)
response : { breakages: AppDependentBreakage[] }
/////////////////////////////// Store/Show /////////////////////////////////////////////////
type ApiAppAvailableFull = ... {
// app base data
id: string
title: string
status: AppStatus | null
versionInstalled: string | null
iconURL: string
// preview data
versionLatest: string
descriptionShort: string
// version specific data
releaseNotes: string
serviceRequirements: AppDependencyRequirement[]
// other data
descriptionLong: string,
version: string[],
}
type AppDependencyRequirement = ... {
//app base data (minus status + version installed)
id: string
title: string
iconURL: string
// dependency data
optional: string | null
default: boolean
versionSpec: string
description: string | null
violation: AppDependencyRequirementViolation | null
}
type AppDependencyRequirementViolation =
{ name: 'missing'; suggestedVersion: string; } |
{ name: 'incompatible-version'; suggestedVersion: string; } |
{ name: 'incompatible-config'; ruleViolations: string; auto-configurable: boolean } | // (auto-configurable for if/when we do that)
{ name: 'incompatible-status'; status: AppStatus; }
// Get App Available Full
GET /apps/:appId/store
response: ApiAppAvailableFull
// Get Version Specific Data for an App Available
GET /apps/:appId/store/:version
response: {
// version specific data
releaseNotes: string
serviceRequirements: AppDependencyRequirement[]
}
///////////////////////////// Installed/Show ///////////////////////////////////////////
type ApiAppInstalledFull {
// app base data
id: string
title: string
status: AppStatus | null
versionInstalled: string | null
iconURL: string
// preview data
// other data
instructions: string | null
lastBackup: string | null
configuredRequirements: AppDependencyRequirement[] | null // null if not yet configured
}
// Get App Installed Full
GET /apps/:appId/installed
reseponse: AppInstalledFull

View File

@@ -1,43 +1,307 @@
import { ConfigSpec } from 'src/app/app-config/config-types'
import { AppAvailableFull, AppInstalledFull, AppInstalledPreview } from 'src/app/models/app-types'
import { Rules } from '../../models/app-model'
import { SSHFingerprint, ServerStatus, ServerSpecs } from '../../models/server-model'
import { Dump, Operation, 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/models/patch-db/data-model'
/** SERVER **/
export module RR {
export interface ApiServer {
name: string
status: ServerStatus
versionInstalled: string
alternativeRegistryUrl: string | null
specs: ServerSpecs
wifi: {
ssids: string[]
current: string | null
// 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 SubmitPinReq = { pin: string } // auth.pin - unauthed
export type SubmitPinRes = null
export type SubmitPasswordReq = { password: string } // auth.password - unauthed
export type SubmitPasswordRes = null
export type LogoutReq = { } // auth.logout
export type LogoutRes = null
// server
export type GetServerLogsReq = { before?: string } // server.logs
export type GetServerLogsRes = Log[]
export type GetServerMetricsReq = { } // server.metrics
export type GetServerMetricsRes = ServerMetrics
export type UpdateServerReq = WithExpire<{ }> // server.update
export type UpdateServerRes = WithRevision<null>
export type RestartServerReq = { } // server.restart
export type RestartServerRes = null
export type ShutdownServerReq = { } // server.shutdown
export type ShutdownServerRes = null
// network
export type RefreshLanReq = { } // network.lan.refresh
export type RefreshLanRes = null
// registry
export type SetRegistryReq = WithExpire<{ url: string }> // registry.set
export type SetRegistryRes = WithRevision<null>
// notification
export type GetNotificationsReq = WithExpire<{ page: number, 'per-page': number }> // notification.list
export type GetNotificationsRes = WithRevision<ServerNotification<number>[]>
export type DeleteNotificationReq = { id: string } // notification.delete
export type DeleteNotificationRes = null
// wifi
export type AddWifiReq = { // wifi.add
ssid: string
password: string
country: string
priority: number
connect: boolean
}
ssh: SSHFingerprint[]
serverId: string
welcomeAck: boolean
autoCheckUpdates: boolean
export type AddWifiRes = null
export type ConnectWifiReq = WithExpire<{ ssid: string }> // wifi.connect
export type ConnectWifiRes = WithRevision<null>
export type DeleteWifiReq = WithExpire<{ ssid: string }> // wifi.delete
export type DeleteWifiRes = WithRevision<null>
// ssh
export type GetSSHKeysReq = { } // ssh.get
export type GetSSHKeysRes = SSHKeys
export type AddSSHKeyReq = { pubkey: string } // ssh.add
export type AddSSHKeyRes = SSHKeys
export type DeleteSSHKeyReq = { hash: string } // ssh.delete
export type DeleteSSHKeyRes = null
// backup
export type CreateBackupReq = WithExpire<{ logicalname: string, password: string }> // backup.create
export type CreateBackupRes = WithRevision<null>
export type RestoreBackupReq = { logicalname: string, password: string } // backup.restore - unauthed
export type RestoreBackupRes = null
// disk
export type GetDisksReq = { } // disk.list
export type GetDisksRes = DiskInfo
export type EjectDisksReq = { logicalname: string } // disk.eject
export type EjectDisksRes = null
// package
export type GetPackagePropertiesReq = { id: string } // package.properties
export type GetPackagePropertiesRes<T extends number> = PackagePropertiesVersioned<T>
export type GetPackageLogsReq = { id: string, before?: string } // package.logs
export type GetPackageLogsRes = Log[]
export type InstallPackageReq = WithExpire<{ id: string, version: string }> // package.install
export type InstallPackageRes = WithRevision<null>
export type DryUpdatePackageReq = { id: string, version: string } // package.update.dry
export type DryUpdatePackageRes = BreakageRes
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 = BreakageRes
export type SetPackageConfigReq = WithExpire<DrySetPackageConfigReq> // package.config.set
export type SetPackageConfigRes = WithRevision<null>
export type RestorePackageReq = WithExpire<{ id: string, logicalname: string, password: string }> // package.backup.restore
export type RestorePackageRes = 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 = BreakageRes
export type StopPackageReq = WithExpire<{ id: string }> // package.stop
export type StopPackageRes = WithRevision<null>
export type DryRemovePackageReq = RemovePackageReq // package.remove.dry
export type DryRemovePackageRes = BreakageRes
export type RemovePackageReq = WithExpire<{ id: string }> // package.remove
export type RemovePackageRes = WithRevision<null>
export type DryConfigureDependencyReq = { 'dependency-id': string, 'dependent-id': string } // package.dependency.configure.dry
export type DryConfigureDependencyRes = object
// marketplace
export type GetMarketplaceDataReq = { }
export type GetMarketplaceDataRes = MarketplaceData
export type GetMarketplaceEOSReq = { }
export type GetMarketplaceEOSRes = MarketplaceEOS
export type GetAvailableListReq = { category?: string, query?: string, page: number, 'per-page': number }
export type GetAvailableListRes = AvailablePreview[]
export type GetAvailableShowReq = { id: string, version?: string }
export type GetAvailableShowRes = AvailableShow
}
/** APPS **/
export type ApiAppAvailableFull = Omit<AppAvailableFull, 'versionViewing'>
export type WithExpire<T> = { 'expire-id'?: string } & T
export type WithRevision<T> = { response: T, revision?: Revision }
export type ApiAppInstalledPreview = Omit<AppInstalledPreview, 'hasUI' | 'launchable'>
export type ApiAppInstalledFull = Omit<AppInstalledFull, 'hasFetchedFull' | 'hasUI' | 'launchable'>
export interface ApiAppConfig {
spec: ConfigSpec
config: object | null
rules: Rules[]
export interface MarketplaceData {
categories: string[]
}
/** MISC **/
export type Unit = { never?: never; } // hack for the unit typ
export type V1Status = {
status: 'nothing' | 'instructions' | 'available'
export interface MarketplaceEOS {
version: string
headline: string
notes: string
}
export interface AvailablePreview {
id: string
title: string
version: string
icon: URL
descriptionShort: string
}
export interface AvailableShow {
icon: URL
manifest: Manifest
categories: string[]
versions: string[]
'dependency-metadata': {
[id: string]: {
title: string
icon: URL
}
}
}
export interface BreakageRes {
patch: Operation[],
breakages: Breakages
}
export interface Breakages {
[id: string]: TaggedDependencyError
}
export interface TaggedDependencyError {
dependency: string,
error: DependencyError,
}
export interface Log {
timestamp: string
log: string
}
export interface ActionResponse {
message: string
value: string | number | boolean | null
copyable: boolean
qr: boolean
}
export interface ServerMetrics {
[key: string]: {
[key: string]: {
value: string | number | null
unit?: string
}
}
}
export interface DiskInfo {
[id: string]: DiskInfoEntry
}
export interface DiskInfoEntry {
size: string
description: string | null
partitions: PartitionInfo
}
export interface PartitionInfo {
[logicalname: string]: PartitionInfoEntry
}
export interface PartitionInfoEntry {
'is-mounted': boolean // We do not allow backups to mounted partitions
size: string | null
label: string | null
}
export interface ServerSpecs {
[key: string]: string | number
}
export interface SSHKeys {
[hash: string]: SSHKeyEntry
}
export interface SSHKeyEntry {
alg: string
hostname: string
hash: string
}
export type ServerNotifications = ServerNotification<any>[]
export interface ServerNotification<T extends number> {
id: string
'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
}
}
}

View File

@@ -1,14 +1,12 @@
import { HttpService } from '../http.service'
import { AppModel } from '../../models/app-model'
import { MockApiService } from './mock-api.service'
import { LiveApiService } from './live-api.service'
import { ServerModel } from 'src/app/models/server-model'
import { ConfigService } from '../config.service'
export function ApiServiceFactory (config: ConfigService, http: HttpService, appModel: AppModel, serverModel: ServerModel) {
if (config.api.useMocks) {
return new MockApiService(appModel, serverModel, config)
export function ApiServiceFactory (config: ConfigService, http: HttpService) {
if (config.api.mocks) {
return new MockApiService(config)
} else {
return new LiveApiService(http, appModel, serverModel, config)
return new LiveApiService(http, config)
}
}

View File

@@ -1,124 +1,209 @@
import { Rules } from '../../models/app-model'
import { AppAvailablePreview, AppAvailableFull, AppInstalledPreview, AppInstalledFull, DependentBreakage, AppAvailableVersionSpecificInfo, ServiceAction } from '../../models/app-types'
import { S9Notification, SSHFingerprint, ServerMetrics, DiskInfo } from '../../models/server-model'
import { Subject, Observable } from 'rxjs'
import { Unit, ApiServer, ApiAppInstalledFull, ApiAppConfig, ApiAppAvailableFull, ApiAppInstalledPreview, V1Status } from './api-types'
import { AppMetrics, AppMetricsVersioned } from 'src/app/util/metrics.util'
import { ConfigSpec } from 'src/app/app-config/config-types'
import { Http, Source, Update, Operation, Revision } from 'patch-db-client'
import { RR } from './api-types'
import { DataModel } from 'src/app/models/patch-db/data-model'
import { filter } from 'rxjs/operators'
import * as uuid from 'uuid'
export abstract class ApiService {
private $unauthorizedApiResponse$: Subject<{ }> = new Subject()
export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
protected readonly sync = new Subject<Update<DataModel>>()
private syncing = true
watch401$ (): Observable<{ }> {
return this.$unauthorizedApiResponse$.asObservable()
/** 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$ (_?: Observable<number>): Observable<Update<DataModel>> {
return this.sync.asObservable().pipe(filter(() => this.syncing))
}
authenticatedRequestsEnabled: boolean = false
// used for determining internet connectivity
abstract ping (): Promise<void>
protected received401 () {
this.authenticatedRequestsEnabled = false
this.$unauthorizedApiResponse$.next()
// 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 submitPin (params: RR.SubmitPinReq): Promise<RR.SubmitPinRes>
abstract submitPassword (params: RR.SubmitPasswordReq): Promise<RR.SubmitPasswordReq>
abstract logout (params: RR.LogoutReq): Promise<RR.LogoutRes>
// server
abstract getServerLogs (params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes>
abstract getServerMetrics (params: RR.GetServerMetricsReq): Promise<RR.GetServerMetricsRes>
protected abstract updateServerRaw (params: RR.UpdateServerReq): Promise<RR.UpdateServerRes>
updateServer = (params: RR.UpdateServerReq) => this.syncResponse(
() => this.updateServerRaw(params),
)()
abstract restartServer (params: RR.UpdateServerReq): Promise<RR.RestartServerRes>
abstract shutdownServer (params: RR.ShutdownServerReq): Promise<RR.ShutdownServerRes>
// network
abstract refreshLan (params: RR.RefreshLanReq): Promise<RR.RefreshLanRes>
// registry
protected abstract setRegistryRaw (params: RR.SetRegistryReq): Promise<RR.SetRegistryRes>
setRegistry = (params: RR.SetRegistryReq) => this.syncResponse(
() => this.setRegistryRaw(params),
)()
// 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>
// wifi
abstract addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes>
protected abstract connectWifiRaw (params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes>
connectWifi = (params: RR.ConnectWifiReq) => this.syncResponse(
() => this.connectWifiRaw(params),
)()
protected abstract deleteWifiRaw (params: RR.DeleteWifiReq): Promise<RR.ConnectWifiRes>
deleteWifi = (params: RR.DeleteWifiReq) => this.syncResponse(
() => this.deleteWifiRaw(params),
)()
// 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
protected abstract createBackupRaw (params: RR.CreateBackupReq): Promise<RR.CreateBackupRes>
createBackup = (params: RR.CreateBackupReq) => this.syncResponse(
() => this.createBackupRaw(params),
)()
protected abstract restoreBackupRaw (params: RR.RestoreBackupReq): Promise<RR.RestoreBackupRes>
restoreBackup = (params: RR.RestoreBackupReq) => this.syncResponse(
() => this.restoreBackupRaw(params),
)()
// disk
abstract getDisks (params: RR.GetDisksReq): Promise<RR.GetDisksRes>
abstract ejectDisk (params: RR.EjectDisksReq): Promise<RR.EjectDisksRes>
// package
abstract getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes<any>['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 restorePackageRaw (params: RR.RestorePackageReq): Promise<RR.RestorePackageRes>
restorePackage = (params: RR.RestorePackageReq) => this.syncResponse(
() => this.restorePackageRaw(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 dryRemovePackage (params: RR.DryRemovePackageReq): Promise<RR.DryRemovePackageRes>
protected abstract removePackageRaw (params: RR.RemovePackageReq): Promise<RR.RemovePackageRes>
removePackage = (params: RR.RemovePackageReq) => this.syncResponse(
() => this.removePackageRaw(params),
)()
abstract dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes>
// marketplace
abstract getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise<RR.GetMarketplaceDataRes>
abstract getEos (params: RR.GetMarketplaceEOSReq): Promise<RR.GetMarketplaceEOSRes>
abstract getAvailableList (params: RR.GetAvailableListReq): Promise<RR.GetAvailableListRes>
abstract getAvailableShow (params: RR.GetAvailableShowReq): Promise<RR.GetAvailableShowRes>
// 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).then(({ response, revision }) => {
if (revision) this.sync.next(revision)
return response
}) as any
}
}
abstract testConnection (url: string): Promise<true>
abstract getCheckAuth (): Promise<Unit> // Throws an error on failed auth.
abstract postLogin (password: string): Promise<Unit> // Throws an error on failed auth.
abstract postLogout (): Promise<Unit> // Throws an error on failed auth.
abstract getServer (timeout?: number): Promise<ApiServer>
abstract getVersionLatest (): Promise<ReqRes.GetVersionLatestRes>
abstract getServerMetrics (): Promise<ReqRes.GetServerMetricsRes>
abstract getNotifications (page: number, perPage: number): Promise<S9Notification[]>
abstract deleteNotification (id: string): Promise<Unit>
abstract toggleAppLAN (appId: string, toggle: 'enable' | 'disable'): Promise<Unit>
abstract updateAgent (version: any): Promise<Unit>
abstract acknowledgeOSWelcome (version: string): Promise<Unit>
abstract getAvailableApps (): Promise<AppAvailablePreview[]>
abstract getAvailableApp (appId: string): Promise<AppAvailableFull>
abstract getAvailableAppVersionSpecificInfo (appId: string, versionSpec: string): Promise<AppAvailableVersionSpecificInfo>
abstract getInstalledApp (appId: string): Promise<AppInstalledFull>
abstract getAppMetrics (appId: string): Promise<AppMetrics>
abstract getInstalledApps (): Promise<AppInstalledPreview[]>
abstract getExternalDisks (): Promise<DiskInfo[]>
abstract getAppConfig (appId: string): Promise<{ spec: ConfigSpec, config: object, rules: Rules[] }>
abstract getAppLogs (appId: string, params?: ReqRes.GetAppLogsReq): Promise<string[]>
abstract getServerLogs (): Promise<string[]>
abstract installApp (appId: string, version: string, dryRun?: boolean): Promise<AppInstalledFull & { breakages: DependentBreakage[] }>
abstract uninstallApp (appId: string, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
abstract startApp (appId: string): Promise<Unit>
abstract stopApp (appId: string, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
abstract restartApp (appId: string): Promise<Unit>
abstract createAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit>
abstract restoreAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit>
abstract stopAppBackup (appId: string): Promise<Unit>
abstract patchAppConfig (app: AppInstalledPreview, config: object, dryRun?: boolean): Promise<{ breakages: DependentBreakage[] }>
abstract postConfigureDependency (dependencyId: string, dependentId: string, dryRun?: boolean): Promise<{ config: object, breakages: DependentBreakage[] }>
abstract patchServerConfig (attr: string, value: any): Promise<Unit>
abstract wipeAppData (app: AppInstalledPreview): Promise<Unit>
abstract addSSHKey (sshKey: string): Promise<Unit>
abstract deleteSSHKey (sshKey: SSHFingerprint): Promise<Unit>
abstract addWifi (ssid: string, password: string, country: string, connect: boolean): Promise<Unit>
abstract connectWifi (ssid: string): Promise<Unit>
abstract deleteWifi (ssid: string): Promise<Unit>
abstract restartServer (): Promise<Unit>
abstract shutdownServer (): Promise<Unit>
abstract ejectExternalDisk (logicalName: string): Promise<Unit>
abstract serviceAction (appId: string, serviceAction: ServiceAction): Promise<ReqRes.ServiceActionResponse>
abstract refreshLAN (): Promise<Unit>
abstract checkV1Status (): Promise<V1Status>
// @TODO better types?
// private async process<T, F extends (args: object) => Promise<{ response: T, revision?: Revision }>> (f: F, temps: Operation[] = []): Promise<T> {
// let expireId = undefined
// if (temps.length) {
// expireId = uuid.v4()
// this.sync.next({ patch: temps, expiredBy: expireId })
// }
// const { response, revision } = await f({ ...f.arguments, expireId })
// if (revision) this.sync.next(revision)
// return response
// }
}
export function isRpcFailure<Error, Result> (arg: { error: Error } | { result: Result }): arg is { error: Error } {
return !!(arg as any).error
}
export function isRpcSuccess<Error, Result> (arg: { error: Error } | { result: Result }): arg is { result: Result } {
return !!(arg as any).result
}
export module ReqRes {
export type GetVersionRes = { version: string }
export type PostLoginReq = { password: string }
export type PostLoginRes = Unit
export type ServiceActionRequest = {
jsonrpc: '2.0',
id: string,
method: string
}
export type ServiceActionResponse = {
jsonrpc: '2.0',
id: string
} & ({ error: { code: number, message: string } } | { result: string })
export type GetCheckAuthRes = { }
export type GetServerRes = ApiServer
export type GetVersionLatestRes = { versionLatest: string, releaseNotes: string }
export type GetServerMetricsRes = ServerMetrics
export type GetAppAvailableRes = ApiAppAvailableFull
export type GetAppAvailableVersionInfoRes = AppAvailableVersionSpecificInfo
export type GetAppsAvailableRes = AppAvailablePreview[]
export type GetExternalDisksRes = DiskInfo[]
export type GetAppInstalledRes = ApiAppInstalledFull
export type GetAppConfigRes = ApiAppConfig
export type GetAppLogsReq = { after?: string, before?: string, page?: string, perPage?: string }
export type GetServerLogsReq = { }
export type GetAppLogsRes = string[]
export type GetServerLogsRes = string[]
export type GetAppMetricsRes = AppMetricsVersioned<number>
export type GetAppsInstalledRes = ApiAppInstalledPreview[]
export type PostInstallAppReq = { version: string }
export type PostInstallAppRes = ApiAppInstalledFull & { breakages: DependentBreakage[] }
export type PostUpdateAgentReq = { version: string }
export type PostAppBackupCreateReq = { logicalname: string, password: string }
export type PostAppBackupCreateRes = Unit
export type PostAppBackupRestoreReq = { logicalname: string, password: string }
export type PostAppBackupRestoreRes = Unit
export type PostAppBackupStopRes = Unit
export type PatchAppConfigReq = { config: object }
export type PatchServerConfigReq = { value: string }
export type GetNotificationsReq = { page: string, perPage: string }
export type GetNotificationsRes = S9Notification[]
export type PostAddWifiReq = { ssid: string, password: string, country: string, skipConnect: boolean }
export type PostConnectWifiReq = { country: string }
export type PostAddSSHKeyReq = { sshKey: string }
export type PostAddSSHKeyRes = SSHFingerprint
}
// used for type inference in syncResponse
type ExtractResultPromise<T extends Promise<any>> = T extends Promise<infer R> ? Promise<R> : any

View File

@@ -1,338 +1,223 @@
import { Injectable } from '@angular/core'
import { HttpService, Method, HttpOptions } from '../http.service'
import { AppModel, AppStatus } from '../../models/app-model'
import { AppAvailablePreview, AppAvailableFull, AppInstalledFull, AppInstalledPreview, DependentBreakage, AppAvailableVersionSpecificInfo, ServiceAction } from '../../models/app-types'
import { S9Notification, SSHFingerprint, ServerModel, DiskInfo } from '../../models/server-model'
import { ApiService, ReqRes } from './api.service'
import { ApiAppInstalledPreview, ApiServer, Unit, V1Status } from './api-types'
import { HttpErrorResponse } from '@angular/common/http'
import { isUnauthorized } from 'src/app/util/web.util'
import { Replace } from 'src/app/util/types.util'
import { AppMetrics, parseMetricsPermissive } from 'src/app/util/metrics.util'
import { modulateTime } from 'src/app/util/misc.util'
import { Observable, of, throwError } from 'rxjs'
import { catchError, mapTo } from 'rxjs/operators'
import * as uuid from 'uuid'
import { HttpService, Method } from '../http.service'
import { ApiService } from './api.service'
import { RR } from './api-types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { ConfigService } from '../config.service'
@Injectable()
export class LiveApiService extends ApiService {
constructor (
private readonly http: HttpService,
// TODO remove app + server model from here. updates to state should be done in a separate class wrapping ApiService + App/ServerModel
private readonly appModel: AppModel,
private readonly serverModel: ServerModel,
private readonly config: ConfigService,
) { super() }
testConnection (url: string): Promise<true> {
return this.http.raw.get(url).pipe(mapTo(true as true), catchError(e => catchHttpStatusError(e))).toPromise()
async ping (): Promise<void> {
return this.http.rpcRequest({ method: 'ping', params: { } })
}
// Used to check whether password or key is valid. If so, it will be used implicitly by all other calls.
async getCheckAuth (): Promise<Unit> {
return this.http.serverRequest<Unit>({ method: Method.GET, url: '/authenticate' }, { version: '' })
async getStatic (url: string): Promise<string> {
return this.http.httpRequest({ method: Method.GET, url })
}
async postLogin (password: string): Promise<Unit> {
return this.http.serverRequest<Unit>({ method: Method.POST, url: '/auth/login', data: { password } }, { version: '' })
// db
async getRevisions (since: number): Promise<RR.GetRevisionsRes> {
return this.http.rpcRequest({ method: 'db.revisions', params: { since } })
}
async postLogout (): Promise<Unit> {
return this.http.serverRequest<Unit>({ method: Method.POST, url: '/auth/logout' }, { version: '' }).then(() => { this.authenticatedRequestsEnabled = false; return { } })
async getDump (): Promise<RR.GetDumpRes> {
return this.http.rpcRequest({ method: 'db.dump' })
}
async getServer (timeout?: number): Promise<ApiServer> {
return this.authRequest<ReqRes.GetServerRes>({ method: Method.GET, url: '/', readTimeout: timeout })
async setDbValueRaw (params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
return this.http.rpcRequest({ method: 'db.put.ui', params })
}
async acknowledgeOSWelcome (version: string): Promise<Unit> {
return this.authRequest<Unit>({ method: Method.POST, url: `/welcome/${version}` })
// auth
async submitPin (params: RR.SubmitPinReq): Promise<RR.SubmitPinRes> {
return this.http.rpcRequest({ method: 'auth.pin', params })
}
async getVersionLatest (): Promise<ReqRes.GetVersionLatestRes> {
return this.authRequest<ReqRes.GetVersionLatestRes>({ method: Method.GET, url: '/versionLatest' }, { version: '' })
async submitPassword (params: RR.SubmitPasswordReq): Promise<RR.SubmitPasswordRes> {
return this.http.rpcRequest({ method: 'auth.password', params })
}
async getServerMetrics (): Promise<ReqRes.GetServerMetricsRes> {
return this.authRequest<ReqRes.GetServerMetricsRes>({ method: Method.GET, url: `/metrics` })
async logout (params: RR.LogoutReq): Promise<RR.LogoutRes> {
return this.http.rpcRequest({ method: 'auth.logout', params })
}
async getNotifications (page: number, perPage: number): Promise<S9Notification[]> {
const params: ReqRes.GetNotificationsReq = {
page: String(page),
perPage: String(perPage),
}
return this.authRequest<ReqRes.GetNotificationsRes>({ method: Method.GET, url: `/notifications`, params })
// server
async getServerLogs (params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
return this.http.rpcRequest( { method: 'server.logs', params })
}
async deleteNotification (id: string): Promise<Unit> {
return this.authRequest({ method: Method.DELETE, url: `/notifications/${id}` })
async getServerMetrics (params: RR.GetServerMetricsReq): Promise<RR.GetServerMetricsRes> {
return this.http.rpcRequest({ method: 'server.metrics', params })
}
async getExternalDisks (): Promise<DiskInfo[]> {
return this.authRequest<ReqRes.GetExternalDisksRes>({ method: Method.GET, url: `/disks` })
async updateServerRaw (params: RR.UpdateServerReq): Promise<RR.UpdateServerRes> {
return this.http.rpcRequest({ method: 'server.update', params })
}
// TODO: EJECT-DISKS
async ejectExternalDisk (logicalName: string): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/disks/eject`, data: { logicalName } })
async restartServer (params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
return this.http.rpcRequest({ method: 'server.restart', params })
}
async updateAgent (version: string): Promise<Unit> {
const data: ReqRes.PostUpdateAgentReq = {
version: `=${version}`,
}
return this.authRequest({ method: Method.POST, url: '/update', data })
async shutdownServer (params: RR.ShutdownServerReq): Promise<RR.ShutdownServerRes> {
return this.http.rpcRequest({ method: 'server.shutdown', params })
}
async getAvailableAppVersionSpecificInfo (appId: string, versionSpec: string): Promise<AppAvailableVersionSpecificInfo> {
return this
.authRequest<Replace<ReqRes.GetAppAvailableVersionInfoRes, 'versionViewing', 'version'>>({ method: Method.GET, url: `/apps/${appId}/store/${versionSpec}` })
.then(res => ({ ...res, versionViewing: res.version }))
.then(res => {
delete res['version']
return res
})
// network
async refreshLan (params: RR.RefreshLanReq): Promise<RR.RefreshLanRes> {
return this.http.rpcRequest({ method: 'network.lan.refresh', params })
}
async getAvailableApps (): Promise<AppAvailablePreview[]> {
const res = await this.authRequest<ReqRes.GetAppsAvailableRes>({ method: Method.GET, url: '/apps/store' })
return res.map(a => {
const latestVersionTimestamp = new Date(a.latestVersionTimestamp)
if (isNaN(latestVersionTimestamp as any)) throw new Error(`Invalid latestVersionTimestamp ${a.latestVersionTimestamp}`)
return { ...a, latestVersionTimestamp }
})
// registry
async setRegistryRaw (params: RR.SetRegistryReq): Promise<RR.SetRegistryRes> {
return this.http.rpcRequest({ method: 'registry.set', params })
}
async getAvailableApp (appId: string): Promise<AppAvailableFull> {
return this.authRequest<ReqRes.GetAppAvailableRes>({ method: Method.GET, url: `/apps/${appId}/store` })
.then(res => {
return {
...res,
versionViewing: res.versionLatest,
}
})
// notification
async getNotificationsRaw (params: RR.GetNotificationsReq): Promise<RR.GetNotificationsRes> {
return this.http.rpcRequest({ method: 'notifications.list', params })
}
async getInstalledApp (appId: string): Promise<AppInstalledFull> {
return this.authRequest<ReqRes.GetAppInstalledRes>({ method: Method.GET, url: `/apps/${appId}/installed` })
.then(app => {
return {
...app,
hasFetchedFull: true,
hasUI: this.config.hasUI(app),
launchable: this.config.isLaunchable(app),
}
})
async deleteNotification (params: RR.DeleteNotificationReq): Promise<RR.DeleteNotificationRes> {
return this.http.rpcRequest({ method: 'notifications.delete', params })
}
async getInstalledApps (): Promise<AppInstalledPreview[]> {
return this.authRequest<ReqRes.GetAppsInstalledRes>({ method: Method.GET, url: `/apps/installed` })
.then(apps => {
return apps.map(app => {
return {
...app,
hasUI: this.config.hasUI(app),
launchable: this.config.isLaunchable(app),
}
})
})
// wifi
async addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
return this.http.rpcRequest({ method: 'wifi.add', params })
}
async getAppConfig (appId: string): Promise<ReqRes.GetAppConfigRes> {
return this.authRequest<ReqRes.GetAppConfigRes>({ method: Method.GET, url: `/apps/${appId}/config` })
async connectWifiRaw (params: RR.ConnectWifiReq): Promise<RR.ConnectWifiRes> {
return this.http.rpcRequest({ method: 'wifi.connect', params })
}
async getAppLogs (appId: string, params: ReqRes.GetAppLogsReq = { }): Promise<string[]> {
return this.authRequest<ReqRes.GetAppLogsRes>({ method: Method.GET, url: `/apps/${appId}/logs`, params: params as any })
async deleteWifiRaw (params: RR.DeleteWifiReq): Promise<RR.DeleteWifiRes> {
return this.http.rpcRequest({ method: 'wifi.delete', params })
}
async getServerLogs (): Promise<string[]> {
return this.authRequest<ReqRes.GetServerLogsRes>({ method: Method.GET, url: `/logs` })
// ssh
async getSshKeys (params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
return this.http.rpcRequest({ method: 'ssh.get', params })
}
async getAppMetrics (appId: string): Promise<AppMetrics> {
return this.authRequest<ReqRes.GetAppMetricsRes | string>({ method: Method.GET, url: `/apps/${appId}/metrics` })
.then(parseMetricsPermissive)
async addSshKey (params: RR.AddSSHKeyReq): Promise<RR.AddSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.add', params })
}
async installApp (appId: string, version: string, dryRun: boolean = false): Promise<AppInstalledFull & { breakages: DependentBreakage[] }> {
const data: ReqRes.PostInstallAppReq = {
version,
}
return this.authRequest<ReqRes.PostInstallAppRes>({ method: Method.POST, url: `/apps/${appId}/install${dryRunParam(dryRun, true)}`, data })
.then(app => {
return {
...app,
hasFetchedFull: false,
hasUI: this.config.hasUI(app),
launchable: this.config.isLaunchable(app),
}
})
async deleteSshKey (params: RR.DeleteSSHKeyReq): Promise<RR.DeleteSSHKeyRes> {
return this.http.rpcRequest({ method: 'ssh.delete', params })
}
async uninstallApp (appId: string, dryRun: boolean = false): Promise<{ breakages: DependentBreakage[] }> {
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/uninstall${dryRunParam(dryRun, true)}`, readTimeout: 60000 })
// backup
async createBackupRaw (params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
return this.http.rpcRequest({ method: 'backup.create', params })
}
async startApp (appId: string): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/start`, readTimeout: 60000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.RUNNING }))
.then(() => ({ }))
async restoreBackupRaw (params: RR.RestoreBackupReq): Promise<RR.RestoreBackupRes> {
return this.http.rpcRequest({ method: 'backup.restore', params })
}
async stopApp (appId: string, dryRun: boolean = false): Promise<{ breakages: DependentBreakage[] }> {
const res = await this.authRequest<{ breakages: DependentBreakage[] }>({ method: Method.POST, url: `/apps/${appId}/stop${dryRunParam(dryRun, true)}`, readTimeout: 60000 })
if (!dryRun) this.appModel.update({ id: appId, status: AppStatus.STOPPING }, modulateTime(new Date(), 5, 'seconds'))
return res
// disk
getDisks (params: RR.GetDisksReq): Promise<RR.GetDisksRes> {
return this.http.rpcRequest({ method: 'disk.list', params })
}
async restartApp (appId: string): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/restart`, readTimeout: 60000 })
.then(() => ({ } as any))
ejectDisk (params: RR.EjectDisksReq): Promise<RR.EjectDisksRes> {
return this.http.rpcRequest({ method: 'disk.eject', params })
}
async createAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit> {
const data: ReqRes.PostAppBackupCreateReq = {
password: password || undefined,
logicalname,
}
return this.authRequest<ReqRes.PostAppBackupCreateRes>({ method: Method.POST, url: `/apps/${appId}/backup`, data, readTimeout: 60000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.CREATING_BACKUP }))
.then(() => ({ }))
// package
async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise<RR.GetPackagePropertiesRes<any>['data']> {
return this.http.rpcRequest({ method: 'package.properties', params })
.then(parsePropertiesPermissive)
}
async stopAppBackup (appId: string): Promise<Unit> {
return this.authRequest<ReqRes.PostAppBackupStopRes>({ method: Method.POST, url: `/apps/${appId}/backup/stop`, readTimeout: 60000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.STOPPED }))
.then(() => ({ }))
async getPackageLogs (params: RR.GetPackageLogsReq): Promise<RR.GetPackageLogsRes> {
return this.http.rpcRequest( { method: 'package.logs', params })
}
async restoreAppBackup (appId: string, logicalname: string, password?: string): Promise<Unit> {
const data: ReqRes.PostAppBackupRestoreReq = {
password: password || undefined,
logicalname,
}
return this.authRequest<ReqRes.PostAppBackupRestoreRes>({ method: Method.POST, url: `/apps/${appId}/backup/restore`, data, readTimeout: 60000 })
.then(() => this.appModel.update({ id: appId, status: AppStatus.RESTORING_BACKUP }))
.then(() => ({ }))
async installPackageRaw (params: RR.InstallPackageReq): Promise<RR.InstallPackageRes> {
return this.http.rpcRequest({ method: 'package.install', params })
}
async patchAppConfig (app: AppInstalledPreview, config: object, dryRun = false): Promise<{ breakages: DependentBreakage[] }> {
const data: ReqRes.PatchAppConfigReq = {
config,
}
return this.authRequest({ method: Method.PATCH, url: `/apps/${app.id}/config${dryRunParam(dryRun, true)}`, data, readTimeout: 60000 })
async dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise<RR.DryUpdatePackageRes> {
return this.http.rpcRequest({ method: 'package.update.dry', params })
}
async postConfigureDependency (dependencyId: string, dependentId: string, dryRun?: boolean): Promise<{ config: object, breakages: DependentBreakage[] }> {
return this.authRequest({ method: Method.POST, url: `/apps/${dependencyId}/autoconfig/${dependentId}${dryRunParam(dryRun, true)}`, readTimeout: 60000 })
async getPackageConfig (params: RR.GetPackageConfigReq): Promise<RR.GetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.get', params })
}
async patchServerConfig (attr: string, value: any): Promise<Unit> {
const data: ReqRes.PatchServerConfigReq = {
value,
}
return this.authRequest({ method: Method.PATCH, url: `/${attr}`, data, readTimeout: 60000 })
.then(() => this.serverModel.update({ [attr]: value }))
.then(() => ({ }))
async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise<RR.DrySetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set.dry', params })
}
async wipeAppData (app: AppInstalledPreview): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/apps/${app.id}/wipe`, readTimeout: 60000 }).then((res) => {
this.appModel.update({ id: app.id, status: AppStatus.NEEDS_CONFIG })
return res
})
async setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise<RR.SetPackageConfigRes> {
return this.http.rpcRequest({ method: 'package.config.set', params })
}
async toggleAppLAN (appId: string, toggle: 'enable' | 'disable'): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/lan/${toggle}` })
async restorePackageRaw (params: RR.RestorePackageReq): Promise<RR.RestorePackageRes> {
return this.http.rpcRequest({ method: 'package.restore', params })
}
async addSSHKey (sshKey: string): Promise<Unit> {
const data: ReqRes.PostAddSSHKeyReq = {
sshKey,
}
const fingerprint = await this.authRequest<ReqRes.PostAddSSHKeyRes>({ method: Method.POST, url: `/sshKeys`, data })
this.serverModel.update({ ssh: [...this.serverModel.peek().ssh, fingerprint] })
return { }
async executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> {
return this.http.rpcRequest({ method: 'package.action', params })
}
async addWifi (ssid: string, password: string, country: string, connect: boolean): Promise<Unit> {
const data: ReqRes.PostAddWifiReq = {
ssid,
password,
country,
skipConnect: !connect,
}
return this.authRequest({ method: Method.POST, url: `/wifi`, data })
async startPackageRaw (params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
return this.http.rpcRequest({ method: 'package.start', params })
}
async connectWifi (ssid: string): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: encodeURI(`/wifi/${ssid}`) })
async dryStopPackage (params: RR.DryStopPackageReq): Promise<RR.DryStopPackageRes> {
return this.http.rpcRequest({ method: 'package.stop.dry', params })
}
async deleteWifi (ssid: string): Promise<Unit> {
return this.authRequest({ method: Method.DELETE, url: encodeURI(`/wifi/${ssid}`) })
async stopPackageRaw (params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
return this.http.rpcRequest({ method: 'package.stop', params })
}
async deleteSSHKey (fingerprint: SSHFingerprint): Promise<Unit> {
await this.authRequest({ method: Method.DELETE, url: `/sshKeys/${fingerprint.hash}` })
const ssh = this.serverModel.peek().ssh
this.serverModel.update({ ssh: ssh.filter(s => s !== fingerprint) })
return { }
async dryRemovePackage (params: RR.DryRemovePackageReq): Promise<RR.DryRemovePackageRes> {
return this.http.rpcRequest({ method: 'package.remove.dry', params })
}
async restartServer (): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: '/restart', readTimeout: 60000 })
async removePackageRaw (params: RR.RemovePackageReq): Promise<RR.RemovePackageRes> {
return this.http.rpcRequest({ method: 'package.remove', params })
}
async shutdownServer (): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: '/shutdown', readTimeout: 60000 })
async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes> {
return this.http.rpcRequest({ method: 'package.dependency.configure.dry', params })
}
async serviceAction (appId: string, s: ServiceAction): Promise<ReqRes.ServiceActionResponse> {
const data: ReqRes.ServiceActionRequest = {
jsonrpc: '2.0',
id: uuid.v4(),
method: s.id,
}
return this.authRequest({ method: Method.POST, url: `/apps/${appId}/actions`, data, readTimeout: 300000 })
// marketplace
async getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise<RR.GetMarketplaceDataRes> {
return this.http.rpcRequest({ method: 'marketplace.data', params })
}
async refreshLAN (): Promise<Unit> {
return this.authRequest({ method: Method.POST, url: '/network/lan/reset' })
async getEos (params: RR.GetMarketplaceEOSReq): Promise<RR.GetMarketplaceEOSRes> {
return this.http.rpcRequest({ method: 'marketplace.eos', params })
}
async checkV1Status (): Promise<V1Status> {
return this.http.request({ method: Method.GET, url: 'https://registry.start9labs.com/sys/status' })
async getAvailableList (params: RR.GetAvailableListReq): Promise<RR.GetAvailableListRes> {
return this.http.rpcRequest({ method: 'marketplace.available.list', params })
}
private async authRequest<T> (opts: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise<T> {
if (!this.authenticatedRequestsEnabled) throw new Error(`Authenticated requests are not enabled. Do you need to login?`)
opts.withCredentials = true
return this.http.serverRequest<T>(opts, overrides).catch((e: HttpError) => {
console.log(`Got a server error!`, e)
if (isUnauthorized(e)) this.received401()
throw e
})
}
}
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
const dryRunParam = (dryRun: boolean, first: boolean) => {
if (!dryRun) return ''
return first ? `?dryrun` : `&dryrun`
}
function catchHttpStatusError (error: HttpErrorResponse): Observable<true> {
if (error.error instanceof ErrorEvent) {
// A client-side or network error occurred. Handle it accordingly.
return throwError('Not Connected')
} else {
return of(true)
async getAvailableShow (params: RR.GetAvailableShowReq): Promise<RR.GetAvailableShowRes> {
return this.http.rpcRequest({ method: 'marketplace.available', params })
}
}

View File

@@ -1,8 +1,5 @@
# Size Limit [![Cult Of Martians][cult-img]][cult]
<img src="https://ai.github.io/size-limit/logo.svg" align="right"
alt="Size Limit logo by Anton Lovchikov" width="120" height="178">
Size Limit is a performance budget tool for JavaScript. It checks every commit
on CI, calculates the real cost of your JS for end-users and throws an error
if the cost exceeds the limit.
@@ -159,192 +156,6 @@ interactive elements, using React/Vue/Svelte lib or vanilla JS.
</details>
### Big Libraries
JS libraries > 10 KB in size.
This preset includes headless Chrome, and will measure your libs execution
time. You likely dont need this overhead for a small 2 KB lib, but for larger
ones the execution time is a more accurate and understandable metric that
the size in bytes. Library like [React] is a good example for this preset.
<details><summary><b>Show instructions</b></summary>
1. Install preset:
```sh
$ npm install --save-dev size-limit @size-limit/preset-big-lib
```
2. Add the `size-limit` section and the `size` script to your `package.json`:
```diff
+ "size-limit": [
+ {
+ "path": "dist/react.production-*.js"
+ }
+ ],
"scripts": {
"build": "webpack ./scripts/rollup/build.js",
+ "size": "npm run build && size-limit",
"test": "jest && eslint ."
}
```
3. If you use ES modules you can test the size after tree-shaking with `import`
option:
```diff
"size-limit": [
{
"path": "dist/react.production-*.js",
+ "import": "{ createComponent }"
}
],
```
4. Heres how you can get the size for your current project:
```sh
$ npm run size
Package size: 30.08 KB with all dependencies, minified and gzipped
Loading time: 602 ms on slow 3G
Running time: 214 ms on Snapdragon 410
Total time: 815 ms
```
5. Now, lets set the limit. Add 25% to the current total time and use that
as the limit in your `package.json`:
```diff
"size-limit": [
{
+ "limit": "1 s",
"path": "dist/react.production-*.js"
}
],
```
6. Add a `size` script to your test suite:
```diff
"scripts": {
"build": "rollup ./scripts/rollup/build.js",
"size": "npm run build && size-limit",
- "test": "jest && eslint ."
+ "test": "jest && eslint . && npm run size"
}
```
7. If you dont have a continuous integration service running, dont forget
to add one — start with [Travis CI].
8. Add the library size to docs, it will help users to choose your project:
```diff
# Project Name
Short project description
* **Fast.** 10% faster than competitor.
+ * **Small.** 15 KB (minified and gzipped).
+ [Size Limit](https://github.com/ai/size-limit) controls the size.
```
</details>
### Small Libraries
JS libraries < 10 KB in size.
This preset will only measure the size, without the execution time, so its
suitable for small libraries. If your library is larger, you likely want
the Big Libraries preset above. [Nano ID] or [Storeon] are good examples
for this preset.
<details><summary><b>Show instructions</b></summary>
1. First, install `size-limit`:
```sh
$ npm install --save-dev size-limit @size-limit/preset-small-lib
```
2. Add the `size-limit` section and the `size` script to your `package.json`:
```diff
+ "size-limit": [
+ {
+ "path": "index.js"
+ }
+ ],
"scripts": {
+ "size": "size-limit",
"test": "jest && eslint ."
}
```
3. Heres how you can get the size for your current project:
```sh
$ npm run size
Package size: 177 B with all dependencies, minified and gzipped
```
4. If your project size starts to look bloated, run `--why` for analysis:
```sh
$ npm run size -- --why
```
5. Now, lets set the limit. Determine the current size of your library,
add just a little bit (a kilobyte, maybe) and use that as the limit
in your `package.json`:
```diff
"size-limit": [
{
+ "limit": "9 KB",
"path": "index.js"
}
],
```
6. Add the `size` script to your test suite:
```diff
"scripts": {
"size": "size-limit",
- "test": "jest && eslint ."
+ "test": "jest && eslint . && npm run size"
}
```
7. If you dont have a continuous integration service running, dont forget
to add one — start with [Travis CI].
8. Add the library size to docs, it will help users to choose your project:
```diff
# Project Name
Short project description
* **Fast.** 10% faster than competitor.
+ * **Small.** 500 bytes (minified and gzipped). No dependencies.
+ [Size Limit](https://github.com/ai/size-limit) controls the size.
```
</details>
[Travis CI]: https://github.com/dwyl/learn-travis
[Storeon]: https://github.com/ai/storeon/
[Nano ID]: https://github.com/ai/nanoid/
[React]: https://github.com/facebook/react/
## Reports
Size Limit has a [GitHub action] that comments and rejects pull requests based
@@ -371,99 +182,6 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
```
## Config
Size Limits supports three ways to define config.
1. `size-limit` section in `package.json`:
```json
"size-limit": [
{
"path": "index.js",
"import": "{ createStore }",
"limit": "500 ms"
}
]
```
2. or a separate `.size-limit.json` config file:
```js
[
{
"path": "index.js",
"import": "{ createStore }",
"limit": "500 ms"
}
]
```
3. or a more flexible `.size-limit.js` config file:
```js
module.exports = [
{
path: "index.js",
import: "{ createStore }",
limit: "500 ms"
}
]
```
Each section in the config can have these options:
* **path**: relative paths to files. The only mandatory option.
It could be a path `"index.js"`, a [pattern] `"dist/app-*.js"`
or an array `["index.js", "dist/app-*.js", "!dist/app-exclude.js"]`.
* **import**: partial import to test tree-shaking. It could be `"{ lib }"`
to test `import { lib } from 'lib'` or `{ "a.js": "{ a }", "b.js": "{ b }" }`
to test multiple files.
* **limit**: size or time limit for files from the `path` option. It should be
a string with a number and unit, separated by a space.
Format: `100 B`, `10 KB`, `500 ms`, `1 s`.
* **name**: the name of the current section. It will only be useful
if you have multiple sections.
* **entry**: when using a custom webpack config, a webpack entry could be given.
It could be a string or an array of strings.
By default, the total size of all entry points will be checked.
* **webpack**: with `false` it will disable webpack.
* **running**: with `false` it will disable calculating running time.
* **gzip**: with `false` it will disable gzip compression.
* **brotli**: with `true` it will use brotli compression and disable gzip compression.
* **config**: a path to a custom webpack config.
* **ignore**: an array of files and dependencies to exclude from
the project size calculation.
If you use Size Limit to track the size of CSS files, make sure to set
`webpack: false`. Otherwise, you will get wrong numbers, because webpack
inserts `style-loader` runtime (≈2 KB) into the bundle.
[pattern]: https://github.com/sindresorhus/globby#globbing-patterns
## Plugins and Presets
Plugins:
* `@size-limit/file` checks the size of files with Gzip, Brotli
or without compression.
* `@size-limit/webpack` adds your library to empty webpack project
and prepares bundle file for `file` plugin.
* `@size-limit/time` uses headless Chrome to track time to execute JS.
* `@size-limit/dual-publish` compiles files to ES modules with [`dual-publish`]
to check size after tree-shaking.
Plugin presets:
* `@size-limit/preset-app` contains `file` and `time` plugins.
* `@size-limit/preset-big-lib` contains `webpack`, `file`, and `time` plugins.
* `@size-limit/preset-small-lib` contains `webpack` and `file` plugins.
[`dual-publish`]: https://github.com/ai/dual-publish
## JS API
```js

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject, Subscription } from 'rxjs'
import { BehaviorSubject, Observable } from 'rxjs'
import { distinctUntilChanged } from 'rxjs/operators'
import { ApiService } from './api/api.service'
import { chill } from '../util/misc.util'
import { isUnauthorized } from '../util/web.util'
import { Storage } from '@ionic/storage'
import { StorageKeys } from '../models/storage-keys'
@@ -16,47 +14,41 @@ export enum AuthState {
providedIn: 'root',
})
export class AuthService {
private readonly $authState$: BehaviorSubject<AuthState> = new BehaviorSubject(AuthState.INITIALIZING)
private readonly authState$: BehaviorSubject<AuthState> = new BehaviorSubject(AuthState.INITIALIZING)
constructor (
private readonly api: ApiService,
private readonly storage: Storage,
) { }
peek (): AuthState { return this.$authState$.getValue() }
listen (callback: Partial<{ [k in AuthState]: () => any }>): Subscription {
return this.$authState$.pipe(distinctUntilChanged()).subscribe(s => {
return (callback[s] || chill)()
})
) {
this.storage.create()
}
async login (password: string) {
try {
await this.api.postLogin(password)
await this.storage.set(StorageKeys.LOGGED_IN_KEY, true)
this.$authState$.next(AuthState.VERIFIED)
} catch (e) {
if (isUnauthorized(e)) {
this.$authState$.next(AuthState.UNVERIFIED)
throw { name: 'invalid', message: 'invalid credentials' }
}
console.error(`Failed login attempt`, e)
throw e
}
}
async restoreCache (): Promise<AuthState> {
async init (): Promise<AuthState> {
const loggedIn = await this.storage.get(StorageKeys.LOGGED_IN_KEY)
if (loggedIn) {
this.$authState$.next(AuthState.VERIFIED)
this.authState$.next(AuthState.VERIFIED)
return AuthState.VERIFIED
} else {
this.$authState$.next(AuthState.UNVERIFIED)
this.authState$.next(AuthState.UNVERIFIED)
return AuthState.UNVERIFIED
}
}
async setAuthStateUnverified (): Promise<void> {
this.$authState$.next(AuthState.UNVERIFIED)
watch$ (): Observable<AuthState> {
return this.authState$.pipe(distinctUntilChanged())
}
async submitPin (pin: string): Promise<void> {
await this.api.submitPin({ pin })
}
async submitPassword (password: string): Promise<void> {
await this.api.submitPassword({ password })
await this.storage.set(StorageKeys.LOGGED_IN_KEY, true)
this.authState$.next(AuthState.VERIFIED)
}
async setUnverified (): Promise<void> {
this.authState$.next(AuthState.UNVERIFIED)
}
}

View File

@@ -1,12 +1,30 @@
import { Injectable } from '@angular/core'
import { AppStatus } from '../models/app-model'
import { ApiAppInstalledPreview } from './api/api-types'
import { InstalledPackageDataEntry, InterfaceDef, Manifest, PackageDataEntry, PackageMainStatus, PackageState } from '../models/patch-db/data-model'
const { useMocks, mockOver, skipStartupAlerts } = require('../../../use-mocks.json') as UseMocks
const { patchDb, maskAs, api, skipStartupAlerts } = require('../../../ui-config.json') as UiConfig
type UseMocks = {
useMocks: boolean
mockOver: 'tor' | 'lan'
type UiConfig = {
patchDb: {
// If this is false (the default), poll will be used if in consulate only. If true it will be on regardless of env. This is useful in concert with api mocks.
usePollOverride: boolean
poll: {
cooldown: number /* in ms */
}
websocket: {
type: 'ws'
url: string
version: number
}
// Wait this long (ms) before asking BE for a dump when out of order messages are received
timeoutForMissingRevision: number
}
api: {
mocks: boolean
url: string
version: string
root: string
}
maskAs: 'tor' | 'lan' | 'none'
skipStartupAlerts: boolean
}
@Injectable({
@@ -16,33 +34,74 @@ export class ConfigService {
origin = removePort(removeProtocol(window.origin))
version = require('../../../package.json').version
api = {
useMocks,
url: '/api',
version: '/v0',
root: '', // empty will default to same origin
}
patchDb = patchDb
api = api
skipStartupAlerts = skipStartupAlerts
isConsulate = window['platform'] === 'ios'
isTor () : boolean {
return (this.api.useMocks && mockOver === 'tor') || this.origin.endsWith('.onion')
return (maskAs === 'tor') || this.origin.endsWith('.onion')
}
hasUI (app: ApiAppInstalledPreview): boolean {
return app.lanUi || app.torUi
isLan () : boolean {
return (maskAs === 'lan') || this.origin.endsWith('.local')
}
isLaunchable (app: ApiAppInstalledPreview): boolean {
return !this.isConsulate &&
app.status === AppStatus.RUNNING &&
(
(app.torAddress && app.torUi && this.isTor()) ||
(app.lanAddress && app.lanUi && !this.isTor())
)
isLaunchable (pkg: PackageDataEntry): boolean {
if (this.isConsulate || pkg.state !== PackageState.Installed) {
return false
}
const installed = pkg.installed
return installed.status.main.status === PackageMainStatus.Running &&
(
(hasTorUi(installed.manifest.interfaces) && this.isTor()) ||
(hasLanUi(installed.manifest.interfaces) && !this.isTor())
)
}
launchableURL (pkg: InstalledPackageDataEntry): string {
return this.isTor() ? `http://${torUiAddress(pkg)}` : `https://${lanUiAddress(pkg)}`
}
}
export function hasTorUi (interfaces: { [id: string]: InterfaceDef }): boolean {
return !!Object.values(interfaces).find(i => i.ui && i['tor-config'])
}
export function hasLanUi (interfaces: { [id: string]: InterfaceDef }): boolean {
return !!Object.values(interfaces).find(i => i.ui && i['lan-config'])
}
export function torUiAddress (pkg: InstalledPackageDataEntry): string {
const interfaces = pkg.manifest.interfaces
const id = Object.keys(interfaces).find(key => {
const val = interfaces[key]
return val.ui && val['tor-config']
})
return pkg['interface-info'].addresses[id]['tor-address']
}
export function lanUiAddress (pkg: InstalledPackageDataEntry): string {
const interfaces = pkg.manifest.interfaces
const id = Object.keys(interfaces).find(key => {
const val = interfaces[key]
return val.ui && val['lan-config']
})
return pkg['interface-info'].addresses[id]['lan-address']
}
export function hasUi (interfaces: { [id: string]: InterfaceDef }): boolean {
return hasTorUi(interfaces) || hasLanUi(interfaces)
}
export function getManifest (pkg: PackageDataEntry): Manifest {
if (pkg.state === PackageState.Installed) {
return pkg.installed.manifest
}
return pkg['temp-manifest']
}
function removeProtocol (str: string): string {

View File

@@ -0,0 +1,105 @@
import { Injectable } from '@angular/core'
import { fromEvent, Observable, Subject, Subscription, timer } from 'rxjs'
import { debounceTime, delay, retryWhen, startWith, switchMap, tap } from 'rxjs/operators'
import { ApiService } from './api/api.service'
@Injectable({
providedIn: 'root',
})
export class ConnectionService {
private offlineSubscription: Subscription
private onlineSubscription: Subscription
private httpSubscription: Subscription
private readonly currentState: ConnectionState = {
network: true,
internet: true,
}
private readonly stateChangeEventEmitter = new Subject<ConnectionState>()
constructor (
private readonly apiService: ApiService,
) {
this.checkNetworkState()
this.checkInternetState()
}
ngOnDestroy (): void {
try {
this.offlineSubscription.unsubscribe()
this.onlineSubscription.unsubscribe()
this.httpSubscription.unsubscribe()
} catch (e) {
console.error(e.message)
}
}
/**
* Monitor Network & Internet connection status by subscribing to this observer.
*/
monitor$ (): Observable<ConnectionState> {
return this.stateChangeEventEmitter.pipe(
debounceTime(300),
startWith(this.currentState),
)
}
private checkNetworkState (): void {
this.onlineSubscription = fromEvent(window, 'online').subscribe(() => {
this.currentState.network = true
this.checkInternetState()
this.emitEvent()
})
this.offlineSubscription = fromEvent(window, 'offline').subscribe(() => {
this.currentState.network = true
this.checkInternetState()
this.emitEvent()
})
}
private checkInternetState (): void {
if (this.httpSubscription) {
this.httpSubscription.unsubscribe()
}
// ping server every 10 seconds
this.httpSubscription = timer(0, 10000)
.pipe(
switchMap(() => this.apiService.ping()),
retryWhen(errors =>
errors.pipe(
tap(val => {
console.error('Ping error: ', val)
this.currentState.internet = true
this.emitEvent()
}),
// restart after 2 seconds
delay(2000),
),
),
)
.subscribe(() => {
this.currentState.internet = true
this.emitEvent()
})
}
private emitEvent (): void {
this.stateChangeEventEmitter.next(this.currentState)
}
}
/**
* Instance of this interface is used to report current connection status.
*/
export interface ConnectionState {
/**
* "True" if browser has network connection. Determined by Window objects "online" / "offline" events.
*/
network: boolean
/**
* "True" if browser has Internet access. Determined by heartbeat system which periodically makes request to heartbeat Url.
*/
internet: boolean
}

View File

@@ -1,32 +1,49 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
import { Observable, from, interval, race } from 'rxjs'
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
import { Observable, from, interval, race, Subject } from 'rxjs'
import { map, take } from 'rxjs/operators'
import { ConfigService } from './config.service'
import { Revision } from 'patch-db-client'
@Injectable({
providedIn: 'root',
})
export class HttpService {
private unauthorizedApiResponse$ = new Subject()
authReqEnabled: boolean = false
rootUrl: string
constructor (
private readonly http: HttpClient,
private readonly config: ConfigService,
) { }
get raw () : HttpClient {
return this.http
) {
const { url, version } = this.config.api
this.rootUrl = `${url}/${version}`
}
async serverRequest<T> (options: HttpOptions, overrides: Partial<{ version: string }> = { }): Promise<T> {
options.url = leadingSlash(`${this.config.api.url}${exists(overrides.version) ? overrides.version : this.config.api.version}${options.url}`)
if ( this.config.api.root && this.config.api.root !== '' ) {
options.url = `${this.config.api.root}${options.url}`
watch401$ (): Observable<{ }> {
return this.unauthorizedApiResponse$.asObservable()
}
async rpcRequest<T> (rpcOpts: RPCOptions): Promise<T> {
rpcOpts.params = rpcOpts.params || { }
const httpOpts = {
method: Method.POST,
data: rpcOpts,
url: '',
}
return this.request<T>(options)
const res = await this.httpRequest<RPCResponse<T>>(httpOpts)
if (isRpcError(res)) throw new RpcError(res.error)
if (isRpcSuccess(res)) return res.result
}
async request<T> (httpOpts: HttpOptions): Promise<T> {
const { url, body, timeout, ...rest} = translateOptions(httpOpts)
async httpRequest<T> (httpOpts: HttpOptions): Promise<T> {
let { url, body, timeout, ...rest} = translateOptions(httpOpts)
url = this.rootUrl + url
let req: Observable<{ body: T }>
switch (httpOpts.method){
case Method.GET: req = this.http.get(url, rest) as any; break
@@ -37,25 +54,42 @@ export class HttpService {
}
return (timeout ? withTimeout(req, timeout) : req)
.toPromise()
.then(res => res.body)
.catch(e => { console.error(e); throw humanReadableErrorMessage(e)})
.toPromise()
.then(res => res.body)
.catch(e => { throw new HttpError(e) })
}
}
function humanReadableErrorMessage (e: any): Error {
// server up, custom backend error
if (e.error && e.error.message) return { ...e, message: e.error.message }
if (e.message) return { ...e, message: e.message }
if (e.status && e.statusText) return { ...e, message: `${e.status} ${e.statusText}` }
return { ...e, message: `Unidentifiable HTTP exception` }
function RpcError (e: RPCError['error']): void {
const { code, message } = e
this.status = code
this.message = message
if (typeof e.data === 'string') {
throw new Error(`unexpected response for RPC Error data: ${e.data}`)
}
const data = e.data || { message: 'unknown RPC error', revision: null }
this.data = { ...data, code }
}
function leadingSlash (url: string): string {
let toReturn = url
toReturn = toReturn.startsWith('/') ? toReturn : '/' + toReturn
toReturn = !toReturn.endsWith('/') ? toReturn : toReturn.slice(0, -1)
return toReturn
function HttpError (e: HttpErrorResponse): void {
const { status, statusText, error } = e
this.status = status
this.message = statusText
this.data = error || { } // error = { code: string, message: string }
}
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 {
status: number
message: string
data: { code: string, message: string, revision: Revision | null }
}
export enum Method {
@@ -66,27 +100,64 @@ export enum Method {
DELETE = 'DELETE',
}
export interface RPCOptions {
method: string
// @TODO what are valid params? object, bool?
params?: {
[param: string]: string | number | boolean | object | string[] | 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?: {
message: string
revision: Revision | null
} | string
}
}
export type RPCResponse<T> = RPCSuccess<T> | RPCError
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
export interface HttpOptions {
withCredentials?: boolean
url: string
method: Method
params?: {
[param: string]: string | string[];
[param: string]: string | string[]
}
data?: any
headers?: {
[key: string]: string;
}
url: string
readTimeout?: number
}
export interface HttpJsonOptions {
headers?: HttpHeaders | {
[header: string]: string | string[];
[header: string]: string | string[]
}
observe: 'events'
params?: HttpParams | {
[param: string]: string | string[];
[param: string]: string | string[]
}
reportProgress?: boolean
responseType?: 'json'
@@ -116,7 +187,3 @@ function withTimeout<U> (req: Observable<U>, timeout: number): Observable<U> {
interval(timeout).pipe(take(1), map(() => { throw new Error('timeout') })),
)
}
function exists (str?: string): boolean {
return !!str || str === ''
}

View File

@@ -1,69 +0,0 @@
import { Injectable } from '@angular/core'
import { NavController } from '@ionic/angular'
import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs'
import { catchError, concatMap, distinctUntilChanged, map, take, tap } from 'rxjs/operators'
import { ServerModel, ServerStatus } from '../models/server-model'
import { ApiService } from './api/api.service'
import { Emver } from './emver.service'
// call checkForUpdates in marketplace pages, can subscribe globally however
type UpdateAvailable = { versionLatest: string, releaseNotes: string}
@Injectable({ providedIn: 'root' })
export class OsUpdateService {
// holds version latest if update available, undefined if not.
private readonly $updateAvailable$ = new BehaviorSubject<UpdateAvailable>(undefined)
watchForUpdateAvailable$ (): Observable<undefined | UpdateAvailable> {
return this.$updateAvailable$.asObservable().pipe(distinctUntilChanged())
}
constructor (
private readonly emver: Emver,
private readonly serverModel: ServerModel,
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
) { }
// emits the latest version or re-checks to see if there's a new latest version
checkWhenNotAvailable$ (): Observable<undefined | UpdateAvailable> {
return this.$updateAvailable$.pipe(
take(1),
concatMap(vl => vl ? of(vl) : this.checkForUpdates$()),
)
}
// can sub to this imperatively and take the return value as gospel, or watch the $updateAvailable$ subject for the same info.
checkForUpdates$ (): Observable<undefined | UpdateAvailable> {
return forkJoin([
this.serverModel.watch().versionInstalled.pipe(take(1)),
this.apiService.getVersionLatest(),
]).pipe(
map(([vi, vl]) => this.updateIsAvailable(vi, vl) ? vl : undefined),
catchError(e => {
console.error(`OsUpdateService Error: ${e}`)
return of(undefined)
}),
// cache the result for components to learn update available without having to have called this method
tap(this.$updateAvailable$),
)
}
updateIsAvailable (vi: string, vl: UpdateAvailable): boolean {
if (!vi || !vl) return false
if (this.emver.compare(vi, vl.versionLatest) === -1) {
this.$updateAvailable$.next(vl)
return true
} else {
this.$updateAvailable$.next(undefined)
return false
}
}
async updateEmbassyOS (versionLatest: string): Promise<void> {
await this.apiService.updateAgent(versionLatest)
this.serverModel.update({ status: ServerStatus.UPDATING })
this.$updateAvailable$.next(undefined)
await this.navCtrl.navigateRoot('/embassy')
}
}

View File

@@ -0,0 +1,58 @@
import { PackageDataEntry, PackageMainStatus, PackageState, Status } from '../models/patch-db/data-model'
import { ConnectionState } from './connection.service'
export function renderPkgStatus (pkg: PackageDataEntry, connection: ConnectionState): PkgStatusRendering {
if (!connection.network || !connection.internet) {
return { display: 'Connecting', color: 'medium', showDots: true, feStatus: FEStatus.Connecting }
}
switch (pkg.state) {
case PackageState.Installing: return { display: 'Installing', color: 'primary', showDots: true, feStatus: FEStatus.Installing }
case PackageState.Updating: return { display: 'Updating', color: 'primary', showDots: true, feStatus: FEStatus.Updating }
case PackageState.Removing: return { display: 'Removing', color: 'warning', showDots: true, feStatus: FEStatus.Removing }
case PackageState.Installed: return handleInstalledState(pkg.installed.status)
}
}
function handleInstalledState (status: Status): PkgStatusRendering {
if (!status.configured) {
return { display: 'Needs Config', color: 'warning', showDots: false, feStatus: FEStatus.NeedsConfig }
}
if (Object.values(status['dependency-errors']).length) {
return { display: 'Dependency Issue', color: 'warning', showDots: false, feStatus: FEStatus.DependencyIssue }
}
switch (status.main.status) {
case PackageMainStatus.Running: return { display: 'Running', color: 'success', showDots: false, feStatus: FEStatus.Running }
case PackageMainStatus.Stopping: return { display: 'Stopping', color: 'dark', showDots: true, feStatus: FEStatus.Stopping }
case PackageMainStatus.Stopped: return { display: 'Stopped', color: 'medium', showDots: false, feStatus: FEStatus.Stopped }
case PackageMainStatus.BackingUp: return { display: 'Backing Up', color: 'warning', showDots: true, feStatus: FEStatus.BackingUp }
case PackageMainStatus.Restoring: return { display: 'Restoring', color: 'primary', showDots: true, feStatus: FEStatus.Restoring }
}
}
export interface PkgStatusRendering {
feStatus: FEStatus
display: string
color: string
showDots: boolean
}
// aggregate of all pkg statuses, except for Installed, which implies a "main" or "FE" status
export enum FEStatus {
// pkg
Installing = 'installing',
Updating = 'updating',
Removing = 'removing',
// main
Running = 'running',
Stopping = 'stopping',
Stopped = 'stopped',
BackingUp = 'backing-up',
Restoring = 'restoring',
// FE
Connecting = 'connecting',
DependencyIssue = 'dependency-issue',
NeedsConfig = 'needs-config',
}

View File

@@ -1,23 +0,0 @@
import { Router } from '@angular/router'
import { Injectable } from '@angular/core'
import { NavController } from '@ionic/angular'
@Injectable({
providedIn: 'root',
})
export class PwaBackService {
constructor (
private readonly router: Router,
private readonly nav: NavController,
) { }
// this will strip an entry from the path on navigation
back () {
return this.nav.back()
// this.router.navigate()
// const path = this.router.url.split('/').filter(a => a !== '')
// path.pop()
// this.router.navigate(['/', ...path], { replaceUrl: false })
}
}

View File

@@ -2,124 +2,108 @@ import { Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { AppConfigValuePage } from '../modals/app-config-value/app-config-value.page'
import { ApiService } from './api/api.service'
import { PropertySubject } from '../util/property-subject.util'
import { S9Server, ServerModel } from '../models/server-model'
import { ValueSpec } from '../app-config/config-types'
import { ConfigSpec } from '../pkg-config/config-types'
import { ConfigCursor } from '../pkg-config/config-cursor'
import { SSHService } from '../pages/server-routes/developer-routes/dev-ssh-keys/ssh.service'
@Injectable({
providedIn: 'root',
})
export class ServerConfigService {
server: PropertySubject<S9Server>
constructor (
private readonly modalCtrl: ModalController,
private readonly apiService: ApiService,
private readonly serverModel: ServerModel,
) {
this.server = this.serverModel.watch()
}
private readonly sshService: SSHService,
) { }
async presentModalValueEdit (key: string, current?: string) {
const cursor = new ConfigCursor(serverConfig, { [key]: current }).seekNext(key)
async presentModalValueEdit (key: string, add = false) {
const modal = await this.modalCtrl.create({
backdropDismiss: false,
component: AppConfigValuePage,
presentingElement: await this.modalCtrl.getTop(),
componentProps: {
...this.getConfigSpec(key),
value: add ? '' : this.server[key].getValue(),
cursor,
saveFn: this.saveFns[key],
},
})
await modal.present()
}
private getConfigSpec (key: string): SpecAndSaveFn {
const configSpec: { [key: string]: SpecAndSaveFn } = {
name: {
spec: {
type: 'string',
name: 'Device Name',
description: 'A unique label for this device.',
nullable: false,
// @TODO determine regex
// pattern: '',
patternDescription: 'Must be less than 40 characters',
masked: false,
copyable: true,
},
saveFn: (val: string) => {
return this.apiService.patchServerConfig('name', val).then(() => this.serverModel.update({ name: val }))
},
},
autoCheckUpdates: {
spec: {
type: 'boolean',
name: 'Auto Check for Updates',
description: 'On launch, EmabssyOS will automatically check for updates of itself and your installed services. Updating still requires user approval and action. No updates will ever be performed automatically.',
default: true,
},
saveFn: (val: boolean) => {
return this.apiService.patchServerConfig('autoCheckUpdates', val).then(() => this.serverModel.update({ autoCheckUpdates: val }))
},
},
// password: {
// spec: {
// type: 'string',
// name: 'Change Password',
// description: 'The master password for your Embassy. Must contain at least 128 bits of entropy.',
// nullable: false,
// // @TODO figure out how to confirm min entropy
// // pattern: '^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[*.!@#$%^&*\]).{12,32}$',
// patternDescription: 'Password too simple. Password must contain at least 128 bits of entroy.',
// changeWarning: 'Changing your password will have no affect on old backups. In order to restore old backups, you must provide the password that was used to create them.',
// masked: true,
// copyable: true,
// },
// saveFn: (val: string) => {
// return this.apiService.patchServerConfig('password', val)
// },
// },
// alternativeRegistryUrl: {
// spec: {
// type: 'string',
// name: 'Marketplace URL',
// description: 'Used for connecting to an alternative service marketplace.',
// nullable: true,
// // @TODO regex for URL
// // pattern: '',
// patternDescription: 'Must be a valid URL',
// changeWarning: 'Downloading services from an alternative marketplace could result in malicious or harmful code being installed on your device.',
// masked: false,
// copyable: true,
// },
// saveFn: (val: string) => {
// return this.apiService.patchServerConfig('alternativeRegistryUrl', val).then(() => this.serverModel.update({ alternativeRegistryUrl: val }))
// },
// },
ssh: {
spec: {
type: 'string',
name: 'SSH Key',
description: 'Add SSH keys to your Embassy to gain root access from the command line.',
nullable: false,
// @TODO regex for SSH Key
// pattern: '',
patternDescription: 'Must be a valid SSH key',
masked: true,
copyable: true,
},
saveFn: (val: string) => {
return this.apiService.addSSHKey(val)
},
},
}
return configSpec[key]
saveFns: { [key: string]: (val: any) => Promise<any> } = {
name: async (value: string) => {
return this.apiService.setDbValue({ pointer: 'ui/name', value })
},
autoCheckUpdates: async (value: boolean) => {
return this.apiService.setDbValue({ pointer: 'ui/auto-check-updates', value })
},
ssh: async (pubkey: string) => {
return this.sshService.add(pubkey)
},
registry: async (url: string) => {
return this.apiService.setRegistry({ url })
},
// password: async (password: string) => {
// return this.apiService.updatePassword({ password })
// },
}
}
interface SpecAndSaveFn {
spec: ValueSpec
saveFn: (val: any) => Promise<any>
const serverConfig: ConfigSpec = {
name: {
type: 'string',
name: 'Device Name',
description: 'A unique label for this device.',
nullable: false,
// @TODO determine regex
// pattern: '',
patternDescription: 'Must be less than 40 characters',
masked: false,
copyable: false,
},
autoCheckUpdates: {
type: 'boolean',
name: 'Auto Check for Updates',
description: 'On launch, EmabssyOS will automatically check for updates of itself and your installed services. Updating still requires user approval and action. No updates will ever be performed automatically.',
default: true,
},
ssh: {
type: 'string',
name: 'SSH Key',
description: 'Add SSH keys to your Embassy to gain root access from the command line.',
nullable: false,
// @TODO regex for SSH Key
// pattern: '',
patternDescription: 'Must be a valid SSH key',
masked: false,
copyable: false,
},
registry: {
type: 'string',
name: 'Marketplace URL',
description: 'The URL of the service marketplace. By default, your Embassy connects to the official Start9 Embassy Marketplace.',
nullable: true,
// @TODO regex for URL
// pattern: '',
patternDescription: 'Must be a valid URL',
changeWarning: 'Downloading services from an alternative marketplace can result in malicious or harmful code being installed on your device.',
default: 'https://registry.start9.com',
masked: false,
copyable: false,
},
// password: {
// type: 'string',
// name: 'Change Password',
// description: 'The master password for your Embassy. Must contain at least 128 bits of entropy.',
// nullable: false,
// // @TODO figure out how to confirm min entropy
// // pattern: '^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[*.!@#$%^&*\]).{12,32}$',
// patternDescription: 'Password too simple. Password must contain at least 128 bits of entroy.',
// changeWarning: 'Changing your password will have no affect on old backups. In order to restore old backups, you must provide the password that was used to create them.',
// masked: true,
// copyable: true,
// },
}

View File

@@ -5,6 +5,5 @@ import { Injectable } from '@angular/core'
providedIn: 'root',
})
export class SplitPaneTracker {
$menuFixedOpenOnLeft$: BehaviorSubject<boolean> = new BehaviorSubject(false)
constructor () { }
menuFixedOpenOnLeft$: BehaviorSubject<boolean> = new BehaviorSubject(false)
}

View File

@@ -3,15 +3,14 @@ import { AlertController, IonicSafeString, ModalController, NavController } from
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 { S9Server } from '../models/server-model'
import { displayEmver } from '../pipes/emver.pipe'
import { V1Status } from './api/api-types'
import { ApiService, ReqRes } from './api/api.service'
import { ApiService } from './api/api.service'
import { RR } from './api/api-types'
import { ConfigService } from './config.service'
import { Emver } from './emver.service'
import { OsUpdateService } from './os-update.service'
@Injectable({ providedIn: 'root' })
@Injectable({providedIn: 'root' })
export class StartupAlertsNotifier {
constructor (
private readonly alertCtrl: AlertController,
@@ -37,13 +36,6 @@ export class StartupAlertsNotifier {
display: vl => this.displayOsUpdateCheck(vl),
hasRun: this.config.skipStartupAlerts,
}
const v1StatusUpdate: Check<V1Status> = {
name: 'v1Status',
shouldRun: s => this.shouldRunOsUpdateCheck(s),
check: () => this.v1StatusCheck(),
display: s => this.displayV1Check(s),
hasRun: this.config.skipStartupAlerts,
}
const apps: Check<boolean> = {
name: 'apps',
shouldRun: s => this.shouldRunAppsCheck(s),

View File

@@ -1,56 +0,0 @@
import { Injectable } from '@angular/core'
import { ToastController, NavController } from '@ionic/angular'
import { ServerModel, S9Server } from '../models/server-model'
@Injectable({
providedIn: 'root',
})
export class SyncNotifier {
displayedWelcomeMessage = false
checkedForUpdates = false
constructor (
private readonly toastCtrl: ToastController,
private readonly navCtrl: NavController,
private readonly serverModel: ServerModel,
) { }
async handleSpecial (server: Readonly<S9Server>): Promise<void> {
this.handleNotifications(server)
}
private async handleNotifications (server: Readonly<S9Server>) {
const count = server.notifications.length
if (!count) { return }
let updates = { } as Partial<S9Server>
updates.badge = server.badge + count
updates.notifications = []
const toast = await this.toastCtrl.create({
header: 'Embassy',
message: `${count} new notification${count === 1 ? '' : 's'}`,
position: 'bottom',
duration: 4000,
cssClass: 'notification-toast',
buttons: [
{
side: 'start',
icon: 'close',
handler: () => {
return true
},
},
{
side: 'end',
text: 'View',
handler: () => {
this.navCtrl.navigateForward(['/notifications'])
},
},
],
})
await toast.present()
this.serverModel.update(updates)
}
}

View File

@@ -1,74 +0,0 @@
import { Injectable } from '@angular/core'
import { ServerModel } from '../models/server-model'
import { ApiService } from './api/api.service'
import { tryAll, pauseFor } from '../util/misc.util'
import { AppModel } from '../models/app-model'
import { SyncNotifier } from './sync.notifier'
import { BehaviorSubject, Observable, of, from, Subject, EMPTY } from 'rxjs'
import { switchMap, concatMap, catchError, delay, tap } from 'rxjs/operators'
import { StartupAlertsNotifier } from './startup-alerts.notifier'
@Injectable({
providedIn: 'root',
})
export class SyncDaemon {
private readonly syncInterval = 5000
private readonly $sync$ = new BehaviorSubject(false)
constructor (
private readonly apiService: ApiService,
private readonly serverModel: ServerModel,
private readonly appModel: AppModel,
private readonly syncNotifier: SyncNotifier,
private readonly startupAlertsNotifier: StartupAlertsNotifier,
) {
this.$sync$.pipe(
switchMap(go => go
? this.sync().pipe(delay(this.syncInterval), tap(() => this.$sync$.next(true)))
: EMPTY,
),
).subscribe()
}
start () { this.$sync$.next(true) }
stop () { this.$sync$.next(false) }
sync (): Observable<void> {
return from(this.getServerAndApps()).pipe(
concatMap(() => this.syncNotifier.handleSpecial(this.serverModel.peek())),
concatMap(() => this.startupAlertsNotifier.runChecks(this.serverModel.peek())),
catchError(e => of(console.error(`Exception in sync service`, e))),
)
}
private async getServerAndApps (): Promise<void> {
const now = new Date()
const [serverRes, appsRes] = await tryAll([
this.apiService.getServer(),
pauseFor(250).then(() => this.apiService.getInstalledApps()),
])
switch (serverRes.result) {
case 'resolve': {
this.serverModel.update(serverRes.value, now)
break
}
case 'reject': {
console.error(`get server request rejected with`, serverRes.value)
this.serverModel.markUnreachable()
break
}
}
switch (appsRes.result) {
case 'resolve': {
this.appModel.syncCache(appsRes.value, now)
break
}
case 'reject': {
console.error(`get apps request rejected with`, appsRes.value)
this.appModel.markAppsUnreachable()
break
}
}
}
}

View File

@@ -4,7 +4,7 @@ import { ModalController } from '@ionic/angular'
import { ModalOptions } from '@ionic/core'
import { APP_CONFIG_COMPONENT_MAPPING } from '../modals/app-config-injectable/modal-injectable-token'
import { AppConfigComponentMapping } from '../modals/app-config-injectable/modal-injectable-type'
import { ValueSpec } from '../app-config/config-types'
import { ValueSpec } from '../pkg-config/config-types'
@Injectable({
providedIn: 'root',
@@ -43,7 +43,6 @@ export class TrackingModalController {
)
}
dismiss (val?: any): Promise<boolean> {
return this.modalCtrl.dismiss(val)
}