Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Matt Hill
2024-08-08 10:52:49 -06:00
765 changed files with 43858 additions and 19423 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,34 @@
import { Dump, Revision } from 'patch-db-client'
import { MarketplacePkg, StoreInfo } from '@start9labs/marketplace'
import {
DataModel,
DomainInfo,
NetworkStrategy,
} from 'src/app/services/patch-db/data-model'
import {
StartOSDiskInfo,
FetchLogsReq,
FetchLogsRes,
FollowLogsRes,
FollowLogsReq,
} from '@start9labs/shared'
import { CT, T } from '@start9labs/start-sdk'
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
import { config } from '@start9labs/start-sdk'
import { Dump } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo } from '@start9labs/shared'
import { CT, T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
export module RR {
// websocket
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
// state
export type EchoReq = { message: string } // server.echo
export type EchoRes = string
export type ServerState = 'initializing' | 'error' | 'running'
// DB
export type GetRevisionsRes = Revision[] | Dump<DataModel>
export type GetDumpRes = Dump<DataModel>
export type SubscribePatchReq = {}
export type SubscribePatchRes = {
dump: Dump<DataModel>
guid: string
}
export type SetDBValueReq<T> = { pointer: string; value: T } // db.put.ui
export type SetDBValueRes = null
@@ -30,6 +38,7 @@ export module RR {
export type LoginReq = {
password: string
metadata: SessionMetadata
ephemeral?: boolean
} // auth.login - unauthed
export type loginRes = null
@@ -42,10 +51,22 @@ export module RR {
} // auth.reset-password
export type ResetPasswordRes = null
// server
// diagnostic
export type EchoReq = { message: string; timeout?: number } // server.echo
export type EchoRes = string
export type DiagnosticErrorRes = {
code: number
message: string
data: { details: string }
}
// init
export type InitGetProgressRes = {
progress: T.FullProgress
guid: string
}
// server
export type GetSystemTimeReq = {} // server.time
export type GetSystemTimeRes = {
@@ -56,8 +77,16 @@ export module RR {
export type GetServerLogsReq = FetchLogsReq // server.logs & server.kernel-logs & server.tor-logs
export type GetServerLogsRes = FetchLogsRes
export type FollowServerLogsReq = FollowLogsReq & { limit?: number } // server.logs.follow & server.kernel-logs.follow & server.tor-logs.follow
export type FollowServerLogsRes = FollowLogsRes
// @param limit: BE default is 50
// @param boot: number is offset (0: current, -1 prev, +1 first), string is a specific boot id, and null is all
export type FollowServerLogsReq = {
limit?: number
boot?: number | string | null
} // server.logs.follow & server.kernel-logs.follow
export type FollowServerLogsRes = {
startCursor: string
guid: string
}
export type GetServerMetricsReq = {} // server.metrics
export type GetServerMetricsRes = {
@@ -65,7 +94,7 @@ export module RR {
metrics: Metrics
}
export type UpdateServerReq = { marketplaceUrl: string } // server.update
export type UpdateServerReq = { registry: string } // server.update
export type UpdateServerRes = 'updating' | 'no-updates'
export type SetServerClearnetAddressReq = { domainInfo: DomainInfo | null } // server.set-clearnet
@@ -77,8 +106,8 @@ export module RR {
export type ShutdownServerReq = {} // server.shutdown
export type ShutdownServerRes = null
export type SystemRebuildReq = {} // server.rebuild
export type SystemRebuildRes = null
export type DiskRepairReq = {} // server.disk.repair
export type DiskRepairRes = null
export type ResetTorReq = {
wipeState: boolean
@@ -310,19 +339,18 @@ export module RR {
export type FollowPackageLogsReq = FollowServerLogsReq & { id: string } // package.logs.follow
export type FollowPackageLogsRes = FollowServerLogsRes
export type InstallPackageReq = {
id: string
versionSpec?: string
versionPriority?: 'min' | 'max'
marketplaceUrl: string
} // package.install
export type GetPackageMetricsReq = { id: string } // package.metrics
// @TODO Matt create package metrics type
export type GetPackageMetricsRes = any
export type InstallPackageReq = T.InstallParams
export type InstallPackageRes = null
export type GetPackageConfigReq = { id: string } // package.config.get
export type GetPackageConfigRes = { spec: CT.InputSpec; config: object }
export type DrySetPackageConfigReq = { id: string; config: object } // package.config.set.dry
export type DrySetPackageConfigRes = Breakages
export type DrySetPackageConfigRes = T.PackageId[]
export type SetPackageConfigReq = DrySetPackageConfigReq // package.config.set
export type SetPackageConfigRes = null
@@ -331,6 +359,7 @@ export module RR {
// package.backup.restore
ids: string[]
targetId: string
serverId: string
password: string
}
export type RestorePackagesRes = null
@@ -369,7 +398,10 @@ export module RR {
icon: string // base64
size: number // bytes
}
export type SideloadPacakgeRes = string //guid
export type SideloadPackageRes = {
upload: string
progress: string
}
export type SetInterfaceClearnetAddressReq = SetServerClearnetAddressReq & {
packageId: string
@@ -389,7 +421,8 @@ export module RR {
export type GetMarketplaceInfoRes = StoreInfo
export type GetMarketplaceEosReq = { serverId: string }
export type GetMarketplaceEosRes = MarketplaceEOS
// @TODO Matt fix type
export type GetMarketplaceEosRes = any
export type GetMarketplacePackagesReq = {
ids?: { id: string; version: string }[]
@@ -399,13 +432,17 @@ export module RR {
page?: number
perPage?: number
}
export type GetMarketplacePackagesRes = MarketplacePkg[]
export type GetReleaseNotesReq = { id: string }
export type GetReleaseNotesRes = { [version: string]: string }
// registry
/** these are returned in ASCENDING order. the newest available version will be the LAST in the object */
export type GetRegistryOsUpdateRes = { [version: string]: T.OsVersionInfo }
export type CheckOSUpdateReq = { serverId: string }
export type CheckOSUpdateRes = OSUpdate
}
export interface MarketplaceEOS {
export interface OSUpdate {
version: string
headline: string
releaseNotes: { [version: string]: string }
@@ -461,6 +498,7 @@ export interface Metrics {
}
export interface Session {
loggedIn: string
lastActive: string
userAgent: string
metadata: SessionMetadata
@@ -499,7 +537,7 @@ export interface UnknownDisk {
label: string | null
capacity: number
used: number | null
startOs: StartOSDiskInfo | null
startOs: Record<string, StartOSDiskInfo>
}
export interface BaseBackupTarget {
@@ -508,7 +546,7 @@ export interface BaseBackupTarget {
name: string
mountable: boolean
path: string
startOs: StartOSDiskInfo | null
startOs: Record<string, StartOSDiskInfo>
}
export interface DiskBackupTarget extends UnknownDisk, BaseBackupTarget {

View File

@@ -1,23 +1,52 @@
import { Observable } from 'rxjs'
import { Update } from 'patch-db-client'
import { RR, BackupTargetType, Metrics } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log, SetupStatus } from '@start9labs/shared'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { SetupStatus } from '@start9labs/shared'
import { RPCOptions } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
GetPackageRes,
GetPackagesRes,
MarketplacePkg,
} from '@start9labs/marketplace'
export abstract class ApiService {
// http
// for getting static files: ex icons, instructions, licenses
abstract getStatic(url: string): Promise<string>
// for sideloading packages
abstract uploadPackage(guid: string, body: Blob): Promise<void>
abstract uploadPackage(guid: string, body: Blob): Promise<string>
abstract uploadFile(body: Blob): Promise<string>
// for getting static files: ex icons, instructions, licenses
abstract getStaticProxy(
pkg: MarketplacePkg,
path: 'LICENSE.md' | 'instructions.md',
): Promise<string>
abstract getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md' | 'instructions.md',
): Promise<string>
// websocket
abstract openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
): Observable<T>
// state
abstract echo(params: RR.EchoReq, url: string): Promise<RR.EchoRes>
abstract getState(): Promise<RR.ServerState>
// db
abstract subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes>
abstract setDbValue<T>(
pathArr: Array<string | number>,
value: T,
@@ -37,20 +66,26 @@ export abstract class ApiService {
params: RR.ResetPasswordReq,
): Promise<RR.ResetPasswordRes>
// diagnostic
abstract diagnosticGetError(): Promise<RR.DiagnosticErrorRes>
abstract diagnosticRestart(): Promise<void>
abstract diagnosticForgetDrive(): Promise<void>
abstract diagnosticRepairDisk(): Promise<void>
abstract diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes>
// init
abstract initGetProgress(): Promise<RR.InitGetProgressRes>
abstract initFollowLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes>
// server
abstract echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes>
abstract openPatchWebsocket$(): Observable<Update<DataModel>>
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
abstract openMetricsWebsocket$(
config: WebSocketSubjectConfig<Metrics>,
): Observable<Metrics>
abstract getSystemTime(
params: RR.GetSystemTimeReq,
): Promise<RR.GetSystemTimeRes>
@@ -95,11 +130,7 @@ export abstract class ApiService {
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes>
abstract systemRebuild(
params: RR.SystemRebuildReq,
): Promise<RR.SystemRebuildRes>
abstract repairDisk(params: RR.SystemRebuildReq): Promise<RR.SystemRebuildRes>
abstract repairDisk(params: RR.DiskRepairReq): Promise<RR.DiskRepairRes>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
@@ -109,13 +140,22 @@ export abstract class ApiService {
// marketplace URLs
abstract marketplaceProxy<T>(
path: string,
params: Record<string, unknown>,
url: string,
abstract registryRequest<T>(
registryUrl: string,
options: RPCOptions,
): Promise<T>
abstract getEos(): Promise<RR.GetMarketplaceEosRes>
abstract checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes>
abstract getRegistryInfo(registryUrl: string): Promise<T.RegistryInfo>
abstract getRegistryPackage(
url: string,
id: string,
versionRange: string | null,
): Promise<GetPackageRes>
abstract getRegistryPackages(registryUrl: string): Promise<GetPackagesRes>
// notification
@@ -308,11 +348,7 @@ export abstract class ApiService {
params: RR.DryConfigureDependencyReq,
): Promise<RR.DryConfigureDependencyRes>
abstract sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes>
abstract getSetupStatus(): Promise<SetupStatus | null>
abstract sideloadPackage(): Promise<RR.SideloadPackageRes>
abstract setInterfaceClearnetAddress(
params: RR.SetInterfaceClearnetAddressReq,

View File

@@ -3,22 +3,29 @@ import {
HttpOptions,
HttpService,
isRpcError,
Log,
Method,
RpcError,
RPCOptions,
SetupStatus,
} from '@start9labs/shared'
import { PATCH_CACHE } from 'src/app/services/patch-db/patch-db-source'
import { ApiService } from './embassy-api.service'
import { BackupTargetType, Metrics, RR } from './api.types'
import { BackupTargetType, RR } from './api.types'
import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { webSocket } from 'rxjs/webSocket'
import { Observable, filter, firstValueFrom } from 'rxjs'
import { AuthService } from '../auth.service'
import { DOCUMENT } from '@angular/common'
import { DataModel } from '../patch-db/data-model'
import { PatchDB, pathFromArray, Update } from 'patch-db-client'
import { getServerInfo } from 'src/app/utils/get-server-info'
import { Dump, pathFromArray } from 'patch-db-client'
import { T } from '@start9labs/start-sdk'
import {
GetPackageReq,
GetPackageRes,
GetPackagesReq,
GetPackagesRes,
MarketplacePkg,
} from '@start9labs/marketplace'
import { blake3 } from '@noble/hashes/blake3'
@Injectable()
export class LiveApiService extends ApiService {
@@ -27,7 +34,7 @@ export class LiveApiService extends ApiService {
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly auth: AuthService,
private readonly patch: PatchDB<DataModel>,
@Inject(PATCH_CACHE) private readonly cache$: Observable<Dump<DataModel>>,
) {
super()
@@ -35,17 +42,9 @@ export class LiveApiService extends ApiService {
this.document.defaultView.rpcClient = this
}
// for getting static files: ex icons, instructions, licenses
async getStatic(url: string): Promise<string> {
return this.httpRequest({
method: Method.GET,
url,
responseType: 'text',
})
}
// for sideloading packages
async uploadPackage(guid: string, body: Blob): Promise<void> {
async uploadPackage(guid: string, body: Blob): Promise<string> {
return this.httpRequest({
method: Method.POST,
body,
@@ -59,12 +58,73 @@ export class LiveApiService extends ApiService {
method: Method.POST,
body,
url: `/rest/upload`,
})
}
// for getting static files: ex. instructions, licenses
async getStaticProxy(
pkg: MarketplacePkg,
path: 'LICENSE.md' | 'instructions.md',
): Promise<string> {
const encodedUrl = encodeURIComponent(pkg.s9pk.url)
return this.httpRequest({
method: Method.GET,
url: `/s9pk/proxy/${encodedUrl}/${path}`,
params: {
rootSighash: pkg.s9pk.commitment.rootSighash,
rootMaxsize: pkg.s9pk.commitment.rootMaxsize,
},
responseType: 'text',
})
}
async getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md' | 'instructions.md',
): Promise<string> {
return this.httpRequest({
method: Method.GET,
url: `/s9pk/installed/${id}.s9pk/${path}`,
responseType: 'text',
})
}
// websocket
openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
): Observable<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
const host = location.host
return webSocket({
url: `${protocol}://${host}/ws/rpc/${guid}`,
...config,
})
}
// state
async echo(params: RR.EchoReq, url: string): Promise<RR.EchoRes> {
return this.rpcRequest({ method: 'echo', params }, url)
}
async getState(): Promise<RR.ServerState> {
return this.rpcRequest({ method: 'state', params: {} })
}
// db
async subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes> {
return this.rpcRequest({ method: 'db.subscribe', params })
}
async setDbValue<T>(
pathArr: Array<string | number>,
value: T,
@@ -98,35 +158,59 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'auth.reset-password', params })
}
// diagnostic
async diagnosticGetError(): Promise<RR.DiagnosticErrorRes> {
return this.rpcRequest<RR.DiagnosticErrorRes>({
method: 'diagnostic.error',
params: {},
})
}
async diagnosticRestart(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.restart',
params: {},
})
}
async diagnosticForgetDrive(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.forget',
params: {},
})
}
async diagnosticRepairDisk(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.repair',
params: {},
})
}
async diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.rpcRequest<RR.GetServerLogsRes>({
method: 'diagnostic.logs',
params,
})
}
// init
async initGetProgress(): Promise<RR.InitGetProgressRes> {
return this.rpcRequest({ method: 'init.subscribe', params: {} })
}
async initFollowLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
return this.rpcRequest({ method: 'init.logs.follow', params })
}
// server
async echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes> {
return this.rpcRequest({ method: 'echo', params }, urlOverride)
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
const config: WebSocketSubjectConfig<Update<DataModel>> = {
url: `/db`,
closeObserver: {
next: val => {
if (val.reason === 'UNAUTHORIZED') this.auth.setUnverified()
},
},
}
return this.openWebsocket(config)
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return this.openWebsocket(config)
}
openMetricsWebsocket$(
config: WebSocketSubjectConfig<Metrics>,
): Observable<Metrics> {
return this.openWebsocket(config)
}
async getSystemTime(
params: RR.GetSystemTimeReq,
): Promise<RR.GetSystemTimeRes> {
@@ -175,7 +259,7 @@ export class LiveApiService extends ApiService {
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
const params = {
marketplaceUrl: url || this.config.marketplace.start9,
registry: url || this.config.marketplace.start9,
}
return this.rpcRequest({ method: 'server.update', params })
}
@@ -198,12 +282,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.shutdown', params })
}
async systemRebuild(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
return this.rpcRequest({ method: 'server.rebuild', params })
}
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
return this.rpcRequest({ method: 'disk.repair', params })
}
@@ -220,27 +298,61 @@ export class LiveApiService extends ApiService {
// marketplace URLs
async marketplaceProxy<T>(
path: string,
qp: Record<string, string>,
baseUrl: string,
async registryRequest<T>(
registryUrl: string,
options: RPCOptions,
): Promise<T> {
const fullUrl = `${baseUrl}${path}?${new URLSearchParams(qp).toString()}`
return this.rpcRequest({
method: 'marketplace.get',
params: { url: fullUrl },
...options,
method: `registry.${options.method}`,
params: { registry: registryUrl, ...options.params },
})
}
async getEos(): Promise<RR.GetMarketplaceEosRes> {
const { id } = await getServerInfo(this.patch)
const qp: RR.GetMarketplaceEosReq = { serverId: id }
async checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes> {
const { serverId } = qp
return this.marketplaceProxy(
'/eos/v0/latest',
qp,
this.config.marketplace.start9,
)
return this.registryRequest(this.config.marketplace.start9, {
method: 'os.version.get',
params: { serverId },
})
}
async getRegistryInfo(registryUrl: string): Promise<T.RegistryInfo> {
return this.registryRequest(registryUrl, {
method: 'info',
params: {},
})
}
async getRegistryPackage(
registryUrl: string,
id: string,
versionRange: string | null,
): Promise<GetPackageRes> {
const params: GetPackageReq = {
id,
version: versionRange,
otherVersions: 'short',
}
return this.registryRequest<GetPackageRes>(registryUrl, {
method: 'package.get',
params,
})
}
async getRegistryPackages(registryUrl: string): Promise<GetPackagesRes> {
const params: GetPackagesReq = {
id: null,
version: null,
otherVersions: 'short',
}
return this.registryRequest<GetPackagesRes>(registryUrl, {
method: 'package.get',
params,
})
}
// notification
@@ -463,8 +575,8 @@ export class LiveApiService extends ApiService {
}
async followPackageLogs(
params: RR.FollowServerLogsReq,
): Promise<RR.FollowServerLogsRes> {
params: RR.FollowPackageLogsReq,
): Promise<RR.FollowPackageLogsRes> {
return this.rpcRequest({ method: 'package.logs.follow', params })
}
@@ -533,12 +645,10 @@ export class LiveApiService extends ApiService {
})
}
async sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes> {
async sideloadPackage(): Promise<RR.SideloadPackageRes> {
return this.rpcRequest({
method: 'package.sideload',
params,
params: {},
})
}
@@ -554,23 +664,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.proxy.set-outbound', params })
}
async getSetupStatus() {
return this.rpcRequest<SetupStatus | null>({
method: 'setup.status',
params: {},
})
}
private openWebsocket<T>(config: WebSocketSubjectConfig<T>): Observable<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
const host = location.host
config.url = `${protocol}://${host}/ws${config.url}`
return webSocket(config)
}
private async rpcRequest<T>(
options: RPCOptions,
urlOverride?: string,
@@ -589,9 +682,7 @@ export class LiveApiService extends ApiService {
const patchSequence = res.headers.get('x-patch-sequence')
if (patchSequence)
await firstValueFrom(
this.patch.cache$.pipe(
filter(({ sequence }) => sequence >= Number(patchSequence)),
),
this.cache$.pipe(filter(({ id }) => id >= Number(patchSequence))),
)
return body.result
@@ -599,6 +690,29 @@ export class LiveApiService extends ApiService {
private async httpRequest<T>(opts: HttpOptions): Promise<T> {
const res = await this.http.httpRequest<T>(opts)
if (res.headers.get('Repr-Digest')) {
// verify
const digest = res.headers.get('Repr-Digest')!
let data: Uint8Array
if (opts.responseType === 'arrayBuffer') {
data = Buffer.from(res.body as ArrayBuffer)
} else if (opts.responseType === 'text') {
data = Buffer.from(res.body as string)
} else if ((opts.responseType as string) === 'blob') {
data = Buffer.from(await (res.body as Blob).arrayBuffer())
} else {
console.warn(
`could not verify Repr-Digest for responseType ${
opts.responseType || 'json'
}`,
)
return res.body
}
const computedDigest = Buffer.from(blake3(data)).toString('base64')
if (`blake3=:${computedDigest}:` === digest) return res.body
console.debug(computedDigest, digest)
throw new Error('File digest mismatch.')
}
return res.body
}
}

View File

@@ -1,15 +1,20 @@
import { Injectable } from '@angular/core'
import { pauseFor, Log, getSetupStatusMock } from '@start9labs/shared'
import {
pauseFor,
Log,
getSetupStatusMock,
RPCErrorDetails,
RPCOptions,
} from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import {
Operation,
PatchOp,
pathFromArray,
RemoveOperation,
Update,
Revision,
} from 'patch-db-client'
import {
DataModel,
InstallingState,
PackageDataEntry,
Proxy,
@@ -19,24 +24,25 @@ import {
import { BackupTargetType, Metrics, RR } from './api.types'
import { Mock } from './api.fixures'
import {
EMPTY,
iif,
from,
interval,
map,
Observable,
shareReplay,
startWith,
Subject,
switchMap,
tap,
timer,
} from 'rxjs'
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
import { mockPatchData } from './mock-patch'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
import { StoreInfo } from '@start9labs/marketplace'
import { T } from '@start9labs/start-sdk'
import {
GetPackageRes,
GetPackagesRes,
MarketplacePkg,
} from '@start9labs/marketplace'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
const PROGRESS: T.FullProgress = {
overall: {
@@ -53,10 +59,7 @@ const PROGRESS: T.FullProgress = {
},
{
name: 'Validating',
progress: {
done: 0,
total: 40,
},
progress: null,
},
{
name: 'Installing',
@@ -70,44 +73,25 @@ const PROGRESS: T.FullProgress = {
@Injectable()
export class MockApiService extends ApiService {
readonly mockWsSource$ = new Subject<Update<DataModel>>()
readonly mockWsSource$ = new Subject<Revision>()
private readonly revertTime = 1800
sequence = 0
constructor(
private readonly bootstrapper: LocalStorageBootstrap,
private readonly connectionService: ConnectionService,
private readonly auth: AuthService,
) {
constructor(private readonly auth: AuthService) {
super()
this.auth.isVerified$
.pipe(
tap(() => {
this.sequence = 0
}),
switchMap(verified =>
iif(
() => verified,
timer(2000).pipe(
tap(() => {
this.connectionService.websocketConnected$.next(true)
}),
),
EMPTY,
),
),
)
.subscribe()
}
async getStatic(url: string): Promise<string> {
await pauseFor(2000)
return `* Test markdown instructions
* Test markdown instructions with [link](https://start9.com)`
}
async uploadPackage(guid: string, body: Blob): Promise<void> {
async uploadPackage(guid: string, body: Blob): Promise<string> {
await pauseFor(2000)
// @TODO Matt what should this return?
return ''
}
async uploadFile(body: Blob): Promise<string> {
@@ -115,8 +99,83 @@ export class MockApiService extends ApiService {
return 'returnedhash'
}
async getStaticProxy(
pkg: MarketplacePkg,
path: 'LICENSE.md' | 'instructions.md',
): Promise<string> {
await pauseFor(2000)
return markdown
}
async getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md' | 'instructions.md',
): Promise<string> {
await pauseFor(2000)
return markdown
}
// websocket
openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
): Observable<T> {
if (guid === 'db-guid') {
return this.mockWsSource$.pipe<any>(
shareReplay({ bufferSize: 1, refCount: true }),
)
} else if (guid === 'logs-guid') {
return interval(50).pipe<any>(
map((_, index) => {
// mock fire open observer
if (index === 0) config.openObserver?.next(new Event(''))
if (index === 100) throw new Error('HAAHHA')
return Mock.ServerLogs[0]
}),
)
} else if (guid === 'init-progress-guid') {
return from(this.initProgress()).pipe(
startWith(PROGRESS),
) as Observable<T>
} else {
throw new Error('invalid guid type')
}
}
// state
async echo(params: RR.EchoReq, url: string): Promise<RR.EchoRes> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
if (num > 8) return params.message
throw new Error()
}
await pauseFor(2000)
return params.message
}
private stateIndex = 0
async getState(): Promise<RR.ServerState> {
await pauseFor(1000)
this.stateIndex++
return this.stateIndex === 1 ? 'running' : 'running'
}
// db
async subscribeToPatchDB(
params: RR.SubscribePatchReq,
): Promise<RR.SubscribePatchRes> {
await pauseFor(2000)
return {
dump: { id: 1, value: mockPatchData },
guid: 'db-guid',
}
}
async setDbValue<T>(
pathArr: Array<string | number>,
value: T,
@@ -140,11 +199,6 @@ export class MockApiService extends ApiService {
async login(params: RR.LoginReq): Promise<RR.loginRes> {
await pauseFor(2000)
setTimeout(() => {
this.mockWsSource$.next({ id: 1, value: mockPatchData })
}, 2000)
return null
}
@@ -170,34 +224,63 @@ export class MockApiService extends ApiService {
return null
}
// server
// diagnostic
async echo(params: RR.EchoReq, url?: string): Promise<RR.EchoRes> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
if (num > 8) return params.message
throw new Error()
async getError(): Promise<RPCErrorDetails> {
await pauseFor(1000)
return {
code: 15,
message: 'Unknown server',
data: { details: 'Some details about the error here' },
}
}
async diagnosticGetError(): Promise<RR.DiagnosticErrorRes> {
await pauseFor(1000)
return {
code: 15,
message: 'Unknown server',
data: { details: 'Some details about the error here' },
}
}
async diagnosticRestart(): Promise<void> {
await pauseFor(1000)
}
async diagnosticForgetDrive(): Promise<void> {
await pauseFor(1000)
}
async diagnosticRepairDisk(): Promise<void> {
await pauseFor(1000)
}
async diagnosticGetLogs(
params: RR.GetServerLogsReq,
): Promise<RR.GetServerLogsRes> {
return this.getServerLogs(params)
}
// init
async initGetProgress(): Promise<RR.InitGetProgressRes> {
await pauseFor(250)
return {
progress: PROGRESS,
guid: 'init-progress-guid',
}
}
async initFollowLogs(): Promise<RR.FollowServerLogsRes> {
await pauseFor(2000)
return params.message
return {
startCursor: 'start-cursor',
guid: 'logs-guid',
}
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
return this.mockWsSource$.pipe(
shareReplay({ bufferSize: 1, refCount: true }),
)
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(50).pipe(
map((_, index) => {
// mock fire open observer
if (index === 0) config.openObserver?.next(new Event(''))
if (index === 100) throw new Error('HAAHHA')
return Mock.ServerLogs[0]
}),
)
}
// server
openMetricsWebsocket$(
config: WebSocketSubjectConfig<Metrics>,
@@ -265,7 +348,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
@@ -275,7 +358,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
@@ -285,11 +368,11 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
randomLogs(limit = 1): Log[] {
private randomLogs(limit = 1): Log[] {
const arrLength = Math.ceil(limit / Mock.ServerLogs.length)
const logs = new Array(arrLength)
.fill(Mock.ServerLogs)
@@ -404,12 +487,6 @@ export class MockApiService extends ApiService {
return null
}
async systemRebuild(
params: RR.SystemRebuildReq,
): Promise<RR.SystemRebuildRes> {
return this.restartServer(params)
}
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
await pauseFor(2000)
return null
@@ -439,45 +516,41 @@ export class MockApiService extends ApiService {
// marketplace URLs
async marketplaceProxy(
path: string,
params: Record<string, string>,
url: string,
async registryRequest(
registryUrl: string,
options: RPCOptions,
): Promise<any> {
await pauseFor(2000)
if (path === '/package/v0/info') {
const info: StoreInfo = {
name: 'Start9 Registry',
categories: [
'bitcoin',
'lightning',
'data',
'featured',
'messaging',
'social',
'alt coin',
'ai',
],
}
return info
} else if (path === '/package/v0/index') {
const version = params['ids']
? (JSON.parse(params['ids']) as { version: string; id: string }[])[0]
.version
: undefined
return Mock.marketplacePkgsList(version)
} else if (path.startsWith('/package/v0/release-notes')) {
return Mock.ReleaseNotes
} else if (path.includes('instructions') || path.includes('license')) {
return `* Test markdown instructions
* Test markdown instructions with [link](https://start9.com)`
return Error('do not call directly')
}
async checkOSUpdate(qp: RR.CheckOSUpdateReq): Promise<RR.CheckOSUpdateRes> {
await pauseFor(2000)
return Mock.MarketplaceEos
}
async getRegistryInfo(registryUrl: string): Promise<T.RegistryInfo> {
await pauseFor(2000)
return Mock.RegistryInfo
}
async getRegistryPackage(
url: string,
id: string,
versionRange: string,
): Promise<GetPackageRes> {
await pauseFor(2000)
if (!versionRange) {
return Mock.RegistryPackages[id]
} else {
return Mock.OtherPackageVersions[id][versionRange]
}
}
async getEos(): Promise<RR.GetMarketplaceEosRes> {
async getRegistryPackages(registryUrl: string): Promise<GetPackagesRes> {
await pauseFor(2000)
return Mock.MarketplaceEos
return Mock.RegistryPackages
}
// notification
@@ -776,7 +849,7 @@ export class MockApiService extends ApiService {
path: path.replace(/\\/g, '/'),
username: 'mockusername',
mountable: true,
startOs: null,
startOs: {},
}
}
@@ -936,13 +1009,13 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
let entries
if (Math.random() < 0.2) {
entries = Mock.PackageLogs
entries = Mock.ServerLogs
} else {
const arrLength = params.limit
? Math.ceil(params.limit / Mock.PackageLogs.length)
? Math.ceil(params.limit / Mock.ServerLogs.length)
: 10
entries = new Array(arrLength)
.fill(Mock.PackageLogs)
.fill(Mock.ServerLogs)
.reduce((acc, val) => acc.concat(val), [])
}
return {
@@ -958,7 +1031,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
startCursor: 'start-cursor',
guid: '7251d5be-645f-4362-a51b-3a85be92b31e',
guid: 'logs-guid',
}
}
@@ -968,7 +1041,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
setTimeout(async () => {
this.updateProgress(params.id)
this.installProgress(params.id)
}, 1000)
const patch: Operation<
@@ -981,11 +1054,11 @@ export class MockApiService extends ApiService {
...Mock.LocalPkgs[params.id],
stateInfo: {
// if installing
// state: 'installing',
state: 'installing',
// if updating
state: 'updating',
manifest: mockPatchData.packageData[params.id].stateInfo.manifest!,
// state: 'updating',
// manifest: mockPatchData.packageData[params.id].stateInfo.manifest!,
// both
installingInfo: {
@@ -1015,7 +1088,7 @@ export class MockApiService extends ApiService {
params: RR.DrySetPackageConfigReq,
): Promise<RR.DrySetPackageConfigRes> {
await pauseFor(2000)
return {}
return []
}
async setPackageConfig(
@@ -1040,7 +1113,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
const patch: Operation<PackageDataEntry>[] = params.ids.map(id => {
setTimeout(async () => {
this.updateProgress(id)
this.installProgress(id)
}, 2000)
return {
@@ -1267,15 +1340,12 @@ export class MockApiService extends ApiService {
}
}
async sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes> {
async sideloadPackage(): Promise<RR.SideloadPackageRes> {
await pauseFor(2000)
return '4120e092-05ab-4de2-9fbd-c3f1f4b1df9e' // no significance, randomly generated
}
async getSetupStatus() {
return getSetupStatusMock()
return {
upload: '4120e092-05ab-4de2-9fbd-c3f1f4b1df9e', // no significance, randomly generated
progress: '5120e092-05ab-4de2-9fbd-c3f1f4b1df9e', // no significance, randomly generated
}
}
async setInterfaceClearnetAddress(
@@ -1310,11 +1380,61 @@ export class MockApiService extends ApiService {
return null
}
private async updateProgress(id: string): Promise<void> {
private async initProgress(): Promise<T.FullProgress> {
const progress = JSON.parse(JSON.stringify(PROGRESS))
for (let [i, phase] of progress.phases.entries()) {
if (typeof phase.progress !== 'object' || !phase.progress.total) {
if (
!phase.progress ||
typeof phase.progress !== 'object' ||
!phase.progress.total
) {
await pauseFor(2000)
progress.phases[i].progress = true
if (
progress.overall &&
typeof progress.overall === 'object' &&
progress.overall.total
) {
const step = progress.overall.total / progress.phases.length
progress.overall.done += step
}
} else {
const step = phase.progress.total / 4
while (phase.progress.done < phase.progress.total) {
await pauseFor(200)
phase.progress.done += step
if (
progress.overall &&
typeof progress.overall === 'object' &&
progress.overall.total
) {
const step = progress.overall.total / progress.phases.length / 4
progress.overall.done += step
}
if (phase.progress.done === phase.progress.total) {
await pauseFor(250)
progress.phases[i].progress = true
}
}
}
}
return progress
}
private async installProgress(id: string): Promise<void> {
const progress = JSON.parse(JSON.stringify(PROGRESS))
for (let [i, phase] of progress.phases.entries()) {
if (!phase.progress || phase.progress === true || !phase.progress.total) {
await pauseFor(2000)
const patches: Operation<any>[] = [
@@ -1326,7 +1446,11 @@ export class MockApiService extends ApiService {
]
// overall
if (typeof progress.overall === 'object' && progress.overall.total) {
if (
progress.overall &&
typeof progress.overall === 'object' &&
progress.overall.total
) {
const step = progress.overall.total / progress.phases.length
progress.overall.done += step
@@ -1356,7 +1480,11 @@ export class MockApiService extends ApiService {
]
// overall
if (typeof progress.overall === 'object' && progress.overall.total) {
if (
progress.overall &&
typeof progress.overall === 'object' &&
progress.overall.total
) {
const step = progress.overall.total / progress.phases.length / 4
progress.overall.done += step
@@ -1469,10 +1597,6 @@ export class MockApiService extends ApiService {
}
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
if (!this.sequence) {
const { sequence } = this.bootstrapper.init()
this.sequence = sequence
}
const revision = {
id: ++this.sequence,
patch,

View File

@@ -129,11 +129,13 @@ export const mockPatchData: DataModel = {
login: '',
password: '',
},
platform: 'x86_64-nonfree',
// @TODO Matt zram needs to be added?
// zram: true,
governor: 'performance',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
platform: 'x86_64-nonfree',
arch: 'x86_64',
governor: 'performance',
},
packageData: {
bitcoind: {
@@ -141,7 +143,7 @@ export const mockPatchData: DataModel = {
state: 'installed',
manifest: {
...Mock.MockManifestBitcoind,
version: '0.20.0',
version: '0.20.0:0',
},
},
icon: '/assets/img/service-icons/bitcoind.svg',
@@ -181,9 +183,8 @@ export const mockPatchData: DataModel = {
},
},
},
dependencyConfigErrors: {},
},
actions: {}, // @TODO
actions: {},
serviceInterfaces: {
ui: {
id: 'ui',
@@ -197,66 +198,11 @@ export const mockPatchData: DataModel = {
addressInfo: {
username: null,
hostId: 'abcdefg',
bindOptions: {
scheme: 'http',
preferredExternalPort: 80,
addSsl: {
// addXForwardedHeaders: false,
preferredExternalPort: 443,
scheme: 'https',
alpn: { specified: ['http/1.1', 'h2'] },
},
secure: null,
},
internalPort: 80,
scheme: 'http',
sslScheme: 'https',
suffix: '',
},
hostInfo: {
id: 'abcdefg',
kind: 'multi',
hostnames: [
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'onion',
hostname: {
value: 'bitcoin-ui-address.onion',
port: 80,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.1.5',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv6',
value: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
port: null,
sslPort: 1234,
},
},
],
},
},
rpc: {
id: 'rpc',
@@ -270,66 +216,11 @@ export const mockPatchData: DataModel = {
addressInfo: {
username: null,
hostId: 'bcdefgh',
bindOptions: {
scheme: 'http',
preferredExternalPort: 80,
addSsl: {
// addXForwardedHeaders: false,
preferredExternalPort: 443,
scheme: 'https',
alpn: { specified: ['http/1.1'] },
},
secure: null,
},
internalPort: 8332,
scheme: 'http',
sslScheme: 'https',
suffix: '',
},
hostInfo: {
id: 'bcdefgh',
kind: 'multi',
hostnames: [
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 2345,
},
},
{
kind: 'onion',
hostname: {
value: 'bitcoin-rpc-address.onion',
port: 80,
sslPort: 443,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.1.5',
port: null,
sslPort: 2345,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv6',
value: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
port: null,
sslPort: 2345,
},
},
],
},
},
p2p: {
id: 'p2p',
@@ -343,69 +234,117 @@ export const mockPatchData: DataModel = {
addressInfo: {
username: null,
hostId: 'cdefghi',
bindOptions: {
scheme: 'bitcoin',
preferredExternalPort: 8333,
addSsl: null,
secure: {
ssl: false,
},
},
internalPort: 8333,
scheme: 'bitcoin',
sslScheme: null,
suffix: '',
},
hostInfo: {
id: 'cdefghi',
kind: 'multi',
hostnames: [
},
},
currentDependencies: {},
hosts: {
abcdefg: {
kind: 'multi',
bindings: [],
addresses: [],
hostnameInfo: {
80: [
{
kind: 'ip',
networkInterfaceId: 'elan0',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: 3456,
sslPort: null,
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
networkInterfaceId: 'eth0',
public: false,
hostname: {
kind: 'ipv6',
value: '[FE80:CD00:0000:0CDE:1257:0000:211E:729CD]',
port: null,
sslPort: 1234,
},
},
{
kind: 'ip',
networkInterfaceId: 'wlan0',
public: false,
hostname: {
kind: 'ipv6',
value: '[FE80:CD00:0000:0CDE:1257:0000:211E:1234]',
port: null,
sslPort: 1234,
},
},
{
kind: 'onion',
hostname: {
value: 'bitcoin-p2p-address.onion',
port: 8333,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.1.5',
port: 3456,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv6',
value: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
port: 3456,
sslPort: null,
value: 'bitcoin-p2p.onion',
port: 80,
sslPort: 443,
},
},
],
},
},
bcdefgh: {
kind: 'multi',
bindings: [],
addresses: [],
hostnameInfo: {
8332: [],
},
},
cdefghi: {
kind: 'multi',
bindings: [],
addresses: [],
hostnameInfo: {
8333: [],
},
},
},
currentDependencies: {},
hosts: {},
storeExposedDependents: [],
marketplaceUrl: 'https://registry.start9.com/',
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
outboundProxy: null,
},
@@ -414,7 +353,7 @@ export const mockPatchData: DataModel = {
state: 'installed',
manifest: {
...Mock.MockManifestLnd,
version: '0.11.0',
version: '0.11.0:0.0.1',
},
},
icon: '/assets/img/service-icons/lnd.png',
@@ -426,9 +365,6 @@ export const mockPatchData: DataModel = {
main: {
status: 'stopped',
},
dependencyConfigErrors: {
'btc-rpc-proxy': 'This is a config unsatisfied error',
},
},
actions: {},
serviceInterfaces: {
@@ -444,63 +380,11 @@ export const mockPatchData: DataModel = {
addressInfo: {
username: null,
hostId: 'qrstuv',
bindOptions: {
scheme: 'grpc',
preferredExternalPort: 10009,
addSsl: null,
secure: {
ssl: true,
},
},
internalPort: 10009,
scheme: null,
sslScheme: 'grpc',
suffix: '',
},
hostInfo: {
id: 'qrstuv',
kind: 'multi',
hostnames: [
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: 5678,
sslPort: null,
},
},
{
kind: 'onion',
hostname: {
value: 'lnd-grpc-address.onion',
port: 10009,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.1.5',
port: 5678,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv6',
value: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
port: 5678,
sslPort: null,
},
},
],
},
},
lndconnect: {
id: 'lndconnect',
@@ -514,63 +398,11 @@ export const mockPatchData: DataModel = {
addressInfo: {
username: null,
hostId: 'qrstuv',
bindOptions: {
scheme: 'lndconnect',
preferredExternalPort: 10009,
addSsl: null,
secure: {
ssl: true,
},
},
internalPort: 10009,
scheme: null,
sslScheme: 'lndconnect',
suffix: 'cert=askjdfbjadnaskjnd&macaroon=ksjbdfnhjasbndjksand',
},
hostInfo: {
id: 'qrstuv',
kind: 'multi',
hostnames: [
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: 5678,
sslPort: null,
},
},
{
kind: 'onion',
hostname: {
value: 'lnd-grpc-address.onion',
port: 10009,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.1.5',
port: 5678,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv6',
value: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
port: 5678,
sslPort: null,
},
},
],
},
},
p2p: {
id: 'p2p',
@@ -584,61 +416,11 @@ export const mockPatchData: DataModel = {
addressInfo: {
username: null,
hostId: 'rstuvw',
bindOptions: {
scheme: null,
preferredExternalPort: 9735,
addSsl: null,
secure: { ssl: true },
},
internalPort: 8333,
scheme: 'bitcoin',
sslScheme: null,
suffix: '',
},
hostInfo: {
id: 'rstuvw',
kind: 'multi',
hostnames: [
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: 6789,
sslPort: null,
},
},
{
kind: 'onion',
hostname: {
value: 'lnd-p2p-address.onion',
port: 9735,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.1.5',
port: 6789,
sslPort: null,
},
},
{
kind: 'ip',
networkInterfaceId: 'elan0',
public: false,
hostname: {
kind: 'ipv6',
value: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
port: 6789,
sslPort: null,
},
},
],
},
},
},
currentDependencies: {
@@ -646,22 +428,22 @@ export const mockPatchData: DataModel = {
title: 'Bitcoin Core',
icon: 'assets/img/service-icons/bitcoind.svg',
kind: 'running',
registryUrl: 'https://registry.start9.com',
versionSpec: '>=26.0.0',
versionRange: '>=26.0.0',
healthChecks: [],
configSatisfied: true,
},
'btc-rpc-proxy': {
title: 'Bitcoin Proxy',
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
kind: 'running',
registryUrl: 'https://community-registry.start9.com',
versionSpec: '>2.0.0',
versionRange: '>2.0.0',
healthChecks: [],
configSatisfied: false,
},
},
hosts: {},
storeExposedDependents: [],
marketplaceUrl: 'https://registry.start9.com/',
registry: 'https://registry.start9.com/',
developerKey: 'developer-key',
outboundProxy: null,
},

View File

@@ -11,7 +11,7 @@ export enum AuthState {
providedIn: 'root',
})
export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly LOGGED_IN_KEY = 'loggedIn'
private readonly authState$ = new ReplaySubject<AuthState>(1)
readonly isVerified$ = this.authState$.pipe(

View File

@@ -81,42 +81,50 @@ export class ConfigService {
}
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(ui: T.ServiceInterfaceWithHostInfo): string {
if (ui.type !== 'ui') return ''
launchableAddress(
interfaces: PackageDataEntry['serviceInterfaces'],
hosts: PackageDataEntry['hosts'],
): string {
const ui = Object.values(interfaces).find(
i =>
i.type === 'ui' &&
(i.addressInfo.scheme === 'http' ||
i.addressInfo.sslScheme === 'https'),
) // TODO select if multiple
if (!ui) return ''
const hostnameInfo =
hosts[ui.addressInfo.hostId]?.hostnameInfo[ui.addressInfo.internalPort]
if (!hostnameInfo) return ''
const host = ui.hostInfo
const addressInfo = ui.addressInfo
const scheme = this.isHttps() ? 'https' : 'http'
const scheme = this.isHttps()
? ui.addressInfo.sslScheme === 'https'
? 'https'
: 'http'
: ui.addressInfo.scheme === 'http'
? 'http'
: 'https'
const username = addressInfo.username ? addressInfo.username + '@' : ''
const suffix = addressInfo.suffix || ''
const url = new URL(`${scheme}://${username}placeholder${suffix}`)
if (host.kind === 'multi') {
const onionHostname = host.hostnames.find(h => h.kind === 'onion')
?.hostname as T.ExportedOnionHostname
const onionHostname = hostnameInfo.find(h => h.kind === 'onion')
?.hostname as T.OnionHostname | undefined
if (this.isTor() && onionHostname) {
url.hostname = onionHostname.value
} else {
const ipHostname = host.hostnames.find(h => h.kind === 'ip')
?.hostname as T.ExportedIpHostname
if (!ipHostname) return ''
url.hostname = this.hostname
url.port = String(ipHostname.sslPort || ipHostname.port)
}
if (this.isTor() && onionHostname) {
url.hostname = onionHostname.value
} else {
const hostname = {} as T.ExportedHostnameInfo // host.hostname
const ipHostname = hostnameInfo.find(h => h.kind === 'ip')?.hostname as
| T.IpHostname
| undefined
if (!hostname) return ''
if (!ipHostname) return ''
if (this.isTor() && hostname.kind === 'onion') {
url.hostname = (hostname.hostname as T.ExportedOnionHostname).value
} else {
url.hostname = this.hostname
url.port = String(hostname.hostname.sslPort || hostname.hostname.port)
}
url.hostname = this.hostname
url.port = String(ipHostname.sslPort || ipHostname.port)
}
return url.href

View File

@@ -1,32 +1,23 @@
import { Injectable } from '@angular/core'
import {
combineLatest,
distinctUntilChanged,
map,
startWith,
fromEvent,
merge,
ReplaySubject,
} from 'rxjs'
import { inject, Injectable } from '@angular/core'
import { combineLatest, Observable, shareReplay } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { NetworkService } from 'src/app/services/network.service'
import { StateService } from 'src/app/services/state.service'
@Injectable({
providedIn: 'root',
})
export class ConnectionService {
readonly networkConnected$ = merge(
fromEvent(window, 'online'),
fromEvent(window, 'offline'),
).pipe(
startWith(null),
map(() => navigator.onLine),
distinctUntilChanged(),
)
readonly websocketConnected$ = new ReplaySubject<boolean>(1)
readonly connected$ = combineLatest([
this.networkConnected$,
this.websocketConnected$.pipe(distinctUntilChanged()),
export class ConnectionService extends Observable<boolean> {
private readonly stream$ = combineLatest([
inject(NetworkService),
inject(StateService).pipe(map(Boolean)),
]).pipe(
map(([network, websocket]) => network && websocket),
distinctUntilChanged(),
shareReplay(1),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { Exver } from '@start9labs/shared'
import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import {
@@ -42,7 +42,7 @@ export class DepErrorService {
)
constructor(
private readonly emver: Emver,
private readonly exver: Exver,
private readonly patch: PatchDB<DataModel>,
) {}
@@ -86,20 +86,26 @@ export class DepErrorService {
}
}
const versionSpec = pkg.currentDependencies[depId].versionSpec
const currentDep = pkg.currentDependencies[depId]
const depManifest = dep.stateInfo.manifest
// incorrect version
if (!this.emver.satisfies(depManifest.version, versionSpec)) {
return {
type: 'incorrectVersion',
expected: versionSpec,
received: depManifest.version,
if (!this.exver.satisfies(depManifest.version, currentDep.versionRange)) {
if (
depManifest.satisfies.some(
v => !this.exver.satisfies(v, currentDep.versionRange),
)
) {
return {
type: 'incorrectVersion',
expected: currentDep.versionRange,
received: depManifest.version,
}
}
}
// invalid config
if (Object.values(pkg.status.dependencyConfigErrors).some(err => !!err)) {
if (!currentDep.configSatisfied) {
return {
type: 'configUnsatisfied',
}
@@ -114,8 +120,6 @@ export class DepErrorService {
}
}
const currentDep = pkg.currentDependencies[depId]
// health check failure
if (depStatus === 'running' && currentDep.kind === 'running') {
for (let id of currentDep.healthChecks) {

View File

@@ -1,17 +1,17 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject, distinctUntilChanged, map, combineLatest } from 'rxjs'
import { MarketplaceEOS } from 'src/app/services/api/api.types'
import { OSUpdate } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { getServerInfo } from 'src/app/utils/get-server-info'
import { DataModel } from './patch-db/data-model'
import { Exver } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
})
export class EOSService {
eos?: MarketplaceEOS
osUpdate?: OSUpdate
updateAvailable$ = new BehaviorSubject<boolean>(false)
readonly updating$ = this.patch.watch$('serverInfo', 'statusInfo').pipe(
@@ -46,14 +46,15 @@ export class EOSService {
constructor(
private readonly api: ApiService,
private readonly emver: Emver,
private readonly patch: PatchDB<DataModel>,
private readonly exver: Exver,
) {}
async loadEos(): Promise<void> {
const { version } = await getServerInfo(this.patch)
this.eos = await this.api.getEos()
const updateAvailable = this.emver.compare(this.eos.version, version) === 1
const { version, id } = await getServerInfo(this.patch)
this.osUpdate = await this.api.checkOSUpdate({ serverId: id })
const updateAvailable =
this.exver.compareOsVersion(this.osUpdate.version, version) === 'greater'
this.updateAvailable$.next(updateAvailable)
}
}

View File

@@ -7,8 +7,7 @@ import {
ValidatorFn,
Validators,
} from '@angular/forms'
import { getDefaultString } from 'src/app/utils/config-utilities'
import { CT } from '@start9labs/start-sdk'
import { CT, utils } from '@start9labs/start-sdk'
const Mustache = require('mustache')
@Injectable({
@@ -40,28 +39,25 @@ export class FormService {
getUnionObject(
spec: CT.ValueSpecUnion,
selection: string | null,
selected: string | null,
): UntypedFormGroup {
const group = this.getFormGroup({
[CT.unionSelectKey]: this.getUnionSelectSpec(spec, selection),
selection: this.getUnionSelectSpec(spec, selected),
})
group.setControl(
CT.unionValueKey,
this.getFormGroup(selection ? spec.variants[selection].spec : {}),
'value',
this.getFormGroup(selected ? spec.variants[selected].spec : {}),
)
return group
}
getListItem(spec: CT.ValueSpecList, entry?: any) {
const listItemValidators = getListItemValidators(spec)
if (CT.isValueSpecListOf(spec, 'text')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (CT.isValueSpecListOf(spec, 'number')) {
return this.formBuilder.control(entry, listItemValidators)
return this.formBuilder.control(entry, stringValidators(spec.spec))
} else if (CT.isValueSpecListOf(spec, 'object')) {
return this.getFormGroup(spec.spec.spec, listItemValidators, entry)
return this.getFormGroup(spec.spec.spec, [], entry)
}
}
@@ -90,7 +86,7 @@ export class FormService {
if (currentValue !== undefined) {
value = currentValue
} else {
value = spec.default ? getDefaultString(spec.default) : null
value = spec.default ? utils.getDefaultString(spec.default) : null
}
return this.formBuilder.control(value, stringValidators(spec))
case 'textarea':
@@ -132,7 +128,7 @@ export class FormService {
fileValidators(spec),
)
case 'union':
const currentSelection = currentValue?.[CT.unionSelectKey]
const currentSelection = currentValue?.selection
const isValid = !!spec.variants[currentSelection]
return this.getUnionObject(
@@ -154,13 +150,11 @@ export class FormService {
}
}
function getListItemValidators(spec: CT.ValueSpecList) {
if (CT.isValueSpecListOf(spec, 'text')) {
return stringValidators(spec.spec)
} else if (CT.isValueSpecListOf(spec, 'number')) {
return numberValidators(spec.spec)
}
}
// function getListItemValidators(spec: CT.ValueSpecList) {
// if (CT.isValueSpecListOf(spec, 'text')) {
// return stringValidators(spec.spec)
// }
// }
function stringValidators(
spec: CT.ValueSpecText | CT.ListValueSpecText,
@@ -224,9 +218,7 @@ function datetimeValidators({
return validators
}
function numberValidators(
spec: CT.ValueSpecNumber | CT.ListValueSpecNumber,
): ValidatorFn[] {
function numberValidators(spec: CT.ValueSpecNumber): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(isNumber())
@@ -416,7 +408,6 @@ function listItemEquals(spec: CT.ValueSpecList, val1: any, val2: any): boolean {
// TODO: fix types
switch (spec.spec.type) {
case 'text':
case 'number':
return val1 == val2
case 'object':
const obj = spec.spec
@@ -528,12 +519,12 @@ function unionEquals(
val1: any,
val2: any,
): boolean {
const variantSpec = spec.variants[val1[CT.unionSelectKey]].spec
const variantSpec = spec.variants[val1.selection].spec
if (!uniqueBy) {
return false
} else if (typeof uniqueBy === 'string') {
if (uniqueBy === CT.unionSelectKey) {
return val1[CT.unionSelectKey] === val2[CT.unionSelectKey]
if (uniqueBy === 'selection') {
return val1.selection === val2.selection
} else {
return itemEquals(variantSpec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
}
@@ -623,18 +614,13 @@ export function convertValuesRecursive(
convertValuesRecursive(valueSpec.spec, group.get(key) as UntypedFormGroup)
} else if (valueSpec.type === 'union') {
const formGr = group.get(key) as UntypedFormGroup
const spec =
valueSpec.variants[formGr.controls[CT.unionSelectKey].value].spec
const spec = valueSpec.variants[formGr.controls['selection'].value].spec
convertValuesRecursive(spec, formGr)
} else if (valueSpec.type === 'list') {
const formArr = group.get(key) as UntypedFormArray
const { controls } = formArr
if (valueSpec.spec.type === 'number') {
controls.forEach(control => {
control.setValue(control.value ? Number(control.value) : null)
})
} else if (valueSpec.spec.type === 'text') {
if (valueSpec.spec.type === 'text') {
controls.forEach(control => {
if (!control.value) control.setValue(null)
})

View File

@@ -1,12 +1,11 @@
import { Injectable } from '@angular/core'
import { sameUrl } from '@start9labs/shared'
import {
AbstractMarketplaceService,
Marketplace,
MarketplacePkg,
StoreData,
StoreIdentity,
StoreInfo,
MarketplacePkg,
GetPackageRes,
StoreData,
} from '@start9labs/marketplace'
import { PatchDB } from 'patch-db-client'
import {
@@ -20,8 +19,8 @@ import {
mergeMap,
Observable,
of,
pairwise,
scan,
pairwise,
shareReplay,
startWith,
switchMap,
@@ -32,7 +31,9 @@ import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
import { ConfigService } from './config.service'
import { Exver, sameUrl } from '@start9labs/shared'
import { ClientStorageService } from './client-storage.service'
import { T } from '@start9labs/start-sdk'
@Injectable()
export class MarketplaceService implements AbstractMarketplaceService {
@@ -87,11 +88,9 @@ export class MarketplaceService implements AbstractMarketplaceService {
mergeMap(({ url, name }) =>
this.fetchStore$(url).pipe(
tap(data => {
if (data?.info) this.updateStoreName(url, name, data.info.name)
}),
map<StoreData | null, [string, StoreData | null]>(data => {
return [url, data]
if (data?.info.name) this.updateStoreName(url, name, data.info.name)
}),
map<StoreData | null, [string, StoreData | null]>(data => [url, data]),
startWith<[string, StoreData | null]>([url, null]),
),
),
@@ -142,6 +141,7 @@ export class MarketplaceService implements AbstractMarketplaceService {
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
private readonly clientStorageService: ClientStorageService,
private readonly exver: Exver,
) {}
getKnownHosts$(filtered = false): Observable<StoreIdentity[]> {
@@ -190,28 +190,29 @@ export class MarketplaceService implements AbstractMarketplaceService {
getPackage$(
id: string,
version: string,
optionalUrl?: string,
version: string | null,
flavor: string | null,
registryUrl?: string,
): Observable<MarketplacePkg> {
return this.patch.watch$('ui', 'marketplace').pipe(
switchMap(uiMarketplace => {
const url = optionalUrl || uiMarketplace.selectedUrl
return this.selectedHost$.pipe(
switchMap(selected =>
this.marketplace$.pipe(
switchMap(m => {
const url = registryUrl || selected.url
if (version !== '*' || !uiMarketplace.knownHosts[url]) {
return this.fetchPackage$(id, version, url)
}
const pkg = m[url]?.packages.find(
p =>
p.id === id &&
p.flavor === flavor &&
(!version || this.exver.compareExver(p.version, version) === 0),
)
return this.marketplace$.pipe(
map(m => m[url]),
filter(Boolean),
take(1),
map(
store =>
store.packages.find(p => p.manifest.id === id) ||
({} as MarketplacePkg),
),
)
}),
return !!pkg
? of(pkg)
: this.fetchPackage$(url, id, version, flavor)
}),
),
),
)
}
@@ -230,56 +231,22 @@ export class MarketplaceService implements AbstractMarketplaceService {
): Promise<void> {
const params: RR.InstallPackageReq = {
id,
versionSpec: `=${version}`,
marketplaceUrl: url,
version,
registry: url,
}
await this.api.installPackage(params)
}
fetchInfo$(url: string): Observable<StoreInfo> {
return this.patch.watch$('serverInfo').pipe(
take(1),
switchMap(serverInfo => {
const qp: RR.GetMarketplaceInfoReq = { serverId: serverInfo.id }
return this.api.marketplaceProxy<RR.GetMarketplaceInfoRes>(
'/package/v0/info',
qp,
url,
)
}),
)
fetchInfo$(url: string): Observable<T.RegistryInfo> {
return from(this.api.getRegistryInfo(url))
}
fetchReleaseNotes$(
id: string,
url?: string,
): Observable<Record<string, string>> {
return this.selectedHost$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<Record<string, string>>(
`/package/v0/release-notes/${id}`,
{},
url || m.url,
),
)
}),
)
}
fetchStatic$(id: string, type: string, url?: string): Observable<string> {
return this.selectedHost$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<string>(
`/package/v0/${type}/${id}`,
{},
url || m.url,
),
)
}),
)
fetchStatic$(
pkg: MarketplacePkg,
type: 'LICENSE.md' | 'instructions.md',
): Observable<string> {
return from(this.api.getStaticProxy(pkg, type))
}
private fetchStore$(url: string): Observable<StoreData | null> {
@@ -293,33 +260,57 @@ export class MarketplaceService implements AbstractMarketplaceService {
)
}
private fetchPackages$(
url: string,
params: Omit<RR.GetMarketplacePackagesReq, 'page' | 'per-page'> = {},
): Observable<MarketplacePkg[]> {
const qp: RR.GetMarketplacePackagesReq = {
...params,
page: 1,
perPage: 100,
}
if (qp.ids) qp.ids = JSON.stringify(qp.ids)
return from(
this.api.marketplaceProxy<RR.GetMarketplacePackagesRes>(
'/package/v0/index',
qp,
url,
),
private fetchPackages$(url: string): Observable<MarketplacePkg[]> {
return from(this.api.getRegistryPackages(url)).pipe(
map(packages => {
return Object.entries(packages).flatMap(([id, pkgInfo]) =>
Object.keys(pkgInfo.best).map(version =>
this.convertToMarketplacePkg(
id,
version,
this.exver.getFlavor(version),
pkgInfo,
),
),
)
}),
)
}
private fetchPackage$(
convertToMarketplacePkg(
id: string,
version: string,
version: string | null,
flavor: string | null,
pkgInfo: GetPackageRes,
): MarketplacePkg {
version =
version ||
Object.keys(pkgInfo.best).find(v => this.exver.getFlavor(v) === flavor) ||
null
return !version || !pkgInfo.best[version]
? ({} as MarketplacePkg)
: {
id,
version,
flavor,
...pkgInfo,
...pkgInfo.best[version],
}
}
private fetchPackage$(
url: string,
id: string,
version: string | null,
flavor: string | null,
): Observable<MarketplacePkg> {
return this.fetchPackages$(url, { ids: [{ id, version }] }).pipe(
map(pkgs => pkgs[0] || {}),
return from(
this.api.getRegistryPackage(url, id, version ? `=${version}` : null),
).pipe(
map(pkgInfo =>
this.convertToMarketplacePkg(id, version, flavor, pkgInfo),
),
)
}

View File

@@ -0,0 +1,22 @@
import { inject, Injectable } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { fromEvent, merge, Observable, shareReplay } from 'rxjs'
import { distinctUntilChanged, map, startWith } from 'rxjs/operators'
@Injectable({ providedIn: 'root' })
export class NetworkService extends Observable<boolean> {
private readonly win = inject(WINDOW)
private readonly stream$ = merge(
fromEvent(this.win, 'online'),
fromEvent(this.win, 'offline'),
).pipe(
startWith(null),
map(() => this.win.navigator.onLine),
distinctUntilChanged(),
shareReplay(1),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@angular/core'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { TuiDialogService } from '@taiga-ui/core'
import { filter, share, switchMap, take, tap, Observable } from 'rxjs'
import { filter, share, switchMap, take, Observable, map } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service'
@@ -11,21 +11,25 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
// Get data from PatchDb after is starts and act upon it
@Injectable({
providedIn: 'root',
})
export class PatchDataService extends Observable<DataModel> {
private readonly stream$ = this.connectionService.connected$.pipe(
export class PatchDataService extends Observable<void> {
private readonly stream$ = this.connection$.pipe(
filter(Boolean),
switchMap(() => this.patch.watch$()),
take(1),
tap(({ ui }) => {
// check for updates to eOS and services
this.checkForUpdates()
// show eos welcome message
this.showEosWelcome(ui.ackWelcome)
map((cache, index) => {
this.bootstrapper.update(cache)
if (index === 0) {
// check for updates to StartOS and services
this.checkForUpdates()
// show eos welcome message
this.showEosWelcome(cache.ui.ackWelcome)
}
}),
share(),
)
@@ -38,7 +42,8 @@ export class PatchDataService extends Observable<DataModel> {
private readonly embassyApi: ApiService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly connectionService: ConnectionService,
private readonly connection$: ConnectionService,
private readonly bootstrapper: LocalStorageBootstrap,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}

View File

@@ -134,6 +134,10 @@ export type PackageDataEntry<T extends StateInfo = StateInfo> =
nextBackup: string | null
}
export type AllPackageData = NonNullable<
T.AllPackageData & Record<string, PackageDataEntry<StateInfo>>
>
export type StateInfo = InstalledState | InstallingState | UpdatingState
export type InstalledState = {

View File

@@ -1,4 +1,4 @@
import { Bootstrapper, DBCache } from 'patch-db-client'
import { Dump } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Injectable } from '@angular/core'
import { StorageService } from '../storage.service'
@@ -6,20 +6,18 @@ import { StorageService } from '../storage.service'
@Injectable({
providedIn: 'root',
})
export class LocalStorageBootstrap implements Bootstrapper<DataModel> {
static CONTENT_KEY = 'patch-db-cache'
export class LocalStorageBootstrap {
static CONTENT_KEY = 'patchDB'
constructor(private readonly storage: StorageService) {}
init(): DBCache<DataModel> {
const cache = this.storage.get<DBCache<DataModel>>(
LocalStorageBootstrap.CONTENT_KEY,
)
init(): Dump<DataModel> {
const cache = this.storage.get<DataModel>(LocalStorageBootstrap.CONTENT_KEY)
return cache || { sequence: 0, data: {} as DataModel }
return cache ? { id: 1, value: cache } : { id: 0, value: {} as DataModel }
}
update(cache: DBCache<DataModel>): void {
update(cache: DataModel): void {
this.storage.set(LocalStorageBootstrap.CONTENT_KEY, cache)
}
}

View File

@@ -0,0 +1,58 @@
import { inject, Injectable, InjectionToken } from '@angular/core'
import { Dump, Revision, Update } from 'patch-db-client'
import { BehaviorSubject, EMPTY, Observable } from 'rxjs'
import {
bufferTime,
catchError,
filter,
skip,
startWith,
switchMap,
take,
} from 'rxjs/operators'
import { StateService } from 'src/app/services/state.service'
import { ApiService } from '../api/embassy-api.service'
import { AuthService } from '../auth.service'
import { DataModel } from './data-model'
import { LocalStorageBootstrap } from './local-storage-bootstrap'
export const PATCH_CACHE = new InjectionToken('', {
factory: () =>
new BehaviorSubject<Dump<DataModel>>({
id: 0,
value: {} as DataModel,
}),
})
@Injectable({
providedIn: 'root',
})
export class PatchDbSource extends Observable<Update<DataModel>[]> {
private readonly api = inject(ApiService)
private readonly state = inject(StateService)
private readonly stream$ = inject(AuthService).isVerified$.pipe(
switchMap(verified => (verified ? this.api.subscribeToPatchDB({}) : EMPTY)),
switchMap(({ dump, guid }) =>
this.api.openWebsocket$<Revision>(guid, {}).pipe(
bufferTime(250),
filter(revisions => !!revisions.length),
startWith([dump]),
),
),
catchError((_, original$) => {
this.state.retrigger()
return this.state.pipe(
skip(1), // skipping previous value stored due to shareReplay
filter(current => current === 'running'),
take(1),
switchMap(() => original$),
)
}),
startWith([inject(LocalStorageBootstrap).init()]),
)
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -1,61 +0,0 @@
import { InjectionToken, Injector } from '@angular/core'
import { Update } from 'patch-db-client'
import {
bufferTime,
catchError,
filter,
switchMap,
take,
tap,
defer,
EMPTY,
from,
interval,
Observable,
} from 'rxjs'
import { DataModel } from './data-model'
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
import { ApiService } from '../api/embassy-api.service'
import { ConfigService } from '../config.service'
export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>[]>>(
'',
)
export function sourceFactory(
injector: Injector,
): Observable<Update<DataModel>[]> {
// defer() needed to avoid circular dependency with ApiService, since PatchDB is needed there
return defer(() => {
const api = injector.get(ApiService)
const authService = injector.get(AuthService)
const connectionService = injector.get(ConnectionService)
const configService = injector.get(ConfigService)
const isTor = configService.isTor()
const timeout = isTor ? 16000 : 4000
const websocket$ = api.openPatchWebsocket$().pipe(
bufferTime(250),
filter(updates => !!updates.length),
catchError((_, watch$) => {
connectionService.websocketConnected$.next(false)
return interval(timeout).pipe(
switchMap(() =>
from(api.echo({ message: 'ping', timeout })).pipe(
catchError(() => EMPTY),
),
),
take(1),
switchMap(() => watch$),
)
}),
tap(() => connectionService.websocketConnected$.next(true)),
)
return authService.isVerified$.pipe(
switchMap(verified => (verified ? websocket$ : EMPTY)),
)
})
}

View File

@@ -3,7 +3,6 @@ import { tap, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { AuthService } from 'src/app/services/auth.service'
import { DataModel } from './patch-db/data-model'
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
// Start and stop PatchDb upon verification
@Injectable({
@@ -11,15 +10,12 @@ import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
})
export class PatchMonitorService extends Observable<unknown> {
private readonly stream$ = this.authService.isVerified$.pipe(
tap(verified =>
verified ? this.patch.start(this.bootstrapper) : this.patch.stop(),
),
tap(verified => (verified ? this.patch.start() : this.patch.stop())),
)
constructor(
private readonly authService: AuthService,
private readonly patch: PatchDB<DataModel>,
private readonly bootstrapper: LocalStorageBootstrap,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}

View File

@@ -0,0 +1,136 @@
import { inject, Injectable } from '@angular/core'
import { CanActivateFn, IsActiveMatchOptions, Router } from '@angular/router'
import { ALWAYS_TRUE_HANDLER } from '@taiga-ui/cdk'
import { TuiAlertService, TuiNotification } from '@taiga-ui/core'
import {
BehaviorSubject,
combineLatest,
concat,
EMPTY,
exhaustMap,
from,
merge,
Observable,
startWith,
Subject,
timer,
} from 'rxjs'
import {
catchError,
filter,
map,
shareReplay,
skip,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { NetworkService } from 'src/app/services/network.service'
const OPTIONS: IsActiveMatchOptions = {
paths: 'subset',
queryParams: 'exact',
fragment: 'ignored',
matrixParams: 'ignored',
}
@Injectable({
providedIn: 'root',
})
export class StateService extends Observable<RR.ServerState | null> {
private readonly alerts = inject(TuiAlertService)
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly network$ = inject(NetworkService)
private readonly single$ = new Subject<RR.ServerState>()
private readonly trigger$ = new BehaviorSubject<void>(undefined)
private readonly poll$ = this.trigger$.pipe(
switchMap(() =>
timer(0, 2000).pipe(
switchMap(() =>
from(this.api.getState()).pipe(catchError(() => EMPTY)),
),
take(1),
),
),
)
private readonly stream$ = merge(this.single$, this.poll$).pipe(
tap(state => {
switch (state) {
case 'initializing':
this.router.navigate(['initializing'], { replaceUrl: true })
break
case 'error':
this.router.navigate(['diagnostic'], { replaceUrl: true })
break
case 'running':
if (
this.router.isActive('initializing', OPTIONS) ||
this.router.isActive('diagnostic', OPTIONS)
) {
this.router.navigate([''], { replaceUrl: true })
}
break
}
}),
startWith(null),
shareReplay(1),
)
private readonly alert = merge(
this.trigger$.pipe(skip(1)),
this.network$.pipe(filter(v => !v)),
)
.pipe(
exhaustMap(() =>
concat(
this.alerts
.open('Trying to reach server', {
label: 'State unknown',
autoClose: false,
status: TuiNotification.Error,
})
.pipe(
takeUntil(
combineLatest([this.stream$, this.network$]).pipe(
filter(state => state.every(Boolean)),
),
),
),
this.alerts.open('Connection restored', {
label: 'Server reached',
status: TuiNotification.Success,
}),
),
),
)
.subscribe()
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
}
retrigger() {
this.trigger$.next()
}
async syncState() {
const state = await this.api.getState()
this.single$.next(state)
}
}
export function stateNot(state: RR.ServerState[]): CanActivateFn {
return () =>
inject(StateService).pipe(
filter(current => !current || !state.includes(current)),
map(ALWAYS_TRUE_HANDLER),
)
}

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT } from '@angular/common'
const PREFIX = '_embassystorage/_embassykv/'
const PREFIX = '_startos/'
@Injectable({
providedIn: 'root',
@@ -15,16 +15,21 @@ export class StorageService {
return JSON.parse(String(this.storage.getItem(`${PREFIX}${key}`)))
}
set<T>(key: string, value: T) {
set(key: string, value: any) {
this.storage.setItem(`${PREFIX}${key}`, JSON.stringify(value))
}
clear() {
Array.from(
{ length: this.storage.length },
(_, i) => this.storage.key(i) || '',
)
.filter(key => key.startsWith(PREFIX))
.forEach(key => this.storage.removeItem(key))
this.storage.clear()
}
migrate036() {
const oldPrefix = '_embassystorage/_embassykv/'
if (!!this.storage.getItem(`${oldPrefix}loggedInKey`)) {
const cache = this.storage.getItem(`${oldPrefix}patch-db-cache`)
this.clear()
this.set('loggedIn', true)
this.set('patchDB', cache)
}
}
}