feat: enable strictNullChecks

feat: enable `noImplicitAny`

chore: remove sync data access

fix loading package data for affected dependencies

chore: properly get alt marketplace data

update patchdb client to allow for emit on undefined values
This commit is contained in:
waterplea
2022-05-26 18:20:31 +03:00
committed by Lucy C
parent 948fb795f2
commit 0390954a85
99 changed files with 674 additions and 535 deletions

View File

@@ -23,40 +23,47 @@ export class LogsPage {
scrollToBottomButton = false scrollToBottomButton = false
isOnBottom = true isOnBottom = true
constructor ( constructor(private readonly api: ApiService) {}
private readonly api: ApiService,
) { }
ngOnInit () { ngOnInit() {
this.getLogs() this.getLogs()
} }
async getLogs () { async getLogs() {
try { try {
// get logs // get logs
const logs = await this.fetch() const logs = await this.fetch()
if (!logs.length) return
if (!logs?.length) return
const container = document.getElementById('container') const container = document.getElementById('container')
const beforeContainerHeight = container.scrollHeight const beforeContainerHeight = container?.scrollHeight || 0
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement const newLogs = document.getElementById('template')?.cloneNode(true)
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
container.prepend(newLogs) if (!(newLogs instanceof HTMLElement)) return
const afterContainerHeight = container.scrollHeight
newLogs.innerHTML =
logs
.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`)
.join('\n') + (logs.length ? '\n' : '')
container?.prepend(newLogs)
const afterContainerHeight = container?.scrollHeight || 0
// scroll down // scroll down
scrollBy(0, afterContainerHeight - beforeContainerHeight) scrollBy(0, afterContainerHeight - beforeContainerHeight)
this.content.scrollToPoint(0, afterContainerHeight - beforeContainerHeight) this.content.scrollToPoint(
0,
afterContainerHeight - beforeContainerHeight,
)
if (logs.length < this.limit) { if (logs.length < this.limit) {
this.needInfinite = false this.needInfinite = false
} }
} catch (e) {}
} catch (e) { }
} }
async fetch (isBefore: boolean = true) { async fetch(isBefore: boolean = true) {
try { try {
const cursor = isBefore ? this.startCursor : this.endCursor const cursor = isBefore ? this.startCursor : this.endCursor
@@ -81,33 +88,40 @@ export class LogsPage {
} }
} }
async loadMore () { async loadMore() {
try { try {
this.loadingMore = true this.loadingMore = true
const logs = await this.fetch(false) const logs = await this.fetch(false)
if (!logs.length) return this.loadingMore = false
if (!logs?.length) return (this.loadingMore = false)
const container = document.getElementById('container') const container = document.getElementById('container')
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement const newLogs = document.getElementById('template')?.cloneNode(true)
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
container.append(newLogs) if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML =
logs
.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`)
.join('\n') + (logs.length ? '\n' : '')
container?.append(newLogs)
this.loadingMore = false this.loadingMore = false
this.scrollEvent() this.scrollEvent()
} catch (e) { } } catch (e) {}
} }
scrollEvent () { scrollEvent() {
const buttonDiv = document.getElementById('button-div') const buttonDiv = document.getElementById('button-div')
this.isOnBottom = buttonDiv.getBoundingClientRect().top < window.innerHeight this.isOnBottom =
!!buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
} }
scrollToBottom () { scrollToBottom() {
this.content.scrollToBottom(500) this.content.scrollToBottom(500)
} }
async loadData (e: any): Promise<void> { async loadData(e: any): Promise<void> {
await this.getLogs() await this.getLogs()
e.target.complete() e.target.complete()
} }
} }

View File

@@ -25,17 +25,14 @@ export class MockApiService extends ApiService {
async restart(): Promise<void> { async restart(): Promise<void> {
await pauseFor(1000) await pauseFor(1000)
return null
} }
async forgetDrive(): Promise<void> { async forgetDrive(): Promise<void> {
await pauseFor(1000) await pauseFor(1000)
return null
} }
async repairDisk(): Promise<void> { async repairDisk(): Promise<void> {
await pauseFor(1000) await pauseFor(1000)
return null
} }
async getLogs(params: GetLogsReq): Promise<GetLogsRes> { async getLogs(params: GetLogsReq): Promise<GetLogsRes> {

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { HttpClient, HttpErrorResponse } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { HttpError, RpcError } from '@start9labs/shared' import { HttpError, RpcError } from '@start9labs/shared'
@Injectable({ @Injectable({
@@ -12,6 +12,8 @@ export class HttpService {
const res = await this.httpRequest<RPCResponse<T>>(options) const res = await this.httpRequest<RPCResponse<T>>(options)
if (isRpcError(res)) throw new RpcError(res.error) if (isRpcError(res)) throw new RpcError(res.error)
if (isRpcSuccess(res)) return res.result if (isRpcSuccess(res)) return res.result
throw new Error('Unknown RPC response')
} }
async httpRequest<T>(body: RPCOptions): Promise<T> { async httpRequest<T>(body: RPCOptions): Promise<T> {

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, ElementRef } from '@angular/core' import { ChangeDetectionStrategy, Component, ElementRef } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { AbstractMarketplaceService } from '../../services/marketplace.service' import { AbstractMarketplaceService } from '../../services/marketplace.service'
@Component({ @Component({
@@ -9,7 +10,7 @@ import { AbstractMarketplaceService } from '../../services/marketplace.service'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ReleaseNotesComponent { export class ReleaseNotesComponent {
private readonly pkgId = this.route.snapshot.paramMap.get('pkgId') private readonly pkgId = getPkgId(this.route)
private selected: string | null = null private selected: string | null = null

View File

@@ -34,7 +34,7 @@ export class AdditionalComponent {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Versions', header: 'Versions',
inputs: this.pkg.versions inputs: this.pkg.versions
.sort((a, b) => -1 * this.emver.compare(a, b)) .sort((a, b) => -1 * (this.emver.compare(a, b) || 0))
.map(v => ({ .map(v => ({
name: v, // for CSS name: v, // for CSS
type: 'radio', type: 'radio',

View File

@@ -1,5 +1,5 @@
import { NgModule, Pipe, PipeTransform } from '@angular/core' import { NgModule, Pipe, PipeTransform } from '@angular/core'
import Fuse from 'fuse.js/dist/fuse.min.js' import Fuse from 'fuse.js'
import { MarketplacePkg } from '../types/marketplace-pkg' import { MarketplacePkg } from '../types/marketplace-pkg'
import { MarketplaceManifest } from '../types/marketplace-manifest' import { MarketplaceManifest } from '../types/marketplace-manifest'

View File

@@ -15,5 +15,8 @@ export abstract class AbstractMarketplaceService {
abstract getPackageMarkdown(type: string, pkgId: string): Observable<string> abstract getPackageMarkdown(type: string, pkgId: string): Observable<string>
abstract getPackage(id: string, version: string): Observable<MarketplacePkg> abstract getPackage(
id: string,
version: string,
): Observable<MarketplacePkg | null>
} }

View File

@@ -1,6 +1,10 @@
import { Component, Input, ViewChild } from '@angular/core' import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, ModalController } from '@ionic/angular' import { IonInput, ModalController } from '@ionic/angular'
import { DiskInfo, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.service' import {
DiskInfo,
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.service'
import * as argon2 from '@start9labs/argon2' import * as argon2 from '@start9labs/argon2'
@Component({ @Component({
@@ -21,26 +25,27 @@ export class PasswordPage {
passwordVer = '' passwordVer = ''
unmasked2 = false unmasked2 = false
constructor ( constructor(private modalController: ModalController) {}
private modalController: ModalController,
) { }
ngAfterViewInit () { ngAfterViewInit() {
setTimeout(() => this.elem.setFocus(), 400) setTimeout(() => this.elem.setFocus(), 400)
} }
async verifyPw () { async verifyPw() {
if (!this.target || !this.target['embassy-os']) this.pwError = 'No recovery target' // unreachable if (!this.target || !this.target['embassy-os'])
this.pwError = 'No recovery target' // unreachable
try { try {
argon2.verify(this.target['embassy-os']['password-hash'], this.password) const passwordHash = this.target['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, this.password)
this.modalController.dismiss({ password: this.password }, 'success') this.modalController.dismiss({ password: this.password }, 'success')
} catch (e) { } catch (e) {
this.pwError = 'Incorrect password provided' this.pwError = 'Incorrect password provided'
} }
} }
async submitPw () { async submitPw() {
this.validate() this.validate()
if (this.password !== this.passwordVer) { if (this.password !== this.passwordVer) {
this.verError = '*passwords do not match' this.verError = '*passwords do not match'
@@ -50,8 +55,8 @@ export class PasswordPage {
this.modalController.dismiss({ password: this.password }, 'success') this.modalController.dismiss({ password: this.password }, 'success')
} }
validate () { validate() {
if (!!this.target) return this.pwError = '' if (!!this.target) return (this.pwError = '')
if (this.passwordVer) { if (this.passwordVer) {
this.checkVer() this.checkVer()
@@ -64,11 +69,12 @@ export class PasswordPage {
} }
} }
checkVer () { checkVer() {
this.verError = this.password !== this.passwordVer ? 'Passwords do not match' : '' this.verError =
this.password !== this.passwordVer ? 'Passwords do not match' : ''
} }
cancel () { cancel() {
this.modalController.dismiss() this.modalController.dismiss()
} }
} }

View File

@@ -16,19 +16,19 @@ export class ProdKeyModal {
productKey = '' productKey = ''
unmasked = false unmasked = false
constructor ( constructor(
private readonly modalController: ModalController, private readonly modalController: ModalController,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly loadingCtrl: LoadingController, private readonly loadingCtrl: LoadingController,
private readonly httpService: HttpService, private readonly httpService: HttpService,
) { } ) {}
ngAfterViewInit () { ngAfterViewInit() {
setTimeout(() => this.elem.setFocus(), 400) setTimeout(() => this.elem.setFocus(), 400)
} }
async verifyProductKey () { async verifyProductKey() {
if (!this.productKey) return if (!this.productKey || !this.target.logicalname) return
const loader = await this.loadingCtrl.create({ const loader = await this.loadingCtrl.create({
message: 'Verifying Product Key', message: 'Verifying Product Key',
@@ -48,7 +48,7 @@ export class ProdKeyModal {
} }
} }
cancel () { cancel() {
this.modalController.dismiss() this.modalController.dismiss()
} }
} }

View File

@@ -68,8 +68,8 @@ export class RecoverPage {
'embassy-os': p['embassy-os'], 'embassy-os': p['embassy-os'],
} }
this.mappedDrives.push({ this.mappedDrives.push({
hasValidBackup: p['embassy-os']?.full, hasValidBackup: !!p['embassy-os']?.full,
is02x: drive['embassy-os']?.version.startsWith('0.2'), is02x: !!drive['embassy-os']?.version.startsWith('0.2'),
drive, drive,
}) })
}) })
@@ -111,7 +111,8 @@ export class RecoverPage {
{ {
text: 'Use Drive', text: 'Use Drive',
handler: async () => { handler: async () => {
await this.importDrive(importableDrive.guid) if (importableDrive.guid)
await this.importDrive(importableDrive.guid)
}, },
}, },
], ],
@@ -148,11 +149,14 @@ export class RecoverPage {
} }
async select(target: DiskBackupTarget) { async select(target: DiskBackupTarget) {
const is02x = target['embassy-os'].version.startsWith('0.2') const is02x = target['embassy-os']?.version.startsWith('0.2')
const { logicalname } = target
if (!logicalname) return
if (this.stateService.hasProductKey) { if (this.stateService.hasProductKey) {
if (is02x) { if (is02x) {
this.selectRecoverySource(target.logicalname) this.selectRecoverySource(logicalname)
} else { } else {
const modal = await this.modalController.create({ const modal = await this.modalController.create({
component: PasswordPage, component: PasswordPage,
@@ -160,8 +164,8 @@ export class RecoverPage {
cssClass: 'alertlike-modal', cssClass: 'alertlike-modal',
}) })
modal.onDidDismiss().then(res => { modal.onDidDismiss().then(res => {
if (res.data && res.data.password) { if (res.data?.password) {
this.selectRecoverySource(target.logicalname, res.data.password) this.selectRecoverySource(logicalname, res.data.password)
} }
}) })
await modal.present() await modal.present()
@@ -188,8 +192,8 @@ export class RecoverPage {
cssClass: 'alertlike-modal', cssClass: 'alertlike-modal',
}) })
modal.onDidDismiss().then(res => { modal.onDidDismiss().then(res => {
if (res.data && res.data.productKey) { if (res.data?.productKey) {
this.selectRecoverySource(target.logicalname) this.selectRecoverySource(logicalname)
} }
}) })
await modal.present() await modal.present()

View File

@@ -24,7 +24,7 @@ export class SuccessPage {
await this.stateService.completeEmbassy() await this.stateService.completeEmbassy()
document document
.getElementById('install-cert') .getElementById('install-cert')
.setAttribute( ?.setAttribute(
'href', 'href',
'data:application/x-x509-ca-cert;base64,' + 'data:application/x-x509-ca-cert;base64,' +
encodeURIComponent(this.stateService.cert), encodeURIComponent(this.stateService.cert),
@@ -56,20 +56,24 @@ export class SuccessPage {
} }
installCert() { installCert() {
document.getElementById('install-cert').click() document.getElementById('install-cert')?.click()
} }
download() { download() {
document.getElementById('tor-addr').innerHTML = this.stateService.torAddress const torAddress = document.getElementById('tor-addr')
document.getElementById('lan-addr').innerHTML = this.stateService.lanAddress const lanAddress = document.getElementById('lan-addr')
if (torAddress) torAddress.innerHTML = this.stateService.torAddress
if (lanAddress) lanAddress.innerHTML = this.stateService.lanAddress
document document
.getElementById('cert') .getElementById('cert')
.setAttribute( ?.setAttribute(
'href', 'href',
'data:application/x-x509-ca-cert;base64,' + 'data:application/x-x509-ca-cert;base64,' +
encodeURIComponent(this.stateService.cert), encodeURIComponent(this.stateService.cert),
) )
let html = document.getElementById('downloadable').innerHTML let html = document.getElementById('downloadable')?.innerHTML || ''
const filename = 'embassy-info.html' const filename = 'embassy-info.html'
const elem = document.createElement('a') const elem = document.createElement('a')

View File

@@ -15,7 +15,7 @@ import { HttpError, RpcError } from '@start9labs/shared'
}) })
export class HttpService { export class HttpService {
fullUrl: string fullUrl: string
productKey: string productKey?: string
constructor(private readonly http: HttpClient) { constructor(private readonly http: HttpClient) {
const port = window.location.port const port = window.location.port
@@ -43,6 +43,8 @@ export class HttpService {
} }
if (isRpcSuccess(res)) return res.result if (isRpcSuccess(res)) return res.result
throw new Error('Unknown RPC response')
} }
async encryptedHttpRequest<T>(httpOpts: { async encryptedHttpRequest<T>(httpOpts: {
@@ -53,7 +55,7 @@ export class HttpService {
const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url
const encryptedBody = await AES_CTR.encryptPbkdf2( const encryptedBody = await AES_CTR.encryptPbkdf2(
this.productKey, this.productKey || '',
encodeUtf8(JSON.stringify(httpOpts.body)), encodeUtf8(JSON.stringify(httpOpts.body)),
) )
const options = { const options = {
@@ -74,7 +76,7 @@ export class HttpService {
.toPromise() .toPromise()
.then(res => .then(res =>
AES_CTR.decryptPbkdf2( AES_CTR.decryptPbkdf2(
this.productKey, this.productKey || '',
(res as any).body as ArrayBuffer, (res as any).body as ArrayBuffer,
), ),
) )
@@ -206,7 +208,7 @@ type AES_CTR = {
secretKey: string, secretKey: string,
messageBuffer: Uint8Array, messageBuffer: Uint8Array,
) => Promise<Uint8Array> ) => Promise<Uint8Array>
decryptPbkdf2: (secretKey, arr: ArrayBuffer) => Promise<string> decryptPbkdf2: (secretKey: string, arr: ArrayBuffer) => Promise<string>
} }
export const AES_CTR: AES_CTR = { export const AES_CTR: AES_CTR = {
@@ -243,8 +245,10 @@ export const AES_CTR: AES_CTR = {
export const encode16 = (buffer: Uint8Array) => export const encode16 = (buffer: Uint8Array) =>
buffer.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '') buffer.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
export const decode16 = hexString => export const decode16 = (hexString: string) =>
new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16))) new Uint8Array(
hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || [],
)
export function encodeUtf8(str: string): Uint8Array { export function encodeUtf8(str: string): Uint8Array {
const encoder = new TextEncoder() const encoder = new TextEncoder()

View File

@@ -43,7 +43,7 @@ export class LiveApiService extends ApiService {
) )
} }
async set02XDrive(logicalname) { async set02XDrive(logicalname: string) {
return this.http.rpcRequest<void>( return this.http.rpcRequest<void>(
{ {
method: 'setup.recovery.v2.set', method: 'setup.recovery.v2.set',
@@ -124,7 +124,7 @@ export class LiveApiService extends ApiService {
} }
function isCifsSource( function isCifsSource(
source: CifsRecoverySource | DiskRecoverySource | undefined, source: CifsRecoverySource | DiskRecoverySource | null,
): source is CifsRecoverySource { ): source is CifsRecoverySource {
return !!(source as CifsRecoverySource)?.hostname return !!(source as CifsRecoverySource)?.hostname
} }

View File

@@ -18,7 +18,7 @@ export class StateService {
embassyLoaded = false embassyLoaded = false
recoverySource: CifsRecoverySource | DiskRecoverySource recoverySource: CifsRecoverySource | DiskRecoverySource
recoveryPassword: string recoveryPassword?: string
dataTransferProgress: { dataTransferProgress: {
bytesTransferred: number bytesTransferred: number

View File

@@ -4,6 +4,7 @@
"peerDependencies": { "peerDependencies": {
"@angular/common": "^13.2.0", "@angular/common": "^13.2.0",
"@angular/core": "^13.2.0", "@angular/core": "^13.2.0",
"@angular/router": "^13.2.0",
"@ionic/angular": "^6.0.3", "@ionic/angular": "^6.0.3",
"@start9labs/emver": "^0.1.5" "@start9labs/emver": "^0.1.5"
}, },

View File

@@ -12,7 +12,7 @@ export class RpcError<T> {
return `${this.error.message}\n\n${this.error.data}` return `${this.error.message}\n\n${this.error.data}`
} }
return this.error.data.details return this.error.data?.details
? `${this.error.message}\n\n${this.error.data.details}` ? `${this.error.message}\n\n${this.error.data.details}`
: this.error.message : this.error.message
} }
@@ -20,6 +20,6 @@ export class RpcError<T> {
private getRevision(): T | null { private getRevision(): T | null {
return typeof this.error.data === 'string' return typeof this.error.data === 'string'
? null ? null
: this.error.data.revision || null : this.error.data?.revision || null
} }
} }

View File

@@ -7,8 +7,12 @@ import { Emver } from '../../services/emver.service'
export class EmverSatisfiesPipe implements PipeTransform { export class EmverSatisfiesPipe implements PipeTransform {
constructor(private readonly emver: Emver) {} constructor(private readonly emver: Emver) {}
transform(versionUnderTest: string, range: string): boolean { transform(versionUnderTest?: string, range?: string): boolean {
return this.emver.satisfies(versionUnderTest, range) return (
!!versionUnderTest &&
!!range &&
this.emver.satisfies(versionUnderTest, range)
)
} }
} }

View File

@@ -19,18 +19,17 @@ export class ConvertBytesPipe implements PipeTransform {
name: 'durationToSeconds', name: 'durationToSeconds',
}) })
export class DurationToSecondsPipe implements PipeTransform { export class DurationToSecondsPipe implements PipeTransform {
transform(duration: string | null): number { transform(duration?: string | null): number {
if (!duration) return 0 if (!duration) return 0
const splitUnit = duration.match(/^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/) const [, num, , unit] =
const unit = splitUnit[3] duration.match(/^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/) || []
const num = splitUnit[1]
return Number(num) * unitsToSeconds[unit] return Number(num) * unitsToSeconds[unit]
} }
} }
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const unitsToSeconds = { const unitsToSeconds: Record<string, number> = {
ns: 1e-9, ns: 1e-9,
µs: 1e-6, µs: 1e-6,
ms: 0.001, ms: 0.001,

View File

@@ -34,5 +34,6 @@ export * from './types/rpc-error-details'
export * from './types/url' export * from './types/url'
export * from './types/workspace-config' export * from './types/workspace-config'
export * from './util/get-pkg-id'
export * from './util/misc.util' export * from './util/misc.util'
export * from './util/unused' export * from './util/unused'

View File

@@ -7,7 +7,7 @@ import * as emver from '@start9labs/emver'
export class Emver { export class Emver {
constructor() {} constructor() {}
compare(lhs: string, rhs: string): number { compare(lhs: string, rhs: string): number | null {
if (!lhs || !rhs) return null if (!lhs || !rhs) return null
return emver.compare(lhs, rhs) return emver.compare(lhs, rhs)
} }

View File

@@ -5,7 +5,7 @@ import { IonicSafeString, ToastController } from '@ionic/angular'
providedIn: 'root', providedIn: 'root',
}) })
export class ErrorToastService { export class ErrorToastService {
private toast: HTMLIonToastElement private toast?: HTMLIonToastElement
constructor(private readonly toastCtrl: ToastController) {} constructor(private readonly toastCtrl: ToastController) {}

View File

@@ -0,0 +1,11 @@
import { ActivatedRoute } from '@angular/router'
export function getPkgId({ snapshot }: ActivatedRoute): string {
const pkgId = snapshot.paramMap.get('pkgId')
if (!pkgId) {
throw new Error('pkgId is missing from route params')
}
return pkgId
}

View File

@@ -28,7 +28,7 @@ export function debounce(delay: number = 300): MethodDecorator {
const original = descriptor.value const original = descriptor.value
descriptor.value = function (...args) { descriptor.value = function (this: any, ...args: any[]) {
clearTimeout(this[timeoutKey]) clearTimeout(this[timeoutKey])
this[timeoutKey] = setTimeout(() => original.apply(this, args), delay) this[timeoutKey] = setTimeout(() => original.apply(this, args), delay)
} }

View File

@@ -33,7 +33,7 @@ export function traceThrowDesc<T>(description: string, t: T | undefined): T {
export function inMs( export function inMs(
count: number, count: number,
unit: 'days' | 'hours' | 'minutes' | 'seconds', unit: 'days' | 'hours' | 'minutes' | 'seconds',
) { ): number {
switch (unit) { switch (unit) {
case 'seconds': case 'seconds':
return count * 1000 return count * 1000
@@ -63,31 +63,6 @@ export function toObject<T>(t: T[], map: (t0: T) => string): Record<string, T> {
}, {} as Record<string, T>) }, {} as Record<string, T>)
} }
export function deepCloneUnknown<T>(value: T): T {
if (typeof value !== 'object' || value === null) {
return value
}
if (Array.isArray(value)) {
return deepCloneArray(value)
}
return deepCloneObject(value)
}
export function deepCloneObject<T>(source: T) {
const result = {}
Object.keys(source).forEach(key => {
const value = source[key]
result[key] = deepCloneUnknown(value)
}, {})
return result as T
}
export function deepCloneArray(collection: any) {
return collection.map(value => {
return deepCloneUnknown(value)
})
}
export function partitionArray<T>( export function partitionArray<T>(
ts: T[], ts: T[],
condition: (t: T) => boolean, condition: (t: T) => boolean,
@@ -110,21 +85,3 @@ export function update<T>(
): Record<string, T> { ): Record<string, T> {
return { ...t, ...u } return { ...t, ...u }
} }
export function uniqueBy<T>(
ts: T[],
uniqueBy: (t: T) => string,
prioritize: (t1: T, t2: T) => T,
) {
return Object.values(
ts.reduce((acc, next) => {
const previousValue = acc[uniqueBy(next)]
if (previousValue) {
acc[uniqueBy(next)] = prioritize(acc[uniqueBy(next)], previousValue)
} else {
acc[uniqueBy(next)] = previousValue
}
return acc
}, {}),
)
}

View File

@@ -30,7 +30,7 @@ export class FooterComponent {
getProgress({ getProgress({
downloaded, downloaded,
size, size,
}: ServerInfo['status-info']['update-progress']): number { }: NonNullable<ServerInfo['status-info']['update-progress']>): number {
return Math.round((100 * (downloaded || 1)) / (size || 1)) return Math.round((100 * (downloaded || 1)) / (size || 1))
} }
} }

View File

@@ -83,6 +83,8 @@ function getMessage(failure: ConnectionFailure): OfflineMessage {
message: 'Embassy not found on Local Area Network.', message: 'Embassy not found on Local Area Network.',
link: 'https://start9.com/latest/support/common-issues', link: 'https://start9.com/latest/support/common-issues',
} }
default:
return { message: '' }
} }
} }

View File

@@ -5,7 +5,6 @@ import { mapTo, share, switchMap } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { AuthService } from 'src/app/services/auth.service' import { AuthService } from 'src/app/services/auth.service'
import { ConnectionService } from 'src/app/services/connection.service'
// Start and stop PatchDb upon verification // Start and stop PatchDb upon verification
@Injectable({ @Injectable({
@@ -27,7 +26,6 @@ export class PatchMonitorService extends Observable<boolean> {
) )
constructor( constructor(
private readonly connectionService: ConnectionService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
private readonly storage: Storage, private readonly storage: Storage,

View File

@@ -51,7 +51,7 @@ export class UnreadToastService extends Observable<unknown> {
await this.unreadToast?.dismiss() await this.unreadToast?.dismiss()
this.unreadToast = await this.toastCtrl.create(TOAST) this.unreadToast = await this.toastCtrl.create(TOAST)
this.unreadToast.buttons.push({ this.unreadToast.buttons?.push({
side: 'end', side: 'end',
text: 'View', text: 'View',
handler: () => { handler: () => {

View File

@@ -49,7 +49,7 @@ export class UpdateToastService extends Observable<unknown> {
await this.updateToast?.dismiss() await this.updateToast?.dismiss()
this.updateToast = await this.toastCtrl.create(TOAST) this.updateToast = await this.toastCtrl.create(TOAST)
this.updateToast.buttons.push({ this.updateToast.buttons?.push({
side: 'end', side: 'end',
text: 'Restart', text: 'Restart',
handler: () => { handler: () => {

View File

@@ -26,13 +26,15 @@
{{ page.title }} {{ page.title }}
</ion-label> </ion-label>
<ion-icon <ion-icon
*ngIf="page.url === '/embassy' && eosService.updateAvailable$ | async" *ngIf="page.url === '/embassy' && (eosService.updateAvailable$ | async)"
color="success" color="success"
size="small" size="small"
name="rocket-outline" name="rocket-outline"
></ion-icon> ></ion-icon>
<ion-badge <ion-badge
*ngIf="page.url === '/notifications' && notification$ | async as count" *ngIf="
page.url === '/notifications' && (notification$ | async) as count
"
color="danger" color="danger"
class="badge" class="badge"
> >

View File

@@ -1,12 +1,10 @@
<div [hidden]="!control.dirty && !control.touched" class="validation-error"> <div [hidden]="!control.dirty && !control.touched" class="validation-error">
<!-- primitive --> <!-- primitive -->
<p *ngIf="control.hasError('required')"> <p *ngIf="control.hasError('required')">{{ spec.name }} is required</p>
{{ spec.name }} is required
</p>
<!-- string --> <!-- string -->
<p *ngIf="control.hasError('pattern')"> <p *ngIf="control.hasError('pattern')">
{{ spec['pattern-description'] }} {{ $any(spec)['pattern-description'] }}
</p> </p>
<!-- number --> <!-- number -->
@@ -15,7 +13,7 @@
{{ spec.name }} must be an integer {{ spec.name }} must be an integer
</p> </p>
<p *ngIf="control.hasError('numberNotInRange')"> <p *ngIf="control.hasError('numberNotInRange')">
{{ control.errors['numberNotInRange'].value }} {{ control.errors?.['numberNotInRange']?.value }}
</p> </p>
<p *ngIf="control.hasError('notNumber')"> <p *ngIf="control.hasError('notNumber')">
{{ spec.name }} must be a number {{ spec.name }} must be a number
@@ -25,13 +23,13 @@
<!-- list --> <!-- list -->
<ng-container *ngIf="spec.type === 'list'"> <ng-container *ngIf="spec.type === 'list'">
<p *ngIf="control.hasError('listNotInRange')"> <p *ngIf="control.hasError('listNotInRange')">
{{ control.errors['listNotInRange'].value }} {{ control.errors?.['listNotInRange']?.value }}
</p> </p>
<p *ngIf="control.hasError('listNotUnique')"> <p *ngIf="control.hasError('listNotUnique')">
{{ control.errors['listNotUnique'].value }} {{ control.errors?.['listNotUnique']?.value }}
</p> </p>
<p *ngIf="control.hasError('listItemIssue')"> <p *ngIf="control.hasError('listItemIssue')">
{{ control.errors['listItemIssue'].value }} {{ control.errors?.['listItemIssue']?.value }}
</p> </p>
</ng-container> </ng-container>
</div> </div>

View File

@@ -51,7 +51,7 @@
<form-label <form-label
[data]="{ [data]="{
spec: spec, spec: spec,
new: current && current[entry.key] === undefined, new: !!current && current[entry.key] === undefined,
edited: entry.value.dirty edited: entry.value.dirty
}" }"
></form-label> ></form-label>
@@ -129,7 +129,7 @@
<!-- enum --> <!-- enum -->
<!-- class enter-click disables the enter click on the modal behind the select --> <!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select <ion-select
*ngIf="spec.type === 'enum'" *ngIf="spec.type === 'enum' && formGroup.get(entry.key) as control"
[interfaceOptions]="{ [interfaceOptions]="{
message: getWarningText(spec.warning), message: getWarningText(spec.warning),
cssClass: 'enter-click' cssClass: 'enter-click'
@@ -137,7 +137,7 @@
slot="end" slot="end"
placeholder="Select" placeholder="Select"
[formControlName]="entry.key" [formControlName]="entry.key"
[selectedText]="spec['value-names'][formGroup.get(entry.key).value]" [selectedText]="spec['value-names'][control.value]"
> >
<ion-select-option <ion-select-option
*ngFor="let option of spec.values" *ngFor="let option of spec.values"
@@ -157,7 +157,7 @@
<form-label <form-label
[data]="{ [data]="{
spec: spec, spec: spec,
new: current && current[entry.key] === undefined, new: !!current && current[entry.key] === undefined,
edited: entry.value.dirty edited: entry.value.dirty
}" }"
></form-label> ></form-label>
@@ -208,7 +208,7 @@
<form-label <form-label
[data]="{ [data]="{
spec: spec, spec: spec,
new: current && current[entry.key] === undefined, new: !!current && current[entry.key] === undefined,
edited: entry.value.dirty edited: entry.value.dirty
}" }"
></form-label> ></form-label>
@@ -281,16 +281,12 @@
: $any(spec.spec).spec : $any(spec.spec).spec
" "
[formGroup]="abstractControl" [formGroup]="abstractControl"
[current]=" [current]="current?.[entry.key]?.[i]"
current && current[entry.key]
? current[entry.key][i]
: undefined
"
[unionSpec]=" [unionSpec]="
spec.subtype === 'union' ? $any(spec.spec) : undefined spec.subtype === 'union' ? $any(spec.spec) : undefined
" "
(onInputChange)=" (onInputChange)="
updateLabel(entry.key, i, spec.spec['display-as']) updateLabel(entry.key, i, $any(spec.spec)['display-as'])
" "
(onExpand)="resize(entry.key, i)" (onExpand)="resize(entry.key, i)"
></form-object> ></form-object>
@@ -350,7 +346,7 @@
<form-label <form-label
[data]="{ [data]="{
spec: spec, spec: spec,
new: current && current[entry.key] === undefined, new: !!current && current[entry.key] === undefined,
edited: entry.value.dirty edited: entry.value.dirty
}" }"
></form-label> ></form-label>
@@ -372,7 +368,7 @@
</ng-container> </ng-container>
</ng-container> </ng-container>
<form-error <form-error
*ngIf="formGroup.get(entry.key).errors" *ngIf="formGroup.get(entry.key)?.errors"
[control]="$any(formGroup.get(entry.key))" [control]="$any(formGroup.get(entry.key))"
[spec]="spec" [spec]="spec"
> >

View File

@@ -34,8 +34,8 @@ const Mustache = require('mustache')
export class FormObjectComponent { export class FormObjectComponent {
@Input() objectSpec: ConfigSpec @Input() objectSpec: ConfigSpec
@Input() formGroup: FormGroup @Input() formGroup: FormGroup
@Input() unionSpec: ValueSpecUnion @Input() unionSpec?: ValueSpecUnion
@Input() current: { [key: string]: any } @Input() current?: { [key: string]: any }
@Input() showEdited: boolean = false @Input() showEdited: boolean = false
@Output() onInputChange = new EventEmitter<void>() @Output() onInputChange = new EventEmitter<void>()
@Output() onExpand = new EventEmitter<void>() @Output() onExpand = new EventEmitter<void>()
@@ -61,7 +61,7 @@ export class FormObjectComponent {
if (spec.type === 'list' && ['object', 'union'].includes(spec.subtype)) { if (spec.type === 'list' && ['object', 'union'].includes(spec.subtype)) {
this.objectListDisplay[key] = [] this.objectListDisplay[key] = []
this.formGroup.get(key).value.forEach((obj, index) => { this.formGroup.get(key)?.value.forEach((obj: any, index: number) => {
const displayAs = (spec.spec as ListValueSpecOf<'object'>)[ const displayAs = (spec.spec as ListValueSpecOf<'object'>)[
'display-as' 'display-as'
] ]
@@ -87,7 +87,7 @@ export class FormObjectComponent {
} }
updateUnion(e: any): void { updateUnion(e: any): void {
const primary = this.unionSpec.tag.id const primary = this.unionSpec?.tag.id
Object.keys(this.formGroup.controls).forEach(control => { Object.keys(this.formGroup.controls).forEach(control => {
if (control === primary) return if (control === primary) return
@@ -104,7 +104,7 @@ export class FormObjectComponent {
this.formGroup.addControl(control, unionGroup.controls[control]) this.formGroup.addControl(control, unionGroup.controls[control])
}) })
Object.entries(this.unionSpec.variants[e.detail.value]).forEach( Object.entries(this.unionSpec?.variants[e.detail.value] || {}).forEach(
([key, value]) => { ([key, value]) => {
if (['object', 'union'].includes(value.type)) { if (['object', 'union'].includes(value.type)) {
this.objectDisplay[key] = { this.objectDisplay[key] = {
@@ -138,6 +138,9 @@ export class FormObjectComponent {
if (markDirty) arr.markAsDirty() if (markDirty) arr.markAsDirty()
const listSpec = this.objectSpec[key] as ValueSpecList const listSpec = this.objectSpec[key] as ValueSpecList
const newItem = this.formService.getListItem(listSpec, val) const newItem = this.formService.getListItem(listSpec, val)
if (!newItem) return
newItem.markAllAsTouched() newItem.markAllAsTouched()
arr.insert(0, newItem) arr.insert(0, newItem)
if (['object', 'union'].includes(listSpec.subtype)) { if (['object', 'union'].includes(listSpec.subtype)) {
@@ -177,13 +180,14 @@ export class FormObjectComponent {
updateLabel(key: string, i: number, displayAs: string) { updateLabel(key: string, i: number, displayAs: string) {
this.objectListDisplay[key][i].displayAs = displayAs this.objectListDisplay[key][i].displayAs = displayAs
? Mustache.render(displayAs, this.formGroup.get(key).value[i]) ? Mustache.render(displayAs, this.formGroup.get(key)?.value[i])
: '' : ''
} }
getWarningText(text: string): IonicSafeString { getWarningText(text: string = ''): IonicSafeString | string {
if (text) return text
return new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`) ? new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`)
: ''
} }
handleInputChange() { handleInputChange() {
@@ -192,8 +196,8 @@ export class FormObjectComponent {
handleBooleanChange(key: string, spec: ValueSpecBoolean) { handleBooleanChange(key: string, spec: ValueSpecBoolean) {
if (spec.warning) { if (spec.warning) {
const current = this.formGroup.get(key).value const current = this.formGroup.get(key)?.value
const cancelFn = () => this.formGroup.get(key).setValue(!current) const cancelFn = () => this.formGroup.get(key)?.setValue(!current)
this.presentAlertChangeWarning(key, spec, undefined, cancelFn) this.presentAlertChangeWarning(key, spec, undefined, cancelFn)
} }
} }
@@ -307,7 +311,7 @@ export class FormObjectComponent {
} }
private updateEnumList(key: string, current: string[], updated: string[]) { private updateEnumList(key: string, current: string[], updated: string[]) {
this.formGroup.get(key).markAsDirty() this.formGroup.get(key)?.markAsDirty()
for (let i = current.length - 1; i >= 0; i--) { for (let i = current.length - 1; i >= 0; i--) {
if (!updated.includes(current[i])) { if (!updated.includes(current[i])) {
@@ -322,9 +326,9 @@ export class FormObjectComponent {
}) })
} }
private getDocSize(key: string, index = 0) { private getDocSize(key: string, index = 0): string {
const element = document.getElementById(this.getElementId(key, index)) const element = document.getElementById(this.getElementId(key, index))
return `${element.scrollHeight}px` return `${element?.scrollHeight}px`
} }
getElementId(key: string, index = 0): string { getElementId(key: string, index = 0): string {

View File

@@ -16,7 +16,7 @@
{{ dependentViolation }} {{ dependentViolation }}
</div> </div>
<div *ngIf="patch.data['package-data']" class="items"> <div *ngIf="pkgs$ | async as pkgs" class="items">
<div class="affected"> <div class="affected">
<ion-text color="warning">Affected Services</ion-text> <ion-text color="warning">Affected Services</ion-text>
</div> </div>
@@ -26,13 +26,10 @@
*ngFor="let dep of dependentBreakages | keyvalue" *ngFor="let dep of dependentBreakages | keyvalue"
> >
<ion-thumbnail class="thumbnail" slot="start"> <ion-thumbnail class="thumbnail" slot="start">
<img <img alt="" [src]="pkgs[dep.key]['static-files'].icon" />
alt=""
[src]="patch.data['package-data'][dep.key]['static-files'].icon"
/>
</ion-thumbnail> </ion-thumbnail>
<ion-label> <ion-label>
<h5>{{ patch.data['package-data'][dep.key].manifest.title }}</h5> <h5>{{ pkgs[dep.key].manifest.title }}</h5>
</ion-label> </ion-label>
</ion-item> </ion-item>
</div> </div>

View File

@@ -35,6 +35,8 @@ export class DependentsComponent {
loading$ = new BehaviorSubject(false) loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>() cancel$ = new Subject<void>()
readonly pkgs$ = this.patch.watch$('package-data')
constructor(public readonly patch: PatchDbService) {} constructor(public readonly patch: PatchDbService) {}
load() { load() {
@@ -45,6 +47,7 @@ export class DependentsComponent {
) )
.subscribe({ .subscribe({
complete: () => { complete: () => {
console.log('DEP BREAKS, ', this.dependentBreakages)
if ( if (
this.dependentBreakages && this.dependentBreakages &&
!isEmptyObject(this.dependentBreakages) !isEmptyObject(this.dependentBreakages)

View File

@@ -33,7 +33,7 @@ export class WizardBaker {
const action = 'update' const action = 'update'
const toolbar: TopbarParams = { action, title, version } const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [ const slideDefinitions: Array<SlideDefinition | undefined> = [
installAlert installAlert
? { ? {
slide: { slide: {
@@ -170,7 +170,7 @@ export class WizardBaker {
const action = 'downgrade' const action = 'downgrade'
const toolbar: TopbarParams = { action, title, version } const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [ const slideDefinitions: Array<SlideDefinition | undefined> = [
installAlert installAlert
? { ? {
slide: { slide: {

View File

@@ -66,19 +66,20 @@ export class LogsPage {
try { try {
// get logs // get logs
const logs = await this.fetch() const logs = await this.fetch()
if (!logs.length) return if (!logs?.length) return
const container = document.getElementById('container') const container = document.getElementById('container')
const beforeContainerHeight = container.scrollHeight const beforeContainerHeight = container?.scrollHeight || 0
const newLogs = document const newLogs = document.getElementById('template')?.cloneNode(true)
.getElementById('template')
.cloneNode(true) as HTMLElement if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML = newLogs.innerHTML =
logs logs
.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`) .map(l => `${l.timestamp} ${convert.toHtml(l.message)}`)
.join('\n') + (logs.length ? '\n' : '') .join('\n') + (logs.length ? '\n' : '')
container.prepend(newLogs) container?.prepend(newLogs)
const afterContainerHeight = container.scrollHeight const afterContainerHeight = container?.scrollHeight || 0
// scroll down // scroll down
scrollBy(0, afterContainerHeight - beforeContainerHeight) scrollBy(0, afterContainerHeight - beforeContainerHeight)
@@ -97,17 +98,18 @@ export class LogsPage {
try { try {
this.loadingMore = true this.loadingMore = true
const logs = await this.fetch(false) const logs = await this.fetch(false)
if (!logs.length) return (this.loadingMore = false) if (!logs?.length) return (this.loadingMore = false)
const container = document.getElementById('container') const container = document.getElementById('container')
const newLogs = document const newLogs = document.getElementById('template')?.cloneNode(true)
.getElementById('template')
.cloneNode(true) as HTMLElement if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML = newLogs.innerHTML =
logs logs
.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`) .map(l => `${l.timestamp} ${convert.toHtml(l.message)}`)
.join('\n') + (logs.length ? '\n' : '') .join('\n') + (logs.length ? '\n' : '')
container.append(newLogs) container?.append(newLogs)
this.loadingMore = false this.loadingMore = false
this.scrollEvent() this.scrollEvent()
} catch (e) {} } catch (e) {}
@@ -116,7 +118,7 @@ export class LogsPage {
scrollEvent() { scrollEvent() {
const buttonDiv = document.getElementById('button-div') const buttonDiv = document.getElementById('button-div')
this.isOnBottom = this.isOnBottom =
buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight !!buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
} }
scrollToBottom() { scrollToBottom() {

View File

@@ -20,5 +20,5 @@ export class StatusComponent {
@Input() weight?: string = 'normal' @Input() weight?: string = 'normal'
@Input() disconnected?: boolean = false @Input() disconnected?: boolean = false
@Input() installProgress?: number @Input() installProgress?: number
@Input() sigtermTimeout?: string @Input() sigtermTimeout?: string | null = null
} }

View File

@@ -33,7 +33,7 @@
<ng-template #noError> <ng-template #noError>
<ion-item <ion-item
*ngIf="hasConfig && !pkg.installed.status.configured && !configForm.dirty" *ngIf="hasConfig && !pkg.installed?.status?.configured && !configForm.dirty"
> >
<ion-label> <ion-label>
<ion-text color="success" <ion-text color="success"

View File

@@ -62,11 +62,12 @@ export class AppConfigPage {
if (!this.hasConfig) return if (!this.hasConfig) return
let oldConfig: object
let newConfig: object | undefined
let spec: ConfigSpec
let patch: Operation[] | undefined
try { try {
let oldConfig: object
let newConfig: object
let spec: ConfigSpec
let patch: Operation[]
if (this.dependentInfo) { if (this.dependentInfo) {
this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}` this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
const { const {
@@ -133,7 +134,7 @@ export class AppConfigPage {
if (this.configForm.invalid) { if (this.configForm.invalid) {
document document
.getElementsByClassName('validation-error')[0] .getElementsByClassName('validation-error')[0]
?.parentElement.parentElement.scrollIntoView({ behavior: 'smooth' }) ?.parentElement?.parentElement?.scrollIntoView({ behavior: 'smooth' })
return return
} }
@@ -153,7 +154,7 @@ export class AppConfigPage {
config, config,
}) })
if (!isEmptyObject(breakages['length'])) { if (!isEmptyObject(breakages['length']) && this.pkg) {
const { cancelled } = await wizardModal( const { cancelled } = await wizardModal(
this.modalCtrl, this.modalCtrl,
this.wizardBaker.configure({ this.wizardBaker.configure({
@@ -247,11 +248,11 @@ export class AppConfigPage {
return isNaN(num) ? node : num return isNaN(num) ? node : num
}) })
if (op.op !== 'remove') this.configForm.get(arrPath).markAsDirty() if (op.op !== 'remove') this.configForm.get(arrPath)?.markAsDirty()
if (typeof arrPath[arrPath.length - 1] === 'number') { if (typeof arrPath[arrPath.length - 1] === 'number') {
const prevPath = arrPath.slice(0, arrPath.length - 1) const prevPath = arrPath.slice(0, arrPath.length - 1)
this.configForm.get(prevPath).markAsDirty() this.configForm.get(prevPath)?.markAsDirty()
} }
}) })
} }

View File

@@ -50,7 +50,7 @@ export class GenericFormPage {
this.formGroup.markAllAsTouched() this.formGroup.markAllAsTouched()
document document
.getElementsByClassName('validation-error')[0] .getElementsByClassName('validation-error')[0]
?.parentElement.parentElement.scrollIntoView({ behavior: 'smooth' }) ?.parentElement?.parentElement?.scrollIntoView({ behavior: 'smooth' })
return return
} }

View File

@@ -29,7 +29,7 @@ export class GenericInputComponent {
...this.options, ...this.options,
} }
this.value = this.options.initialValue this.value = this.options.initialValue || ''
} }
ngAfterViewInit() { ngAfterViewInit() {

View File

@@ -12,21 +12,21 @@ export class SnakePage {
speed = 45 speed = 45
width = 40 width = 40
height = 26 height = 26
grid grid = NaN
startingLength = 4 startingLength = 4
score = 0 score = 0
highScore = 0 highScore = 0
xDown: number xDown?: number
yDown: number yDown?: number
canvas: HTMLCanvasElement canvas: HTMLCanvasElement
image: HTMLImageElement image: HTMLImageElement
context context: CanvasRenderingContext2D
snake snake: any
bitcoin bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
moveQueue: String[] = [] moveQueue: String[] = []
@@ -37,7 +37,8 @@ export class SnakePage {
ngOnInit() { ngOnInit() {
if (this.patch.getData().ui.gaming?.snake?.['high-score']) { if (this.patch.getData().ui.gaming?.snake?.['high-score']) {
this.highScore = this.patch.getData().ui.gaming?.snake?.['high-score'] this.highScore =
this.patch.getData().ui.gaming?.snake?.['high-score'] || 0
} }
} }
@@ -60,8 +61,8 @@ export class SnakePage {
this.handleTouchMove(e) this.handleTouchMove(e)
} }
@HostListener('window:resize', ['$event']) @HostListener('window:resize')
sizeChange(event) { sizeChange() {
this.init() this.init()
} }
@@ -78,7 +79,7 @@ export class SnakePage {
init() { init() {
this.canvas = document.getElementById('game') as HTMLCanvasElement this.canvas = document.getElementById('game') as HTMLCanvasElement
this.canvas.style.border = '1px solid #e0e0e0' this.canvas.style.border = '1px solid #e0e0e0'
this.context = this.canvas.getContext('2d') this.context = this.canvas.getContext('2d')!
const container = document.getElementsByClassName('canvas-center')[0] const container = document.getElementsByClassName('canvas-center')[0]
this.grid = Math.min( this.grid = Math.min(
Math.floor(container.clientWidth / this.width), Math.floor(container.clientWidth / this.width),
@@ -109,13 +110,13 @@ export class SnakePage {
return evt.touches return evt.touches
} }
handleTouchStart(evt) { handleTouchStart(evt: TouchEvent) {
const firstTouch = this.getTouches(evt)[0] const firstTouch = this.getTouches(evt)[0]
this.xDown = firstTouch.clientX this.xDown = firstTouch.clientX
this.yDown = firstTouch.clientY this.yDown = firstTouch.clientY
} }
handleTouchMove(evt) { handleTouchMove(evt: TouchEvent) {
if (!this.xDown || !this.yDown) { if (!this.xDown || !this.yDown) {
return return
} }
@@ -141,8 +142,8 @@ export class SnakePage {
} }
} }
/* reset values */ /* reset values */
this.xDown = null this.xDown = undefined
this.yDown = null this.yDown = undefined
} }
// game loop // game loop
@@ -257,7 +258,7 @@ export class SnakePage {
this.score = 0 this.score = 0
} }
getRandomInt(min, max) { getRandomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min)) + min return Math.floor(Math.random() * (max - min)) + min
} }
} }

View File

@@ -18,7 +18,7 @@ import { wizardModal } from 'src/app/components/install-wizard/install-wizard.co
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page' import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { isEmptyObject, ErrorToastService } from '@start9labs/shared' import { isEmptyObject, ErrorToastService, getPkgId } from '@start9labs/shared'
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page' import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
@Component({ @Component({
@@ -28,7 +28,7 @@ import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.
}) })
export class AppActionsPage { export class AppActionsPage {
@ViewChild(IonContent) content: IonContent @ViewChild(IonContent) content: IonContent
pkgId: string readonly pkgId = getPkgId(this.route)
pkg: PackageDataEntry pkg: PackageDataEntry
subs: Subscription[] subs: Subscription[]
@@ -45,7 +45,6 @@ export class AppActionsPage {
) {} ) {}
ngOnInit() { ngOnInit() {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.subs = [ this.subs = [
this.patch.watch$('package-data', this.pkgId).subscribe(pkg => { this.patch.watch$('package-data', this.pkgId).subscribe(pkg => {
this.pkg = pkg this.pkg = pkg
@@ -62,13 +61,14 @@ export class AppActionsPage {
} }
async handleAction(action: { key: string; value: Action }) { async handleAction(action: { key: string; value: Action }) {
const status = this.pkg.installed.status const status = this.pkg.installed?.status
if ( if (
status &&
(action.value['allowed-statuses'] as PackageMainStatus[]).includes( (action.value['allowed-statuses'] as PackageMainStatus[]).includes(
status.main.status, status.main.status,
) )
) { ) {
if (!isEmptyObject(action.value['input-spec'])) { if (!isEmptyObject(action.value['input-spec'] || {})) {
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
component: GenericFormPage, component: GenericFormPage,
componentProps: { componentProps: {
@@ -112,7 +112,7 @@ export class AppActionsPage {
const statuses = [...action.value['allowed-statuses']] const statuses = [...action.value['allowed-statuses']]
const last = statuses.pop() const last = statuses.pop()
let statusesStr = statuses.join(', ') let statusesStr = statuses.join(', ')
let error = null let error = ''
if (statuses.length) { if (statuses.length) {
if (statuses.length > 1) { if (statuses.length > 1) {
// oxford comma // oxford comma
@@ -144,7 +144,7 @@ export class AppActionsPage {
id, id,
title, title,
version, version,
uninstallAlert: alerts.uninstall, uninstallAlert: alerts.uninstall || undefined,
}), }),
) )
@@ -177,6 +177,7 @@ export class AppActionsPage {
}) })
setTimeout(() => successModal.present(), 400) setTimeout(() => successModal.present(), 400)
return true
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)
return false return false

View File

@@ -1,6 +1,7 @@
import { Component, Input, ViewChild } from '@angular/core' import { Component, Input, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { IonContent, ToastController } from '@ionic/angular' import { IonContent, ToastController } from '@ionic/angular'
import { getPkgId } from '@start9labs/shared'
import { getUiInterfaceKey } from 'src/app/services/config.service' import { getUiInterfaceKey } from 'src/app/services/config.service'
import { import {
InstalledPackageDataEntry, InstalledPackageDataEntry,
@@ -23,7 +24,7 @@ export class AppInterfacesPage {
@ViewChild(IonContent) content: IonContent @ViewChild(IonContent) content: IonContent
ui: LocalInterface | null ui: LocalInterface | null
other: LocalInterface[] = [] other: LocalInterface[] = []
pkgId: string readonly pkgId = getPkgId(this.route)
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
@@ -31,11 +32,12 @@ export class AppInterfacesPage {
) {} ) {}
ngOnInit() { ngOnInit() {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
const pkg = this.patch.getData()['package-data'][this.pkgId] const pkg = this.patch.getData()['package-data'][this.pkgId]
const interfaces = pkg.manifest.interfaces const interfaces = pkg.manifest.interfaces
const uiKey = getUiInterfaceKey(interfaces) const uiKey = getUiInterfaceKey(interfaces)
if (!pkg?.installed) return
const addressesMap = pkg.installed['interface-addresses'] const addressesMap = pkg.installed['interface-addresses']
if (uiKey) { if (uiKey) {
@@ -45,10 +47,10 @@ export class AppInterfacesPage {
addresses: { addresses: {
'lan-address': uiAddresses['lan-address'] 'lan-address': uiAddresses['lan-address']
? 'https://' + uiAddresses['lan-address'] ? 'https://' + uiAddresses['lan-address']
: null, : '',
'tor-address': uiAddresses['tor-address'] 'tor-address': uiAddresses['tor-address']
? 'http://' + uiAddresses['tor-address'] ? 'http://' + uiAddresses['tor-address']
: null, : '',
}, },
} }
} }
@@ -62,10 +64,10 @@ export class AppInterfacesPage {
addresses: { addresses: {
'lan-address': addresses['lan-address'] 'lan-address': addresses['lan-address']
? 'https://' + addresses['lan-address'] ? 'https://' + addresses['lan-address']
: null, : '',
'tor-address': addresses['tor-address'] 'tor-address': addresses['tor-address']
? 'http://' + addresses['tor-address'] ? 'http://' + addresses['tor-address']
: null, : '',
}, },
} }
}) })

View File

@@ -21,7 +21,9 @@ export class AppListPkgComponent {
constructor(private readonly launcherService: UiLauncherService) {} constructor(private readonly launcherService: UiLauncherService) {}
get status(): PackageMainStatus { get status(): PackageMainStatus {
return this.pkg.entry.installed?.status.main.status return (
this.pkg.entry.installed?.status.main.status || PackageMainStatus.Stopped
)
} }
get manifest(): Manifest { get manifest(): Manifest {

View File

@@ -1,12 +1,17 @@
<!-- header --> <!-- header -->
<ion-item-divider> <ion-item-divider>
{{ reordering ? "Reorder" : "Installed Services" }} {{ reordering ? 'Reorder' : 'Installed Services' }}
<ion-button *ngIf="pkgs.length > 1" slot="end" fill="clear" (click)="toggle()"> <ion-button
*ngIf="pkgs.length > 1"
slot="end"
fill="clear"
(click)="toggle()"
>
<ion-icon <ion-icon
slot="start" slot="start"
[name]="reordering ? 'checkmark' : 'swap-vertical'" [name]="reordering ? 'checkmark' : 'swap-vertical'"
></ion-icon> ></ion-icon>
{{ reordering ? "Done" : "Reorder" }} {{ reordering ? 'Done' : 'Reorder' }}
</ion-button> </ion-button>
</ion-item-divider> </ion-item-divider>
@@ -14,11 +19,15 @@
<ion-list *ngIf="reordering; else grid"> <ion-list *ngIf="reordering; else grid">
<ion-reorder-group disabled="false" (ionItemReorder)="reorder($any($event))"> <ion-reorder-group disabled="false" (ionItemReorder)="reorder($any($event))">
<ion-reorder *ngFor="let item of pkgs"> <ion-reorder *ngFor="let item of pkgs">
<ion-item color="light" *ngIf="item | packageInfo | async as pkg" class="item"> <ion-item
color="light"
*ngIf="item | packageInfo | async as pkg"
class="item"
>
<app-list-icon <app-list-icon
slot="start" slot="start"
[pkg]="pkg" [pkg]="pkg"
[connectionFailure]="connectionFailure$ | async" [connectionFailure]="!!(connectionFailure$ | async)"
></app-list-icon> ></app-list-icon>
<ion-thumbnail slot="start"> <ion-thumbnail slot="start">
<img alt="" [src]="pkg.entry['static-files'].icon" /> <img alt="" [src]="pkg.entry['static-files'].icon" />
@@ -27,7 +36,7 @@
<h2>{{ pkg.entry.manifest.title }}</h2> <h2>{{ pkg.entry.manifest.title }}</h2>
<p>{{ pkg.entry.manifest.version | displayEmver }}</p> <p>{{ pkg.entry.manifest.version | displayEmver }}</p>
<status <status
[disconnected]="connectionFailure$ | async" [disconnected]="!!(connectionFailure$ | async)"
[rendering]="pkg.primaryRendering" [rendering]="pkg.primaryRendering"
[installProgress]="pkg.installProgress?.totalProgress" [installProgress]="pkg.installProgress?.totalProgress"
weight="bold" weight="bold"
@@ -47,8 +56,9 @@
<ion-row> <ion-row>
<ion-col *ngFor="let pkg of pkgs" sizeXs="12" sizeXl="6"> <ion-col *ngFor="let pkg of pkgs" sizeXs="12" sizeXl="6">
<app-list-pkg <app-list-pkg
[pkg]="pkg | packageInfo | async" *ngIf="pkg | packageInfo | async as info"
[connectionFailure]="connectionFailure$ | async" [pkg]="info"
[connectionFailure]="!!(connectionFailure$ | async)"
></app-list-pkg> ></app-list-pkg>
</ion-col> </ion-col>
</ion-row> </ion-row>

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({ @Component({
@@ -8,22 +9,22 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
styleUrls: ['./app-logs.page.scss'], styleUrls: ['./app-logs.page.scss'],
}) })
export class AppLogsPage { export class AppLogsPage {
pkgId: string readonly pkgId = getPkgId(this.route)
loading = true loading = true
needInfinite = true needInfinite = true
before: string before: string
constructor ( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService, private readonly embassyApi: ApiService,
) { } ) {}
ngOnInit () { fetchFetchLogs() {
this.pkgId = this.route.snapshot.paramMap.get('pkgId') return async (params: {
} before_flag?: boolean
limit?: number
fetchFetchLogs () { cursor?: string
return async (params: { before_flag?: boolean, limit?: number, cursor?: string }) => { }) => {
return this.embassyApi.getPackageLogs({ return this.embassyApi.getPackageLogs({
id: this.pkgId, id: this.pkgId,
before_flag: params.before_flag, before_flag: params.before_flag,

View File

@@ -5,7 +5,7 @@ import { Subscription } from 'rxjs'
import { Metric } from 'src/app/services/api/api.types' import { Metric } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MainStatus } from 'src/app/services/patch-db/data-model' import { MainStatus } from 'src/app/services/patch-db/data-model'
import { pauseFor, ErrorToastService } from '@start9labs/shared' import { pauseFor, ErrorToastService, getPkgId } from '@start9labs/shared'
@Component({ @Component({
selector: 'app-metrics', selector: 'app-metrics',
@@ -14,7 +14,7 @@ import { pauseFor, ErrorToastService } from '@start9labs/shared'
}) })
export class AppMetricsPage { export class AppMetricsPage {
loading = true loading = true
pkgId: string readonly pkgId = getPkgId(this.route)
mainStatus: MainStatus mainStatus: MainStatus
going = false going = false
metrics: Metric metrics: Metric
@@ -29,7 +29,6 @@ export class AppMetricsPage {
) {} ) {}
ngOnInit() { ngOnInit() {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.startDaemon() this.startDaemon()
} }

View File

@@ -15,7 +15,7 @@ import { PackageProperties } from 'src/app/util/properties.util'
import { QRComponent } from 'src/app/components/qr/qr.component' import { QRComponent } from 'src/app/components/qr/qr.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageMainStatus } from 'src/app/services/patch-db/data-model' import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { ErrorToastService } from '@start9labs/shared' import { ErrorToastService, getPkgId } from '@start9labs/shared'
import { getValueByPointer } from 'fast-json-patch' import { getValueByPointer } from 'fast-json-patch'
@Component({ @Component({
@@ -25,7 +25,7 @@ import { getValueByPointer } from 'fast-json-patch'
}) })
export class AppPropertiesPage { export class AppPropertiesPage {
loading = true loading = true
pkgId: string readonly pkgId = getPkgId(this.route)
pointer: string pointer: string
properties: PackageProperties properties: PackageProperties
node: PackageProperties node: PackageProperties
@@ -55,8 +55,6 @@ export class AppPropertiesPage {
} }
async ngOnInit() { async ngOnInit() {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
await this.getProperties() await this.getProperties()
this.subs = [ this.subs = [
@@ -100,7 +98,7 @@ export class AppPropertiesPage {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: property.key, header: property.key,
message: property.value.description, message: property.value.description || undefined,
}) })
await alert.present() await alert.present()
} }

View File

@@ -6,7 +6,7 @@
<!-- ** status ** --> <!-- ** status ** -->
<app-show-status <app-show-status
[pkg]="pkg" [pkg]="pkg"
[connectionFailure]="connectionFailure$ | async" [connectionFailure]="!!(connectionFailure$ | async)"
[dependencies]="dependencies" [dependencies]="dependencies"
[status]="status" [status]="status"
></app-show-status> ></app-show-status>
@@ -16,7 +16,7 @@
<app-show-health-checks <app-show-health-checks
*ngIf="isRunning(status)" *ngIf="isRunning(status)"
[pkg]="pkg" [pkg]="pkg"
[connectionFailure]="connectionFailure$ | async" [connectionFailure]="!!(connectionFailure$ | async)"
></app-show-health-checks> ></app-show-health-checks>
<!-- ** dependencies ** --> <!-- ** dependencies ** -->
<app-show-dependencies <app-show-dependencies
@@ -27,7 +27,7 @@
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu> <app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
</ng-container> </ng-container>
</ion-item-group> </ion-item-group>
<!-- ** installing, updating, restoring ** --> <!-- ** installing, updating, restoring ** -->
<ion-content *ngIf="showProgress(pkg)"> <ion-content *ngIf="showProgress(pkg)">
<app-show-progress <app-show-progress

View File

@@ -13,6 +13,7 @@ import {
} from 'src/app/services/connection.service' } from 'src/app/services/connection.service'
import { map, startWith } from 'rxjs/operators' import { map, startWith } from 'rxjs/operators'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
const STATES = [ const STATES = [
PackageState.Installing, PackageState.Installing,
@@ -26,7 +27,7 @@ const STATES = [
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppShowPage { export class AppShowPage {
private readonly pkgId = this.route.snapshot.paramMap.get('pkgId') private readonly pkgId = getPkgId(this.route)
readonly pkg$ = this.patch.watch$('package-data', this.pkgId).pipe( readonly pkg$ = this.patch.watch$('package-data', this.pkgId).pipe(
map(pkg => { map(pkg => {

View File

@@ -31,6 +31,6 @@ export class AppShowProgressComponent {
} }
getColor(action: keyof InstallProgress): string { getColor(action: keyof InstallProgress): string {
return this.pkg['install-progress'][action] ? 'success' : 'secondary' return this.pkg['install-progress']?.[action] ? 'success' : 'secondary'
} }
} }

View File

@@ -37,7 +37,7 @@
</ion-button> </ion-button>
<ion-button <ion-button
*ngIf="!pkgStatus.configured" *ngIf="!pkgStatus?.configured"
class="action-button" class="action-button"
slot="start" slot="start"
color="warning" color="warning"
@@ -48,7 +48,7 @@
</ion-button> </ion-button>
<ion-button <ion-button
*ngIf="interfaces | hasUi" *ngIf="pkgStatus && (interfaces | hasUi)"
class="action-button" class="action-button"
slot="start" slot="start"
color="primary" color="primary"

View File

@@ -61,8 +61,8 @@ export class AppShowStatusComponent {
return this.pkg.manifest.interfaces return this.pkg.manifest.interfaces
} }
get pkgStatus(): Status { get pkgStatus(): Status | null {
return this.pkg.installed.status return this.pkg.installed?.status || null
} }
get isInstalled(): boolean { get isInstalled(): boolean {
@@ -75,7 +75,8 @@ export class AppShowStatusComponent {
get isStopped(): boolean { get isStopped(): boolean {
return ( return (
this.status.primary === PrimaryStatus.Stopped && this.pkgStatus.configured this.status.primary === PrimaryStatus.Stopped &&
!!this.pkgStatus?.configured
) )
} }
@@ -109,7 +110,7 @@ export class AppShowStatusComponent {
async stop(): Promise<void> { async stop(): Promise<void> {
const { id, title, version } = this.pkg.manifest const { id, title, version } = this.pkg.manifest
const hasDependents = !!Object.keys( const hasDependents = !!Object.keys(
this.pkg.installed['current-dependents'], this.pkg.installed?.['current-dependents'] || {},
).filter(depId => depId !== id).length ).filter(depId => depId !== id).length
if (!hasDependents) { if (!hasDependents) {

View File

@@ -134,7 +134,7 @@ export class ToButtonsPipe implements PipeTransform {
private async donate({ manifest }: PackageDataEntry): Promise<void> { private async donate({ manifest }: PackageDataEntry): Promise<void> {
const url = manifest['donation-url'] const url = manifest['donation-url']
if (url) { if (url) {
this.document.defaultView.open(url, '_blank', 'noreferrer') this.document.defaultView?.open(url, '_blank', 'noreferrer')
} else { } else {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Not Accepting Donations', header: 'Not Accepting Donations',

View File

@@ -62,7 +62,7 @@ export class ToDependenciesPipe implements PipeTransform {
private setDepValues( private setDepValues(
pkg: PackageDataEntry, pkg: PackageDataEntry,
id: string, id: string,
errors: { [id: string]: DependencyError }, errors: { [id: string]: DependencyError | null },
): DependencyInfo { ): DependencyInfo {
let errorText = '' let errorText = ''
let actionText = 'View' let actionText = 'View'
@@ -105,13 +105,13 @@ export class ToDependenciesPipe implements PipeTransform {
errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.` errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.`
} }
const depInfo = pkg.installed['dependency-info'][id] const depInfo = pkg.installed?.['dependency-info'][id]
return { return {
id, id,
version: pkg.manifest.dependencies[id].version, version: pkg.manifest.dependencies[id].version,
title: depInfo.manifest?.title || id, title: depInfo?.manifest?.title || id,
icon: depInfo.icon, icon: depInfo?.icon || '',
errorText, errorText,
actionText, actionText,
action, action,

View File

@@ -1,12 +1,13 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { debounce, exists, ErrorToastService } from '@start9labs/shared'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import { take } from 'rxjs/operators' import { filter, take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { getProjectId } from 'src/app/util/get-project-id'
import { GenericFormPage } from '../../../modals/generic-form/generic-form.page' import { GenericFormPage } from '../../../modals/generic-form/generic-form.page'
import { debounce, ErrorToastService } from '@start9labs/shared'
@Component({ @Component({
selector: 'dev-config', selector: 'dev-config',
@@ -14,7 +15,7 @@ import { debounce, ErrorToastService } from '@start9labs/shared'
styleUrls: ['dev-config.page.scss'], styleUrls: ['dev-config.page.scss'],
}) })
export class DevConfigPage { export class DevConfigPage {
projectId: string readonly projectId = getProjectId(this.route)
editorOptions = { theme: 'vs-dark', language: 'yaml' } editorOptions = { theme: 'vs-dark', language: 'yaml' }
code: string = '' code: string = ''
saving: boolean = false saving: boolean = false
@@ -28,11 +29,9 @@ export class DevConfigPage {
) {} ) {}
ngOnInit() { ngOnInit() {
this.projectId = this.route.snapshot.paramMap.get('projectId')
this.patchDb this.patchDb
.watch$('ui', 'dev', this.projectId, 'config') .watch$('ui', 'dev', this.projectId, 'config')
.pipe(take(1)) .pipe(filter(exists), take(1))
.subscribe(config => { .subscribe(config => {
this.code = config this.code = config
}) })

View File

@@ -1,14 +1,16 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { take } from 'rxjs/operators' import { filter, take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { import {
debounce, debounce,
exists,
ErrorToastService, ErrorToastService,
MarkdownComponent, MarkdownComponent,
} from '@start9labs/shared' } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { getProjectId } from 'src/app/util/get-project-id'
@Component({ @Component({
selector: 'dev-instructions', selector: 'dev-instructions',
@@ -16,7 +18,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
styleUrls: ['dev-instructions.page.scss'], styleUrls: ['dev-instructions.page.scss'],
}) })
export class DevInstructionsPage { export class DevInstructionsPage {
projectId: string readonly projectId = getProjectId(this.route)
editorOptions = { theme: 'vs-dark', language: 'markdown' } editorOptions = { theme: 'vs-dark', language: 'markdown' }
code: string = '' code: string = ''
saving: boolean = false saving: boolean = false
@@ -30,11 +32,9 @@ export class DevInstructionsPage {
) {} ) {}
ngOnInit() { ngOnInit() {
this.projectId = this.route.snapshot.paramMap.get('projectId')
this.patchDb this.patchDb
.watch$('ui', 'dev', this.projectId, 'instructions') .watch$('ui', 'dev', this.projectId, 'instructions')
.pipe(take(1)) .pipe(filter(exists), take(1))
.subscribe(config => { .subscribe(config => {
this.code = config this.code = config
}) })

View File

@@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import { take } from 'rxjs/operators' import { take } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { getProjectId } from 'src/app/util/get-project-id'
@Component({ @Component({
selector: 'dev-manifest', selector: 'dev-manifest',
@@ -10,7 +11,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
styleUrls: ['dev-manifest.page.scss'], styleUrls: ['dev-manifest.page.scss'],
}) })
export class DevManifestPage { export class DevManifestPage {
projectId: string readonly projectId = getProjectId(this.route)
editorOptions = { theme: 'vs-dark', language: 'yaml', readOnly: true } editorOptions = { theme: 'vs-dark', language: 'yaml', readOnly: true }
manifest: string = '' manifest: string = ''
@@ -20,8 +21,6 @@ export class DevManifestPage {
) {} ) {}
ngOnInit() { ngOnInit() {
this.projectId = this.route.snapshot.paramMap.get('projectId')
this.patchDb this.patchDb
.watch$('ui', 'dev', this.projectId) .watch$('ui', 'dev', this.projectId)
.pipe(take(1)) .pipe(take(1))

View File

@@ -231,9 +231,7 @@ const SAMPLE_CONFIG: ConfigSpec = {
masked: false, masked: false,
copyable: false, copyable: false,
// optional // optional
warning: null,
description: 'Example description for required string input.', description: 'Example description for required string input.',
default: null,
placeholder: 'Enter string value', placeholder: 'Enter string value',
pattern: '^[a-zA-Z0-9! _]+$', pattern: '^[a-zA-Z0-9! _]+$',
'pattern-description': 'Must be alphanumeric (may contain underscore).', 'pattern-description': 'Must be alphanumeric (may contain underscore).',
@@ -248,14 +246,12 @@ const SAMPLE_CONFIG: ConfigSpec = {
warning: 'Example warning to display when changing this number value.', warning: 'Example warning to display when changing this number value.',
units: 'ms', units: 'ms',
description: 'Example description for optional number input.', description: 'Example description for optional number input.',
default: null,
placeholder: 'Enter number value', placeholder: 'Enter number value',
}, },
'sample-boolean': { 'sample-boolean': {
type: 'boolean', type: 'boolean',
name: 'Example Boolean Toggle', name: 'Example Boolean Toggle',
// optional // optional
warning: null,
description: 'Example description for boolean toggle', description: 'Example description for boolean toggle',
default: true, default: true,
}, },

View File

@@ -3,7 +3,7 @@
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-back-button defaultHref="/developer"></ion-back-button> <ion-back-button defaultHref="/developer"></ion-back-button>
</ion-buttons> </ion-buttons>
<ion-title>{{ patchDb.data.ui.dev[projectId].name}}</ion-title> <ion-title>{{ name }}</ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button routerLink="manifest">View Manifest</ion-button> <ion-button routerLink="manifest">View Manifest</ion-button>
</ion-buttons> </ion-buttons>

View File

@@ -5,10 +5,10 @@ import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { BasicInfo, getBasicInfoSpec } from './form-info' import { BasicInfo, getBasicInfoSpec } from './form-info'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared' import { ErrorToastService, DestroyService } from '@start9labs/shared'
import { takeUntil } from 'rxjs/operators' import { takeUntil } from 'rxjs/operators'
import { DevProjectData } from 'src/app/services/patch-db/data-model' import { DevProjectData } from 'src/app/services/patch-db/data-model'
import { DestroyService } from '../../../../../../shared/src/services/destroy.service' import { getProjectId } from 'src/app/util/get-project-id'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
@Component({ @Component({
@@ -18,7 +18,7 @@ import * as yaml from 'js-yaml'
providers: [DestroyService], providers: [DestroyService],
}) })
export class DeveloperMenuPage { export class DeveloperMenuPage {
projectId: string readonly projectId = getProjectId(this.route)
projectData: DevProjectData projectData: DevProjectData
constructor( constructor(
@@ -28,12 +28,14 @@ export class DeveloperMenuPage {
private readonly api: ApiService, private readonly api: ApiService,
private readonly errToast: ErrorToastService, private readonly errToast: ErrorToastService,
private readonly destroy$: DestroyService, private readonly destroy$: DestroyService,
public readonly patchDb: PatchDbService, private readonly patchDb: PatchDbService,
) {} ) {}
ngOnInit() { get name(): string {
this.projectId = this.route.snapshot.paramMap.get('projectId') return this.patchDb.data.ui?.dev?.[this.projectId]?.name || ''
}
ngOnInit() {
this.patchDb this.patchDb
.watch$('ui', 'dev', this.projectId) .watch$('ui', 'dev', this.projectId)
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
@@ -51,14 +53,14 @@ export class DeveloperMenuPage {
buttons: [ buttons: [
{ {
text: 'Save', text: 'Save',
handler: basicInfo => { handler: (basicInfo: any) => {
basicInfo.description = { basicInfo.description = {
short: basicInfo.short, short: basicInfo.short,
long: basicInfo.long, long: basicInfo.long,
} }
delete basicInfo.short delete basicInfo.short
delete basicInfo.long delete basicInfo.long
this.saveBasicInfo(basicInfo as BasicInfo) this.saveBasicInfo(basicInfo)
}, },
isSubmit: true, isSubmit: true,
}, },

View File

@@ -9,10 +9,10 @@
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<marketplace-list-content <marketplace-list-content
*ngIf="loaded else loading" *ngIf="loaded else loading"
[localPkgs]="localPkgs$ | async" [localPkgs]="(localPkgs$ | async) || {}"
[pkgs]="pkgs$ | async" [pkgs]="pkgs$ | async"
[categories]="categories$ | async" [categories]="categories$ | async"
[name]="name$ | async" [name]="(name$ | async) || ''"
></marketplace-list-content> ></marketplace-list-content>
<ng-template #loading> <ng-template #loading>

View File

@@ -25,7 +25,7 @@ export class MarketplaceShowControlsComponent {
pkg: MarketplacePkg pkg: MarketplacePkg
@Input() @Input()
localPkg: PackageDataEntry localPkg: PackageDataEntry | null = null
readonly PackageState = PackageState readonly PackageState = PackageState
@@ -77,7 +77,7 @@ export class MarketplaceShowControlsComponent {
title, title,
version, version,
serviceRequirements: dependencies, serviceRequirements: dependencies,
installAlert: alerts.install, installAlert: alerts.install || undefined,
} }
const { cancelled } = await wizardModal( const { cancelled } = await wizardModal(

View File

@@ -5,9 +5,9 @@
<ng-container *ngIf="!(pkg | empty)"> <ng-container *ngIf="!(pkg | empty)">
<marketplace-package [pkg]="pkg"> <marketplace-package [pkg]="pkg">
<marketplace-status <marketplace-status
*ngIf="localPkg$ | async as localPkg"
class="status" class="status"
[version]="pkg.manifest.version" [localPkg]="localPkg"
[localPkg]="localPkg$ | async"
></marketplace-status> ></marketplace-status>
<marketplace-show-controls <marketplace-show-controls
position="controls" position="controls"

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { ErrorToastService } from '@start9labs/shared' import { ErrorToastService, getPkgId } from '@start9labs/shared'
import { import {
MarketplacePkg, MarketplacePkg,
AbstractMarketplaceService, AbstractMarketplaceService,
@@ -8,7 +8,7 @@ import {
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { BehaviorSubject, Observable, of } from 'rxjs' import { BehaviorSubject, Observable, of } from 'rxjs'
import { catchError, filter, shareReplay, switchMap, tap } from 'rxjs/operators' import { catchError, filter, shareReplay, switchMap } from 'rxjs/operators'
@Component({ @Component({
selector: 'marketplace-show', selector: 'marketplace-show',
@@ -17,7 +17,7 @@ import { catchError, filter, shareReplay, switchMap, tap } from 'rxjs/operators'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MarketplaceShowPage { export class MarketplaceShowPage {
private readonly pkgId = this.route.snapshot.paramMap.get('pkgId') private readonly pkgId = getPkgId(this.route)
readonly loadVersion$ = new BehaviorSubject<string>('*') readonly loadVersion$ = new BehaviorSubject<string>('*')
@@ -28,12 +28,16 @@ export class MarketplaceShowPage {
shareReplay({ bufferSize: 1, refCount: true }), shareReplay({ bufferSize: 1, refCount: true }),
) )
readonly pkg$: Observable<MarketplacePkg> = this.loadVersion$.pipe( readonly pkg$: Observable<MarketplacePkg | null> = this.loadVersion$.pipe(
switchMap(version => switchMap(version =>
this.marketplaceService.getPackage(this.pkgId, version), this.marketplaceService.getPackage(this.pkgId, version),
), ),
// TODO: Better fallback // TODO: Better fallback
catchError(e => this.errToast.present(e) && of({} as MarketplacePkg)), catchError(e => {
this.errToast.present(e)
return of({} as MarketplacePkg)
}),
) )
constructor( constructor(

View File

@@ -6,8 +6,8 @@ import { packageLoadingProgress } from 'src/app/util/package-loading-progress'
name: 'installProgress', name: 'installProgress',
}) })
export class InstallProgressPipe implements PipeTransform { export class InstallProgressPipe implements PipeTransform {
transform(loadData: InstallProgress): string { transform(loadData?: InstallProgress): string {
const { totalProgress } = packageLoadingProgress(loadData) const totalProgress = packageLoadingProgress(loadData)?.totalProgress || 0
return totalProgress < 99 ? totalProgress + '%' : 'finalizing' return totalProgress < 99 ? totalProgress + '%' : 'finalizing'
} }

View File

@@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
@Component({ @Component({
templateUrl: './release-notes.page.html', templateUrl: './release-notes.page.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ReleaseNotesPage { export class ReleaseNotesPage {
readonly href = `/marketplace/${this.route.snapshot.paramMap.get('pkgId')}` readonly href = `/marketplace/${getPkgId(this.route)}`
constructor(private readonly route: ActivatedRoute) {} constructor(private readonly route: ActivatedRoute) {}
} }

View File

@@ -23,7 +23,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
export class NotificationsPage { export class NotificationsPage {
loading = true loading = true
notifications: ServerNotifications = [] notifications: ServerNotifications = []
beforeCursor: number beforeCursor?: number
needInfinite = false needInfinite = false
fromToast = false fromToast = false
readonly perPage = 40 readonly perPage = 40
@@ -51,19 +51,23 @@ export class NotificationsPage {
} }
async getNotifications(): Promise<ServerNotifications> { async getNotifications(): Promise<ServerNotifications> {
let notifications: ServerNotifications = []
try { try {
notifications = await this.embassyApi.getNotifications({ const notifications = await this.embassyApi.getNotifications({
before: this.beforeCursor, before: this.beforeCursor,
limit: this.perPage, limit: this.perPage,
}) })
if (!notifications) return []
this.beforeCursor = notifications[notifications.length - 1]?.id this.beforeCursor = notifications[notifications.length - 1]?.id
this.needInfinite = notifications.length >= this.perPage this.needInfinite = notifications.length >= this.perPage
return notifications
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)
} finally {
return notifications
} }
return []
} }
async delete(id: number, index: number): Promise<void> { async delete(id: number, index: number): Promise<void> {

View File

@@ -33,7 +33,7 @@
>instructions</a >instructions</a
>. >.
</h2> </h2>
<ng-container *ngIf="downloadIsDisabled && server$ | async as server"> <ng-container *ngIf="downloadIsDisabled && (server$ | async) as server">
<br /> <br />
<ion-text color="warning"> <ion-text color="warning">
For security reasons, you must setup LAN over a For security reasons, you must setup LAN over a

View File

@@ -8,8 +8,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
styleUrls: ['./lan.page.scss'], styleUrls: ['./lan.page.scss'],
}) })
export class LANPage { export class LANPage {
downloadIsDisabled: boolean readonly downloadIsDisabled = !this.config.isTor()
readonly server$ = this.patch.watch$('server-info') readonly server$ = this.patch.watch$('server-info')
constructor( constructor(
@@ -17,11 +16,7 @@ export class LANPage {
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
) {} ) {}
ngOnInit() {
this.downloadIsDisabled = !this.config.isTor()
}
installCert(): void { installCert(): void {
document.getElementById('install-cert').click() document.getElementById('install-cert')?.click()
} }
} }

View File

@@ -15,7 +15,19 @@ import { v4 } from 'uuid'
import { UIMarketplaceData } from '../../../services/patch-db/data-model' import { UIMarketplaceData } from '../../../services/patch-db/data-model'
import { ConfigService } from '../../../services/config.service' import { ConfigService } from '../../../services/config.service'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { finalize, first } from 'rxjs/operators' import {
distinctUntilChanged,
finalize,
first,
map,
startWith,
} from 'rxjs/operators'
type Marketplaces = {
id: string | undefined
name: string
url: string
}[]
@Component({ @Component({
selector: 'marketplaces', selector: 'marketplaces',
@@ -24,7 +36,7 @@ import { finalize, first } from 'rxjs/operators'
}) })
export class MarketplacesPage { export class MarketplacesPage {
selectedId: string | undefined selectedId: string | undefined
marketplaces: { id: string | undefined; name: string; url: string }[] = [] marketplaces: Marketplaces = []
constructor( constructor(
private readonly api: ApiService, private readonly api: ApiService,
@@ -39,27 +51,33 @@ export class MarketplacesPage {
) {} ) {}
ngOnInit() { ngOnInit() {
this.patch.watch$('ui', 'marketplace').subscribe(mp => { this.patch
const marketplaces = [ .watch$('ui')
{ .pipe(
id: undefined, map(ui => ui.marketplace),
name: this.config.marketplace.name, distinctUntilChanged(),
url: this.config.marketplace.url, )
}, .subscribe(mp => {
] let marketplaces: Marketplaces = [
if (mp) { {
this.selectedId = mp['selected-id'] id: undefined,
const alts = Object.entries(mp['known-hosts']).map(([k, v]) => { name: this.config.marketplace.name,
return { url: this.config.marketplace.url,
id: k, },
name: v.name, ]
url: v.url, if (mp) {
} this.selectedId = mp['selected-id'] || undefined
}) const alts = Object.entries(mp['known-hosts']).map(([k, v]) => {
marketplaces.push.apply(marketplaces, alts) return {
} id: k,
this.marketplaces = marketplaces name: v.name,
}) url: v.url,
}
})
marketplaces = marketplaces.concat(alts)
}
this.marketplaces = marketplaces
})
} }
async presentModalAdd() { async presentModalAdd() {
@@ -91,9 +109,10 @@ export class MarketplacesPage {
await modal.present() await modal.present()
} }
async presentAction(id: string) { async presentAction(id: string = '') {
// no need to view actions if is selected marketplace // no need to view actions if is selected marketplace
if (id === this.patch.getData().ui.marketplace?.['selected-id']) return if (!id || id === this.patch.getData().ui.marketplace?.['selected-id'])
return
const buttons: ActionSheetButton[] = [ const buttons: ActionSheetButton[] = [
{ {
@@ -200,7 +219,10 @@ export class MarketplacesPage {
? (JSON.parse( ? (JSON.parse(
JSON.stringify(this.patch.getData().ui.marketplace), JSON.stringify(this.patch.getData().ui.marketplace),
) as UIMarketplaceData) ) as UIMarketplaceData)
: { 'selected-id': undefined, 'known-hosts': {} } : {
'selected-id': undefined,
'known-hosts': {} as Record<string, unknown>,
}
// no-op on duplicates // no-op on duplicates
const currentUrls = this.marketplaces.map(mp => mp.url) const currentUrls = this.marketplaces.map(mp => mp.url)
@@ -242,7 +264,10 @@ export class MarketplacesPage {
? (JSON.parse( ? (JSON.parse(
JSON.stringify(this.patch.getData().ui.marketplace), JSON.stringify(this.patch.getData().ui.marketplace),
) as UIMarketplaceData) ) as UIMarketplaceData)
: { 'selected-id': undefined, 'known-hosts': {} } : {
'selected-id': undefined,
'known-hosts': {} as Record<string, unknown>,
}
// no-op on duplicates // no-op on duplicates
const currentUrls = this.marketplaces.map(mp => mp.url) const currentUrls = this.marketplaces.map(mp => mp.url)

View File

@@ -43,7 +43,8 @@ export class RestorePage {
useMask: true, useMask: true,
buttonText: 'Next', buttonText: 'Next',
submitFn: async (password: string) => { submitFn: async (password: string) => {
argon2.verify(target.entry['embassy-os']['password-hash'], password) const passwordHash = target.entry['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, password)
await this.restoreFromBackup(target, password) await this.restoreFromBackup(target, password)
}, },
} }

View File

@@ -55,7 +55,7 @@ export class ServerBackupPage {
} else { } else {
if (this.backingUp) { if (this.backingUp) {
this.backingUp = false this.backingUp = false
this.pkgs.forEach(pkg => pkg.sub.unsubscribe()) this.pkgs.forEach(pkg => pkg.sub?.unsubscribe())
this.navCtrl.navigateRoot('/embassy') this.navCtrl.navigateRoot('/embassy')
} }
} }
@@ -65,7 +65,7 @@ export class ServerBackupPage {
ngOnDestroy() { ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe()) this.subs.forEach(sub => sub.unsubscribe())
this.pkgs.forEach(pkg => pkg.sub.unsubscribe()) this.pkgs.forEach(pkg => pkg.sub?.unsubscribe())
} }
async presentModalPassword( async presentModalPassword(
@@ -98,7 +98,10 @@ export class ServerBackupPage {
// existing backup // existing backup
} else { } else {
try { try {
argon2.verify(target.entry['embassy-os']['password-hash'], password) const passwordHash =
target.entry['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, password)
} catch { } catch {
setTimeout( setTimeout(
() => this.presentModalOldPassword(target, password), () => this.presentModalOldPassword(target, password),
@@ -133,7 +136,9 @@ export class ServerBackupPage {
useMask: true, useMask: true,
buttonText: 'Create Backup', buttonText: 'Create Backup',
submitFn: async (oldPassword: string) => { submitFn: async (oldPassword: string) => {
argon2.verify(target.entry['embassy-os']['password-hash'], oldPassword) const passwordHash = target.entry['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, oldPassword)
await this.createBackup(target.id, password, oldPassword) await this.createBackup(target.id, password, oldPassword)
}, },
} }
@@ -182,15 +187,11 @@ export class ServerBackupPage {
pkg.installed?.status.main.status === PackageMainStatus.BackingUp, pkg.installed?.status.main.status === PackageMainStatus.BackingUp,
) )
this.pkgs = pkgArr.map((pkg, i) => { this.pkgs = pkgArr.map((pkg, i) => ({
const pkgInfo = { entry: pkg,
entry: pkg, active: i === activeIndex,
active: i === activeIndex, complete: i < activeIndex,
complete: i < activeIndex, }))
sub: null,
}
return pkgInfo
})
// subscribe to pkg // subscribe to pkg
this.pkgs.forEach(pkg => { this.pkgs.forEach(pkg => {
@@ -220,5 +221,5 @@ interface PkgInfo {
entry: PackageDataEntry entry: PackageDataEntry
active: boolean active: boolean
complete: boolean complete: boolean
sub: Subscription sub?: Subscription
} }

View File

@@ -12,11 +12,11 @@ export class ServerLogsPage {
needInfinite = true needInfinite = true
before: string before: string
constructor ( constructor(
private readonly embassyApi: ApiService, private readonly embassyApi: ApiService,
) { } ) { }
fetchFetchLogs () { fetchFetchLogs() {
return async (params: { before_flag?: boolean, limit?: number, cursor?: string }) => { return async (params: { before_flag?: boolean, limit?: number, cursor?: string }) => {
return this.embassyApi.getServerLogs({ return this.embassyApi.getServerLogs({
before_flag: params.before_flag, before_flag: params.before_flag,
@@ -25,4 +25,22 @@ export class ServerLogsPage {
}) })
} }
} }
async copy(): Promise<void> {
const logs = document
.getElementById('template')
?.cloneNode(true) as HTMLElement
const formatted = '```' + logs.innerHTML + '```'
const success = await copyToClipboard(formatted)
const message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
} }

View File

@@ -30,7 +30,7 @@ export class ServerMetricsPage {
}) })
const height = headersCount * 54 + rowsCount * 50 + 24 // extra 24 for room at the bottom const height = headersCount * 54 + rowsCount * 50 + 24 // extra 24 for room at the bottom
const elem = document.getElementById('metricSection') const elem = document.getElementById('metricSection')
elem.style.height = `${height}px` if (elem) elem.style.height = `${height}px`
this.startDaemon() this.startDaemon()
this.loading = false this.loading = false
} }

View File

@@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title *ngIf="patch.loaded else loading"> <ion-title *ngIf="patch.loaded else loading">
{{ (ui$ | async).name || "Embassy-" + (server$ | async).id }} {{ (ui$ | async)?.name || "Embassy-" + (server$ | async)?.id }}
</ion-title> </ion-title>
<ng-template #loading> <ng-template #loading>
<ion-title>Loading<span class="loading-dots"></span></ion-title> <ion-title>Loading<span class="loading-dots"></span></ion-title>

View File

@@ -42,7 +42,7 @@ export class WifiPage {
await this.getWifi() await this.getWifi()
} }
async getWifi(timeout?: number): Promise<void> { async getWifi(timeout: number = 0): Promise<void> {
this.loading = true this.loading = true
try { try {
this.wifi = await this.api.getWifi({}, timeout) this.wifi = await this.api.getWifi({}, timeout)

View File

@@ -18,7 +18,7 @@ export class Range {
checkIncludes(n: number) { checkIncludes(n: number) {
if ( if (
this.hasMin() !== undefined && this.hasMin() &&
(this.min > n || (!this.minInclusive && this.min == n)) (this.min > n || (!this.minInclusive && this.min == n))
) { ) {
throw new Error(this.minMessage()) throw new Error(this.minMessage())
@@ -31,11 +31,11 @@ export class Range {
} }
} }
hasMin(): boolean { hasMin(): this is Range & { min: number } {
return this.min !== undefined return this.min !== undefined
} }
hasMax(): boolean { hasMax(): this is Range & { max: number } {
return this.max !== undefined return this.max !== undefined
} }

View File

@@ -177,8 +177,6 @@ export module Mock {
nullable: true, nullable: true,
masked: false, masked: false,
copyable: false, copyable: false,
pattern: null,
'pattern-description': null,
warning: 'You may loose all your money by providing your name.', warning: 'You may loose all your money by providing your name.',
}, },
notifications: { notifications: {
@@ -213,7 +211,6 @@ export module Mock {
name: 'Top Speed', name: 'Top Speed',
description: 'The fastest you can possibly run.', description: 'The fastest you can possibly run.',
nullable: false, nullable: false,
default: null,
range: '[-1000, 1000]', range: '[-1000, 1000]',
integral: false, integral: false,
units: 'm/s', units: 'm/s',
@@ -248,7 +245,6 @@ export module Mock {
name: { name: {
type: 'string', type: 'string',
name: 'Name', name: 'Name',
description: null,
nullable: false, nullable: false,
masked: false, masked: false,
copyable: false, copyable: false,
@@ -258,7 +254,6 @@ export module Mock {
email: { email: {
type: 'string', type: 'string',
name: 'Email', name: 'Email',
description: null,
nullable: false, nullable: false,
masked: false, masked: false,
copyable: true, copyable: true,
@@ -1187,7 +1182,6 @@ export module Mock {
type: 'string', type: 'string',
description: 'User first name', description: 'User first name',
nullable: true, nullable: true,
default: null,
masked: false, masked: false,
copyable: false, copyable: false,
}, },
@@ -1210,7 +1204,6 @@ export module Mock {
type: 'number', type: 'number',
description: 'The age of the user', description: 'The age of the user',
nullable: true, nullable: true,
default: null,
integral: false, integral: false,
warning: 'User must be at least 18.', warning: 'User must be at least 18.',
range: '[18,*)', range: '[18,*)',

View File

@@ -265,7 +265,7 @@ export module RR {
} }
export type WithExpire<T> = { 'expire-id'?: string } & T export type WithExpire<T> = { 'expire-id'?: string } & T
export type WithRevision<T> = { response: T; revision?: Revision } export type WithRevision<T> = { response: T | null; revision?: Revision }
export interface MarketplaceEOS { export interface MarketplaceEOS {
version: string version: string

View File

@@ -99,7 +99,7 @@ export class MockApiService extends ApiService {
async killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> { async killSessions(params: RR.KillSessionsReq): Promise<RR.KillSessionsRes> {
await pauseFor(2000) await pauseFor(2000)
return null return { response: null }
} }
// server // server
@@ -749,13 +749,14 @@ export class MockApiService extends ApiService {
{ progress: 'downloaded', completion: 'download-complete' }, { progress: 'downloaded', completion: 'download-complete' },
{ progress: 'validated', completion: 'validation-complete' }, { progress: 'validated', completion: 'validation-complete' },
{ progress: 'unpacked', completion: 'unpack-complete' }, { progress: 'unpacked', completion: 'unpack-complete' },
] ] as const
for (let phase of phases) { for (let phase of phases) {
let i = progress[phase.progress] let i = progress[phase.progress]
while (i < progress.size) { const size = progress?.size || 0
while (i < size) {
await pauseFor(250) await pauseFor(250)
i = Math.min(i + 5, progress.size) i = Math.min(i + 5, size)
progress[phase.progress] = i progress[phase.progress] = i
if (i === progress.size) { if (i === progress.size) {
@@ -858,7 +859,7 @@ export class MockApiService extends ApiService {
private async withRevision<T>( private async withRevision<T>(
patch: Operation<unknown>[], patch: Operation<unknown>[],
response: T = null, response: T | null = null,
): Promise<WithRevision<T>> { ): Promise<WithRevision<T>> {
if (!this.sequence) { if (!this.sequence) {
const { sequence } = await this.bootstrapper.init() const { sequence } = await this.bootstrapper.init()

View File

@@ -11,12 +11,9 @@ import {
export const mockPatchData: DataModel = { export const mockPatchData: DataModel = {
ui: { ui: {
name: `Matt's Embassy`, name: `Matt's Embassy`,
'auto-check-updates': undefined, 'auto-check-updates': false,
'pkg-order': [], 'pkg-order': [],
'ack-welcome': '1.0.0', 'ack-welcome': '1.0.0',
marketplace: undefined,
dev: undefined,
gaming: undefined,
}, },
'server-info': { 'server-info': {
id: 'abcdefgh', id: 'abcdefgh',
@@ -212,8 +209,6 @@ export const mockPatchData: DataModel = {
nullable: true, nullable: true,
masked: false, masked: false,
copyable: false, copyable: false,
pattern: null,
'pattern-description': null,
warning: 'You may loose all your money by providing your name.', warning: 'You may loose all your money by providing your name.',
}, },
notifications: { notifications: {
@@ -248,7 +243,6 @@ export const mockPatchData: DataModel = {
name: 'Top Speed', name: 'Top Speed',
description: 'The fastest you can possibly run.', description: 'The fastest you can possibly run.',
nullable: false, nullable: false,
default: null,
range: '[-1000, 1000]', range: '[-1000, 1000]',
integral: false, integral: false,
units: 'm/s', units: 'm/s',
@@ -283,7 +277,6 @@ export const mockPatchData: DataModel = {
name: { name: {
type: 'string', type: 'string',
name: 'Name', name: 'Name',
description: null,
nullable: false, nullable: false,
masked: false, masked: false,
copyable: false, copyable: false,
@@ -293,7 +286,6 @@ export const mockPatchData: DataModel = {
email: { email: {
type: 'string', type: 'string',
name: 'Email', name: 'Email',
description: null,
nullable: false, nullable: false,
masked: false, masked: false,
copyable: true, copyable: true,

View File

@@ -28,7 +28,7 @@ export class ConfigService {
api = api api = api
marketplace = marketplace marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts skipStartupAlerts = useMocks && mocks.skipStartupAlerts
isConsulate = window['platform'] === 'ios' isConsulate = (window as any)['platform'] === 'ios'
supportsWebSockets = !!window.WebSocket || this.isConsulate supportsWebSockets = !!window.WebSocket || this.isConsulate
isTor(): boolean { isTor(): boolean {
@@ -76,14 +76,20 @@ export function hasLanUi(interfaces: Record<string, InterfaceDef>): boolean {
return !!int?.['lan-config'] return !!int?.['lan-config']
} }
export function torUiAddress(pkg: PackageDataEntry): string { export function torUiAddress({
const key = getUiInterfaceKey(pkg.manifest.interfaces) manifest,
return pkg.installed['interface-addresses'][key]['tor-address'] installed,
}: PackageDataEntry): string {
const key = getUiInterfaceKey(manifest.interfaces)
return installed ? installed['interface-addresses'][key]['tor-address'] : ''
} }
export function lanUiAddress(pkg: PackageDataEntry): string { export function lanUiAddress({
const key = getUiInterfaceKey(pkg.manifest.interfaces) manifest,
return pkg.installed['interface-addresses'][key]['lan-address'] installed,
}: PackageDataEntry): string {
const key = getUiInterfaceKey(manifest.interfaces)
return installed ? installed['interface-addresses'][key]['lan-address'] : ''
} }
export function hasUi(interfaces: Record<string, InterfaceDef>): boolean { export function hasUi(interfaces: Record<string, InterfaceDef>): boolean {
@@ -103,11 +109,11 @@ export function removePort(str: string): string {
export function getUiInterfaceKey( export function getUiInterfaceKey(
interfaces: Record<string, InterfaceDef>, interfaces: Record<string, InterfaceDef>,
): string { ): string {
return Object.keys(interfaces).find(key => interfaces[key].ui) return Object.keys(interfaces).find(key => interfaces[key].ui) || ''
} }
export function getUiInterfaceValue( export function getUiInterfaceValue(
interfaces: Record<string, InterfaceDef>, interfaces: Record<string, InterfaceDef>,
): InterfaceDef { ): InterfaceDef | null {
return Object.values(interfaces).find(i => i.ui) return Object.values(interfaces).find(i => i.ui) || null
} }

View File

@@ -86,7 +86,7 @@ export class FormService {
validators: ValidatorFn[] = [], validators: ValidatorFn[] = [],
current: { [key: string]: any } = {}, current: { [key: string]: any } = {},
): FormGroup { ): FormGroup {
let group = {} let group: Record<string, FormGroup | FormArray | FormControl> = {}
Object.entries(config).map(([key, spec]) => { Object.entries(config).map(([key, spec]) => {
if (spec.type === 'pointer') return if (spec.type === 'pointer') return
group[key] = this.getFormEntry(spec, current ? current[key] : undefined) group[key] = this.getFormEntry(spec, current ? current[key] : undefined)
@@ -137,6 +137,8 @@ export class FormService {
case 'enum': case 'enum':
value = currentValue === undefined ? spec.default : currentValue value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value) return this.formBuilder.control(value)
default:
return this.formBuilder.control(null)
} }
} }
} }
@@ -298,25 +300,20 @@ export function listUnique(spec: ValueSpecList): ValidatorFn {
} }
function listItemEquals(spec: ValueSpecList, val1: any, val2: any): boolean { function listItemEquals(spec: ValueSpecList, val1: any, val2: any): boolean {
// TODO: fix types
switch (spec.subtype) { switch (spec.subtype) {
case 'string': case 'string':
case 'number': case 'number':
case 'enum': case 'enum':
return val1 == val2 return val1 == val2
case 'object': case 'object':
return listObjEquals( const obj: ListValueSpecObject = spec.spec as any
spec.spec['unique-by'],
spec.spec as ListValueSpecObject, return listObjEquals(obj['unique-by'], obj, val1, val2)
val1,
val2,
)
case 'union': case 'union':
return unionEquals( const union: ListValueSpecUnion = spec.spec as any
spec.spec['unique-by'],
spec.spec as ListValueSpecUnion, return unionEquals(union['unique-by'], union, val1, val2)
val1,
val2,
)
default: default:
return false return false
} }
@@ -330,9 +327,21 @@ function itemEquals(spec: ValueSpec, val1: any, val2: any): boolean {
case 'enum': case 'enum':
return val1 == val2 return val1 == val2
case 'object': case 'object':
return objEquals(spec['unique-by'], spec as ValueSpecObject, val1, val2) // TODO: 'unique-by' does not exist on ValueSpecObject, fix types
return objEquals(
(spec as any)['unique-by'],
spec as ValueSpecObject,
val1,
val2,
)
case 'union': case 'union':
return unionEquals(spec['unique-by'], spec as ValueSpecUnion, val1, val2) // TODO: 'unique-by' does not exist on ValueSpecUnion, fix types
return unionEquals(
(spec as any)['unique-by'],
spec as ValueSpecUnion,
val1,
val2,
)
case 'list': case 'list':
if (val1.length !== val2.length) { if (val1.length !== val2.length) {
return false return false
@@ -373,6 +382,7 @@ function listObjEquals(
} }
return true return true
} }
return false
} }
function objEquals( function objEquals(
@@ -384,7 +394,8 @@ function objEquals(
if (uniqueBy === null) { if (uniqueBy === null) {
return false return false
} else if (typeof uniqueBy === 'string') { } else if (typeof uniqueBy === 'string') {
return itemEquals(spec[uniqueBy], val1[uniqueBy], val2[uniqueBy]) // TODO: fix types
return itemEquals((spec as any)[uniqueBy], val1[uniqueBy], val2[uniqueBy])
} else if ('any' in uniqueBy) { } else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) { for (let subSpec of uniqueBy.any) {
if (objEquals(subSpec, spec, val1, val2)) { if (objEquals(subSpec, spec, val1, val2)) {
@@ -400,6 +411,7 @@ function objEquals(
} }
return true return true
} }
return false
} }
function unionEquals( function unionEquals(
@@ -433,12 +445,13 @@ function unionEquals(
} }
return true return true
} }
return false
} }
function uniqueByMessageWrapper( function uniqueByMessageWrapper(
uniqueBy: UniqueBy, uniqueBy: UniqueBy,
spec: ListValueSpecObject | ListValueSpecUnion, spec: ListValueSpecObject | ListValueSpecUnion,
obj: object, obj: Record<string, string>,
) { ) {
let configSpec: ConfigSpec let configSpec: ConfigSpec
if (isUnion(spec)) { if (isUnion(spec)) {
@@ -460,9 +473,9 @@ function uniqueByMessage(
outermost = true, outermost = true,
): string { ): string {
let joinFunc let joinFunc
const subSpecs = [] const subSpecs: string[] = []
if (uniqueBy === null) { if (uniqueBy === null) {
return null return ''
} else if (typeof uniqueBy === 'string') { } else if (typeof uniqueBy === 'string') {
return configSpec[uniqueBy] ? configSpec[uniqueBy].name : uniqueBy return configSpec[uniqueBy] ? configSpec[uniqueBy].name : uniqueBy
} else if ('any' in uniqueBy) { } else if ('any' in uniqueBy) {
@@ -476,7 +489,7 @@ function uniqueByMessage(
subSpecs.push(uniqueByMessage(subSpec, configSpec, false)) subSpecs.push(uniqueByMessage(subSpec, configSpec, false))
} }
} }
const ret = subSpecs.filter(ss => ss).join(joinFunc) const ret = subSpecs.filter(Boolean).join(joinFunc)
return outermost || subSpecs.filter(ss => ss).length === 1 return outermost || subSpecs.filter(ss => ss).length === 1
? ret ? ret
: '(' + ret + ')' : '(' + ret + ')'
@@ -486,7 +499,7 @@ function isObjectOrUnion(
spec: ListValueSpecOf<any>, spec: ListValueSpecOf<any>,
): spec is ListValueSpecObject | ListValueSpecUnion { ): spec is ListValueSpecObject | ListValueSpecUnion {
// only lists of objects and unions have unique-by // only lists of objects and unions have unique-by
return spec['unique-by'] !== undefined return 'unique-by' in spec
} }
function isUnion(spec: any): spec is ListValueSpecUnion { function isUnion(spec: any): spec is ListValueSpecUnion {
@@ -499,18 +512,20 @@ export function convertValuesRecursive(
group: FormGroup, group: FormGroup,
) { ) {
Object.entries(configSpec).forEach(([key, valueSpec]) => { Object.entries(configSpec).forEach(([key, valueSpec]) => {
const control = group.get(key)
if (!control) return
if (valueSpec.type === 'number') { if (valueSpec.type === 'number') {
const control = group.get(key)
control.setValue(control.value ? Number(control.value) : null) control.setValue(control.value ? Number(control.value) : null)
} else if (valueSpec.type === 'string') { } else if (valueSpec.type === 'string') {
const control = group.get(key)
if (!control.value) control.setValue(null) if (!control.value) control.setValue(null)
} else if (valueSpec.type === 'object') { } else if (valueSpec.type === 'object') {
convertValuesRecursive(valueSpec.spec, group.get(key) as FormGroup) convertValuesRecursive(valueSpec.spec, group.get(key) as FormGroup)
} else if (valueSpec.type === 'union') { } else if (valueSpec.type === 'union') {
const control = group.get(key) as FormGroup const formGr = group.get(key) as FormGroup
const spec = valueSpec.variants[control.controls[valueSpec.tag.id].value] const spec = valueSpec.variants[formGr.controls[valueSpec.tag.id].value]
convertValuesRecursive(spec, control) convertValuesRecursive(spec, formGr)
} else if (valueSpec.type === 'list') { } else if (valueSpec.type === 'list') {
const formArr = group.get(key) as FormArray const formArr = group.get(key) as FormArray
const { controls } = formArr const { controls } = formArr

View File

@@ -1,10 +1,5 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
HttpClient,
HttpErrorResponse,
HttpHeaders,
HttpParams,
} from '@angular/common/http'
import { Observable, from, interval, race } from 'rxjs' import { Observable, from, interval, race } from 'rxjs'
import { map, take } from 'rxjs/operators' import { map, take } from 'rxjs/operators'
import { ConfigService } from './config.service' import { ConfigService } from './config.service'
@@ -27,6 +22,7 @@ export class HttpService {
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}` this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}`
} }
// @ts-ignore TODO: fix typing
async rpcRequest<T>(rpcOpts: RPCOptions): Promise<T> { async rpcRequest<T>(rpcOpts: RPCOptions): Promise<T> {
const { url, version } = this.config.api const { url, version } = this.config.api
rpcOpts.params = rpcOpts.params || {} rpcOpts.params = rpcOpts.params || {}
@@ -53,12 +49,15 @@ export class HttpService {
const urlIsRelative = httpOpts.url.startsWith('/') const urlIsRelative = httpOpts.url.startsWith('/')
const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url const url = urlIsRelative ? this.fullUrl + httpOpts.url : httpOpts.url
const { params } = httpOpts
Object.keys(httpOpts.params || {}).forEach(key => { if (hasParams(params)) {
if (httpOpts.params[key] === undefined) { Object.keys(params).forEach(key => {
delete httpOpts.params[key] if (params[key] === undefined) {
} delete params[key]
}) }
})
}
const options = { const options = {
responseType: httpOpts.responseType || 'json', responseType: httpOpts.responseType || 'json',
@@ -181,6 +180,12 @@ export interface HttpOptions {
timeout?: number timeout?: number
} }
function hasParams(
params?: HttpOptions['params'],
): params is Record<string, string | string[]> {
return !!params
}
function withTimeout<U>(req: Observable<U>, timeout: number): Observable<U> { function withTimeout<U>(req: Observable<U>, timeout: number): Observable<U> {
return race( return race(
from(req.toPromise()), // this guarantees it only emits on completion, intermediary emissions are suppressed. from(req.toPromise()), // this guarantees it only emits on completion, intermediary emissions are suppressed.

View File

@@ -5,16 +5,20 @@ import {
MarketplacePkg, MarketplacePkg,
AbstractMarketplaceService, AbstractMarketplaceService,
Marketplace, Marketplace,
MarketplaceData,
} from '@start9labs/marketplace' } from '@start9labs/marketplace'
import { defer, from, Observable, of } from 'rxjs' import { defer, from, Observable, of } from 'rxjs'
import { RR } from 'src/app/services/api/api.types' import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { ServerInfo } from 'src/app/services/patch-db/data-model' import {
ServerInfo,
UIMarketplaceData,
} from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { import {
catchError, catchError,
distinctUntilChanged,
filter,
map, map,
shareReplay, shareReplay,
startWith, startWith,
@@ -27,35 +31,56 @@ import {
export class MarketplaceService extends AbstractMarketplaceService { export class MarketplaceService extends AbstractMarketplaceService {
private readonly notes = new Map<string, Record<string, string>>() private readonly notes = new Map<string, Record<string, string>>()
private readonly init$: Observable<Marketplace> = this.patch private readonly altMarketplaceData$: Observable<
.watch$('ui', 'marketplace') UIMarketplaceData | undefined
.pipe( > = this.patch.watch$('ui').pipe(
map(marketplace => map(ui => ui.marketplace),
marketplace?.['selected-id'] distinctUntilChanged(),
? marketplace['known-hosts'][marketplace['selected-id']] shareReplay({ bufferSize: 1, refCount: true }),
: this.config.marketplace, )
),
shareReplay(),
)
private readonly data$: Observable<MarketplaceData> = this.init$.pipe( private readonly marketplace$ = this.altMarketplaceData$.pipe(
map(data => this.toMarketplace(data)),
)
private readonly serverInfo$: Observable<ServerInfo> = this.patch
.watch$('server-info')
.pipe(take(1), shareReplay({ bufferSize: 1, refCount: true }))
private readonly categories$: Observable<string[]> = this.marketplace$.pipe(
switchMap(({ url }) => switchMap(({ url }) =>
from(this.getMarketplaceData({ 'server-id': this.serverInfo.id }, url)), this.serverInfo$.pipe(
), switchMap(({ id }) =>
shareReplay(), from(this.getMarketplaceData({ 'server-id': id }, url)),
) ),
private readonly pkg$: Observable<MarketplacePkg[]> = this.init$.pipe(
take(1),
switchMap(({ url, name }) =>
from(this.getMarketplacePkgs({ page: 1, 'per-page': 100 }, url)).pipe(
tap(() => this.onPackages(name)),
), ),
), ),
shareReplay(), map(({ categories }) => categories),
catchError(e => this.errToast.present(e) && of([])),
) )
private readonly pkg$: Observable<MarketplacePkg[]> =
this.altMarketplaceData$.pipe(
switchMap(data =>
this.serverInfo$.pipe(
switchMap(info =>
from(
this.getMarketplacePkgs(
{ page: 1, 'per-page': 100 },
this.toMarketplace(data).url,
info['eos-version-compat'],
),
).pipe(tap(() => this.onPackages(data))),
),
),
),
catchError(e => {
this.errToast.present(e)
return of([])
}),
shareReplay({ bufferSize: 1, refCount: true }),
)
constructor( constructor(
private readonly api: ApiService, private readonly api: ApiService,
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
@@ -68,22 +93,29 @@ export class MarketplaceService extends AbstractMarketplaceService {
} }
getMarketplace(): Observable<Marketplace> { getMarketplace(): Observable<Marketplace> {
return this.init$ return this.marketplace$
} }
getCategories(): Observable<string[]> { getCategories(): Observable<string[]> {
return this.data$.pipe(map(({ categories }) => categories)) return this.categories$
} }
getPackages(): Observable<MarketplacePkg[]> { getPackages(): Observable<MarketplacePkg[]> {
return this.pkg$ return this.pkg$
} }
getPackage(id: string, version: string): Observable<MarketplacePkg> { getPackage(id: string, version: string): Observable<MarketplacePkg | null> {
const params = { ids: [{ id, version }] } const params = { ids: [{ id, version }] }
const fallback$ = this.init$.pipe( const fallback$ = this.marketplace$.pipe(
take(1), switchMap(({ url }) =>
switchMap(({ url }) => from(this.getMarketplacePkgs(params, url))), this.serverInfo$.pipe(
switchMap(info =>
from(
this.getMarketplacePkgs(params, url, info['eos-version-compat']),
),
),
),
),
map(pkgs => this.findPackage(pkgs, id, version)), map(pkgs => this.findPackage(pkgs, id, version)),
startWith(null), startWith(null),
) )
@@ -91,23 +123,29 @@ export class MarketplaceService extends AbstractMarketplaceService {
return this.getPackages().pipe( return this.getPackages().pipe(
map(pkgs => this.findPackage(pkgs, id, version)), map(pkgs => this.findPackage(pkgs, id, version)),
switchMap(pkg => (pkg ? of(pkg) : fallback$)), switchMap(pkg => (pkg ? of(pkg) : fallback$)),
tap(pkg => { filter((pkg): pkg is MarketplacePkg | null => {
if (pkg === undefined) { if (pkg === undefined) {
throw new Error(`No results for ${id}${version ? ' ' + version : ''}`) throw new Error(`No results for ${id}${version ? ' ' + version : ''}`)
} }
return true
}), }),
) )
} }
getReleaseNotes(id: string): Observable<Record<string, string>> { getReleaseNotes(id: string): Observable<Record<string, string>> {
if (this.notes.has(id)) { if (this.notes.has(id)) {
return of(this.notes.get(id)) return of(this.notes.get(id) || {})
} }
return this.init$.pipe( return this.marketplace$.pipe(
switchMap(({ url }) => this.loadReleaseNotes(id, url)), switchMap(({ url }) => this.loadReleaseNotes(id, url)),
tap(response => this.notes.set(id, response)), tap(response => this.notes.set(id, response)),
catchError(e => this.errToast.present(e) && of({})), catchError(e => {
this.errToast.present(e)
return of({})
}),
) )
} }
@@ -173,22 +211,19 @@ export class MarketplaceService extends AbstractMarketplaceService {
async getMarketplacePkgs( async getMarketplacePkgs(
params: Omit<RR.GetMarketplacePackagesReq, 'eos-version-compat'>, params: Omit<RR.GetMarketplacePackagesReq, 'eos-version-compat'>,
url: string, url: string,
eosVersionCompat: string,
): Promise<RR.GetMarketplacePackagesRes> { ): Promise<RR.GetMarketplacePackagesRes> {
if (params.query) delete params.category if (params.query) delete params.category
if (params.ids) params.ids = JSON.stringify(params.ids) if (params.ids) params.ids = JSON.stringify(params.ids)
const qp: RR.GetMarketplacePackagesReq = { const qp: RR.GetMarketplacePackagesReq = {
...params, ...params,
'eos-version-compat': this.serverInfo['eos-version-compat'], 'eos-version-compat': eosVersionCompat,
} }
return this.api.marketplaceProxy('/package/v0/index', qp, url) return this.api.marketplaceProxy('/package/v0/index', qp, url)
} }
private get serverInfo(): ServerInfo {
return this.patch.getData()['server-info']
}
private loadReleaseNotes( private loadReleaseNotes(
id: string, id: string,
url: string, url: string,
@@ -202,15 +237,15 @@ export class MarketplaceService extends AbstractMarketplaceService {
) )
} }
private onPackages(name: string) { private onPackages(data?: UIMarketplaceData) {
const { marketplace } = this.patch.getData().ui const { name } = this.toMarketplace(data)
if (!marketplace?.['selected-id']) { if (!data?.['selected-id']) {
return return
} }
const selectedId = marketplace['selected-id'] const selectedId = data['selected-id']
const knownHosts = marketplace['known-hosts'] const knownHosts = data['known-hosts']
if (knownHosts[selectedId].name !== name) { if (knownHosts[selectedId].name !== name) {
this.api.setDbValue({ this.api.setDbValue({
@@ -220,6 +255,12 @@ export class MarketplaceService extends AbstractMarketplaceService {
} }
} }
private toMarketplace(marketplace?: UIMarketplaceData): Marketplace {
return marketplace?.['selected-id']
? marketplace['known-hosts'][marketplace['selected-id']]
: this.config.marketplace
}
private findPackage( private findPackage(
pkgs: readonly MarketplacePkg[], pkgs: readonly MarketplacePkg[],
id: string, id: string,

View File

@@ -17,15 +17,13 @@ export interface UIData {
'auto-check-updates': boolean 'auto-check-updates': boolean
'pkg-order': string[] 'pkg-order': string[]
'ack-welcome': string // EOS version 'ack-welcome': string // EOS version
marketplace: UIMarketplaceData marketplace?: UIMarketplaceData
dev: DevData dev?: DevData
gaming: gaming?: {
| { snake: {
snake: { 'high-score': number
'high-score': number }
} }
}
| undefined
} }
export interface UIMarketplaceData { export interface UIMarketplaceData {
@@ -113,7 +111,7 @@ export interface CurrentDependencyInfo {
'health-checks': string[] // array of health check IDs 'health-checks': string[] // array of health check IDs
} }
export interface Manifest extends MarketplaceManifest<DependencyConfig> { export interface Manifest extends MarketplaceManifest<DependencyConfig | null> {
main: ActionImpl main: ActionImpl
'health-checks': Record< 'health-checks': Record<
string, string,
@@ -124,7 +122,7 @@ export interface Manifest extends MarketplaceManifest<DependencyConfig> {
'min-os-version': string 'min-os-version': string
interfaces: Record<string, InterfaceDef> interfaces: Record<string, InterfaceDef>
backup: BackupActions backup: BackupActions
migrations: Migrations migrations: Migrations | null
actions: Record<string, Action> actions: Record<string, Action>
permissions: any // @TODO 0.3.1 permissions: any // @TODO 0.3.1
} }
@@ -155,8 +153,8 @@ export enum DockerIoFormat {
} }
export interface ConfigActions { export interface ConfigActions {
get: ActionImpl get: ActionImpl | null
set: ActionImpl set: ActionImpl | null
} }
export type Volume = VolumeData export type Volume = VolumeData
@@ -229,7 +227,7 @@ export interface Action {
warning: string | null warning: string | null
implementation: ActionImpl implementation: ActionImpl
'allowed-statuses': (PackageMainStatus.Stopped | PackageMainStatus.Running)[] 'allowed-statuses': (PackageMainStatus.Stopped | PackageMainStatus.Running)[]
'input-spec': ConfigSpec 'input-spec': ConfigSpec | null
} }
export interface Status { export interface Status {

View File

@@ -36,8 +36,8 @@ export function realSourceFactory(
{ defaultView }: Document, { defaultView }: Document,
): Source<DataModel>[] { ): Source<DataModel>[] {
const { patchDb } = config const { patchDb } = config
const { host } = defaultView.location const host = defaultView?.location.host
const protocol = defaultView.location.protocol === 'http:' ? 'ws' : 'wss' const protocol = defaultView?.location.protocol === 'http:' ? 'ws' : 'wss'
return [ return [
new WebsocketSource<DataModel>(`${protocol}://${host}/ws/db`), new WebsocketSource<DataModel>(`${protocol}://${host}/ws/db`),

View File

@@ -17,7 +17,7 @@ export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
let dependency: DependencyStatus | null = null let dependency: DependencyStatus | null = null
let health: HealthStatus | null = null let health: HealthStatus | null = null
if (pkg.state === PackageState.Installed) { if (pkg.state === PackageState.Installed && pkg.installed) {
primary = getPrimaryStatus(pkg.installed.status) primary = getPrimaryStatus(pkg.installed.status)
dependency = getDependencyStatus(pkg) dependency = getDependencyStatus(pkg)
health = getHealthStatus(pkg.installed.status) health = getHealthStatus(pkg.installed.status)
@@ -36,9 +36,10 @@ function getPrimaryStatus(status: Status): PrimaryStatus {
} }
} }
function getDependencyStatus(pkg: PackageDataEntry): DependencyStatus { function getDependencyStatus(pkg: PackageDataEntry): DependencyStatus | null {
const installed = pkg.installed const installed = pkg.installed
if (isEmptyObject(installed['current-dependencies'])) return null if (!installed || isEmptyObject(installed['current-dependencies']))
return null
const depErrors = installed.status['dependency-errors'] const depErrors = installed.status['dependency-errors']
const depIds = Object.keys(depErrors).filter(key => !!depErrors[key]) const depIds = Object.keys(depErrors).filter(key => !!depErrors[key])
@@ -46,9 +47,9 @@ function getDependencyStatus(pkg: PackageDataEntry): DependencyStatus {
return depIds.length ? DependencyStatus.Warning : DependencyStatus.Satisfied return depIds.length ? DependencyStatus.Warning : DependencyStatus.Satisfied
} }
function getHealthStatus(status: Status): HealthStatus { function getHealthStatus(status: Status): HealthStatus | null {
if (status.main.status !== PackageMainStatus.Running || !status.main.health) { if (status.main.status !== PackageMainStatus.Running || !status.main.health) {
return return null
} }
const values = Object.values(status.main.health) const values = Object.values(status.main.health)

View File

@@ -16,7 +16,10 @@ export class ServerConfigService {
private readonly embassyApi: ApiService, private readonly embassyApi: ApiService,
) {} ) {}
async presentAlert(key: string, current?: any): Promise<HTMLIonAlertElement> { async presentAlert(
key: string,
current?: any,
): Promise<HTMLIonAlertElement | null> {
const spec = serverConfig[key] const spec = serverConfig[key]
let inputs: AlertInput[] let inputs: AlertInput[]
@@ -66,7 +69,7 @@ export class ServerConfigService {
] ]
break break
default: default:
return return null
} }
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({

View File

@@ -13,7 +13,7 @@ export class UiLauncherService {
) {} ) {}
launch(pkg: PackageDataEntry): void { launch(pkg: PackageDataEntry): void {
this.document.defaultView.open( this.document.defaultView?.open(
this.config.launchableURL(pkg), this.config.launchableURL(pkg),
'_blank', '_blank',
'noreferrer', 'noreferrer',

View File

@@ -0,0 +1,11 @@
import { ActivatedRoute } from '@angular/router'
export function getProjectId({ snapshot }: ActivatedRoute): string {
const projectId = snapshot.paramMap.get('projectId')
if (!projectId) {
throw new Error('projectId is missing from route params')
}
return projectId
}

View File

@@ -3,9 +3,9 @@ import { InstallProgress } from 'src/app/types/install-progress'
import { ProgressData } from 'src/app/types/progress-data' import { ProgressData } from 'src/app/types/progress-data'
export function packageLoadingProgress( export function packageLoadingProgress(
loadData: InstallProgress, loadData?: InstallProgress,
): ProgressData | null { ): ProgressData | null {
if (isEmptyObject(loadData)) { if (!loadData || isEmptyObject(loadData)) {
return null return null
} }
@@ -20,6 +20,7 @@ export function packageLoadingProgress(
} = loadData } = loadData
// only permit 100% when "complete" == true // only permit 100% when "complete" == true
size = size || 0
downloaded = downloadComplete ? size : Math.max(downloaded - 1, 0) downloaded = downloadComplete ? size : Math.max(downloaded - 1, 0)
validated = validationComplete ? size : Math.max(validated - 1, 0) validated = validationComplete ? size : Math.max(validated - 1, 0)
unpacked = unpackComplete ? size : Math.max(unpacked - 1, 0) unpacked = unpackComplete ? size : Math.max(unpacked - 1, 0)

View File

@@ -5,12 +5,12 @@ import {
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
export function parseDataModel(data: DataModel): ParsedData { export function parseDataModel(data: DataModel): ParsedData {
const all = JSON.parse(JSON.stringify(data['package-data'])) as { const all: Record<string, PackageDataEntry> = JSON.parse(
[id: string]: PackageDataEntry JSON.stringify(data['package-data']),
} )
const order = [...(data.ui['pkg-order'] || [])] const order = [...(data.ui['pkg-order'] || [])]
const pkgs = [] const pkgs: PackageDataEntry[] = []
const recoveredPkgs = Object.entries(data['recovered-packages']) const recoveredPkgs = Object.entries(data['recovered-packages'])
.filter(([id, _]) => !all[id]) .filter(([id, _]) => !all[id])
.map(([id, val]) => ({ .map(([id, val]) => ({

View File

@@ -29,7 +29,7 @@ const matchPropertiesV1 = shape(
qr: boolean, qr: boolean,
}, },
['description', 'copyable', 'qr'], ['description', 'copyable', 'qr'],
{ description: null as null, copyable: false, qr: false } as const, { copyable: false, qr: false } as const,
) )
type PropertiesV1 = typeof matchPropertiesV1._TYPE type PropertiesV1 = typeof matchPropertiesV1._TYPE
@@ -49,7 +49,6 @@ const matchPackagePropertyString = shape(
}, },
['description', 'copyable', 'qr', 'masked'], ['description', 'copyable', 'qr', 'masked'],
{ {
description: null as null,
copyable: false, copyable: false,
qr: false, qr: false,
masked: false, masked: false,
@@ -100,16 +99,16 @@ export function parsePropertiesPermissive(
name, name,
value: { value: {
value: String(value), value: String(value),
description: null,
copyable: false, copyable: false,
qr: false, qr: false,
masked: false, masked: false,
}, },
})) }))
.reduce((acc, { name, value }) => { .reduce((acc, { name, value }) => {
acc[name] = value // TODO: Fix type
acc[name] = value as any
return acc return acc
}, {}) }, {} as PackageProperties)
} }
switch (properties.version) { switch (properties.version) {
case 1: case 1:

View File

@@ -8,13 +8,14 @@
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"alwaysStrict": true, "alwaysStrict": true,
// "strictNullChecks": true, "strictNullChecks": true,
"strictBindCallApply": true, "strictBindCallApply": true,
"strictFunctionTypes": true, "strictFunctionTypes": true,
// "strictPropertyInitialization": true, // "strictPropertyInitialization": true,
// "noImplicitAny": true, "noImplicitAny": true,
"noImplicitThis": true, "noImplicitThis": true,
"useUnknownInCatchVariables": true, "useUnknownInCatchVariables": true,