Feat/community marketplace (#1790)

* add community marketplace

* Update embassy-mock-api.service.ts

* expect ui/marketplace to be undefined

* possible undefined from getpackage

* fix marketplace pages

* rework marketplace infrastructure

* fix bugs

Co-authored-by: Lucy C <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Matt Hill
2022-10-06 17:27:17 -06:00
committed by Aiden McClelland
parent e2db3d84d8
commit 9998ed177b
55 changed files with 754 additions and 879 deletions

View File

@@ -9,10 +9,6 @@
"mocks": {
"maskAs": "tor",
"skipStartupAlerts": true
},
"marketplace": {
"url": "https://registry.start9.com/",
"name": "Start9 Marketplace"
}
},
"gitHash": ""

View File

@@ -1,11 +1,13 @@
{
"name": null,
"auto-check-updates": true,
"pkg-order": [],
"ack-welcome": "0.3.2.1",
"ack-welcome": "0.3.3",
"marketplace": {
"selected-id": null,
"known-hosts": {}
"selected-url": "https://registry.start9.com/",
"known-hosts": {
"https://registry.start9.com/": "Start9 Marketplace",
"https://community-registry.start9.com/": "Community Marketplace"
}
},
"dev": {},
"gaming": {

View File

@@ -17,13 +17,13 @@ import {
})
export class CategoriesComponent {
@Input()
categories = new Set<string>()
categories!: Set<string>
@Input()
category = ''
category!: string
@Input()
updatesAvailable = 0
updatesAvailable!: number
@Output()
readonly categoryChange = new EventEmitter<string>()

View File

@@ -14,7 +14,7 @@ export class ReleaseNotesComponent {
private selected: string | null = null
readonly notes$ = this.marketplaceService.getReleaseNotes(this.pkgId)
readonly notes$ = this.marketplaceService.fetchReleaseNotes(this.pkgId)
constructor(
private readonly route: ActivatedRoute,

View File

@@ -7,7 +7,6 @@ import {
} from '@angular/core'
import { AlertController, ModalController } from '@ionic/angular'
import { displayEmver, Emver, MarkdownComponent } from '@start9labs/shared'
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
import { MarketplacePkg } from '../../../types/marketplace-pkg'
@@ -58,9 +57,9 @@ export class AdditionalComponent {
}
async presentModalMd(title: string) {
const content = this.marketplaceService.getPackageMarkdown(
title,
const content = this.marketplaceService.fetchPackageMarkdown(
this.pkg.manifest.id,
title,
)
const modal = await this.modalCtrl.create({

View File

@@ -7,8 +7,7 @@
<div class="text">
<h1 class="title">{{ pkg.manifest.title }}</h1>
<p class="version">{{ pkg.manifest.version | displayEmver }}</p>
<!-- @TODO remove conditional when registry code deployed. published-at will be required -->
<p *ngIf="pkg['published-at']" class="published">
<p class="published">
Released: {{ pkg['published-at'] | date: 'medium' }}
</p>
<ng-content></ng-content>

View File

@@ -1,9 +1,8 @@
import { NgModule, Pipe, PipeTransform } from '@angular/core'
import Fuse from 'fuse.js'
import { MarketplacePkg } from '../types/marketplace-pkg'
import { MarketplaceManifest } from '../types/marketplace-manifest'
import { Emver } from '@start9labs/shared'
import Fuse from 'fuse.js'
@Pipe({
name: 'filterPackages',

View File

@@ -26,7 +26,6 @@ export * from './pipes/filter-packages.pipe'
export * from './services/marketplace.service'
export * from './types/dependency'
export * from './types/marketplace'
export * from './types/marketplace-data'
export * from './types/marketplace-info'
export * from './types/marketplace-manifest'
export * from './types/marketplace-pkg'

View File

@@ -1,20 +1,26 @@
import { Observable } from 'rxjs'
import { MarketplaceInfo } from '../types/marketplace-info'
import { MarketplacePkg } from '../types/marketplace-pkg'
import { Marketplace } from '../types/marketplace'
export abstract class AbstractMarketplaceService {
abstract getMarketplace(): Observable<Marketplace>
abstract getMarketplaceInfo$(): Observable<MarketplaceInfo>
abstract getReleaseNotes(id: string): Observable<Record<string, string>>
abstract getCategories(): Observable<Set<string>>
abstract getPackages(): Observable<MarketplacePkg[]>
abstract getPackageMarkdown(type: string, pkgId: string): Observable<string>
abstract getPackages$(): Observable<MarketplacePkg[]>
abstract getPackage(
id: string,
version: string,
): Observable<MarketplacePkg | null>
url?: string,
): Observable<MarketplacePkg | undefined>
abstract fetchReleaseNotes(
id: string,
url?: string,
): Observable<Record<string, string>>
abstract fetchPackageMarkdown(
id: string,
type: string,
url?: string,
): Observable<string>
}

View File

@@ -1,4 +1,4 @@
export interface MarketplaceData {
categories: string[]
export interface MarketplaceInfo {
name: string
categories: string[]
}

View File

@@ -1,4 +0,0 @@
export interface Marketplace {
url: string
name: string
}

View File

@@ -26,7 +26,7 @@ export class EmverComparesPipe implements PipeTransform {
try {
return this.emver.compare(first, second) as SemverResult
} catch (e) {
console.warn(`emver comparison failed`, e, first, second)
console.error(`emver comparison failed`, e, first, second)
return 'comparison-impossible'
}
}

View File

@@ -36,8 +36,7 @@ export class HttpService {
@Inject(DOCUMENT) private readonly document: Document,
private readonly http: HttpClient,
) {
const { protocol, hostname, port } = this.document.location
this.fullUrl = `${protocol}//${hostname}:${port}`
this.fullUrl = this.document.location.origin
}
async rpcRequest<T>(

View File

@@ -12,9 +12,5 @@ export type WorkspaceConfig = {
maskAs: 'tor' | 'lan'
skipStartupAlerts: boolean
}
marketplace: {
url: string
name: string
}
}
}

View File

@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { EOSService } from '../../services/eos.service'
import { PatchDB } from 'patch-db-client'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { iif, Observable } from 'rxjs'
import { filter, map, switchMap } from 'rxjs/operators'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -47,9 +47,21 @@ export class MenuComponent {
readonly showEOSUpdate$ = this.eosService.showUpdate$
readonly updateCount$: Observable<number> = this.marketplaceService
.getUpdates()
.pipe(map(pkgs => pkgs.length))
readonly updateCount$: Observable<number> = this.patch
.watch$('ui', 'auto-check-updates')
.pipe(
filter(Boolean),
switchMap(() =>
this.marketplaceService.getUpdates$().pipe(
map(arr => {
return arr.reduce(
(acc, marketplace) => acc + marketplace.pkgs.length,
0,
)
}),
),
),
)
readonly sidebarOpen$ = this.splitPane.sidebarOpen$

View File

@@ -38,10 +38,10 @@ export class SnekDirective {
await loader.present()
try {
await this.embassyApi.setDbValue({
pointer: '/gaming/snake/high-score',
value: data.highScore,
})
await this.embassyApi.setDbValue(
['gaming', 'snake', 'high-score'],
data.highScore,
)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -1,38 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ title }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ng-container>
<div class="center text-center">
<div class="card">
<h4>This service was installed from:</h4>
<p class="courier-new color-success-shade">{{ packageMarketplace }}</p>
<h4>But you are currently connected to:</h4>
<p class="courier-new color-primary-shade">{{ currentMarketplace }}</p>
</div>
<div>
<p>Switch to {{ packageMarketplace }} in</p>
<ion-button
color="success"
routerLink="embassy/marketplaces"
(click)="dismiss()"
>Marketplace Settings</ion-button
>
<p>Or you can</p>
<ion-button
[routerLink]="['marketplace/', pkgId]"
click="dismiss()"
(click)="dismiss()"
>Continue to {{ currentMarketplace }}</ion-button
>
</div>
</div>
</ng-container>
</ion-content>

View File

@@ -1,12 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { ActionMarketplaceComponent } from './action-marketplace.component'
@NgModule({
declarations: [ActionMarketplaceComponent],
imports: [CommonModule, IonicModule, RouterModule.forChild([])],
exports: [ActionMarketplaceComponent],
})
export class ActionMarketplaceComponentModule {}

View File

@@ -1,17 +0,0 @@
.center {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.text-center {
text-align: center;
}
.card {
background: rgba(53, 56, 62, 0.768);
border-radius: 7px;
padding: 27px;
margin-bottom: 24px;
}

View File

@@ -1,20 +0,0 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
@Component({
selector: 'action-marketplace',
templateUrl: './action-marketplace.component.html',
styleUrls: ['./action-marketplace.component.scss'],
})
export class ActionMarketplaceComponent {
@Input() title!: string
@Input() packageMarketplace!: string
@Input() currentMarketplace!: string
@Input() pkgId!: string
constructor(private readonly modalCtrl: ModalController) {}
dismiss() {
this.modalCtrl.dismiss()
}
}

View File

@@ -1,35 +1,39 @@
import { Component, Input } from '@angular/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { LoadingController, ModalController } from '@ionic/angular'
import { ConfigService } from '../../services/config.service'
import { ApiService } from '../../services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'os-update',
templateUrl: './os-update.page.html',
styleUrls: ['./os-update.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OSUpdatePage {
@Input() releaseNotes!: { [version: string]: string }
versions: { version: string; notes: string }[] = []
constructor(
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly config: ConfigService,
private readonly errToast: ErrorToastService,
private readonly embassyApi: ApiService,
private readonly eosService: EOSService,
private readonly patch: PatchDB<DataModel>,
) {}
ngOnInit() {
this.versions = Object.keys(this.releaseNotes)
const releaseNotes = this.eosService.eos?.['release-notes']!
this.versions = Object.keys(releaseNotes)
.sort()
.reverse()
.map(version => {
return {
version,
notes: this.releaseNotes[version],
notes: releaseNotes[version],
}
})
}
@@ -45,9 +49,7 @@ export class OSUpdatePage {
await loader.present()
try {
await this.embassyApi.updateServer({
'marketplace-url': this.config.marketplace.url,
})
await this.embassyApi.updateServer()
this.dismiss()
} catch (e: any) {
this.errToast.present(e)

View File

@@ -161,10 +161,7 @@ export class AppActionsPage {
try {
await this.embassyApi.uninstallPackage({ id: this.pkgId })
this.embassyApi
.setDbValue({
pointer: `/ack-instructions/${this.pkgId}`,
value: false,
})
.setDbValue(['ack-instructions', this.pkgId], false)
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navCtrl.navigateRoot('/services')
} catch (e: any) {

View File

@@ -8,15 +8,10 @@
</ion-header>
<ion-content class="ion-padding">
<!-- loading -->
<ng-container *ngIf="loading else loaded">
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-container>
<!-- loaded -->
<ng-template #loaded>
<ng-container *ngIf="pkgs$ | async as pkgs; else loading">
<app-list-empty
*ngIf="empty; else list"
*ngIf="!pkgs.length; else list"
class="ion-text-center ion-padding"
></app-list-empty>
@@ -36,5 +31,10 @@
</ion-row>
</ion-grid>
</ng-template>
</ng-container>
<!-- loading -->
<ng-template #loading>
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-template>
</ion-content>

View File

@@ -1,46 +1,27 @@
import { Component } from '@angular/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { filter, takeUntil, tap } from 'rxjs/operators'
import { DestroyService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { filter, map, pairwise, startWith } from 'rxjs/operators'
@Component({
selector: 'app-list',
templateUrl: './app-list.page.html',
styleUrls: ['./app-list.page.scss'],
providers: [DestroyService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListPage {
loading = true
pkgs: readonly PackageDataEntry[] = []
readonly pkgs$ = this.patch.watch$('package-data').pipe(
map(pkgs => Object.values(pkgs)),
startWith([]),
pairwise(),
filter(([prev, next]) => {
const length = next.length
return !length || prev.length !== length
}),
map(([_, pkgs]) => {
return pkgs.sort((a, b) => (b.manifest.title > a.manifest.title ? -1 : 1))
}),
)
constructor(
private readonly api: ApiService,
private readonly destroy$: DestroyService,
private readonly patch: PatchDB<DataModel>,
) {}
get empty(): boolean {
return !this.pkgs.length
}
ngOnInit() {
this.patch
.watch$('package-data')
.pipe(
filter(pkgs => Object.keys(pkgs).length !== this.pkgs.length),
tap(pkgs => {
this.loading = false
this.pkgs = Object.values(pkgs).sort((a, b) =>
b.manifest.title > a.manifest.title ? -1 : 1,
)
}),
takeUntil(this.destroy$),
)
.subscribe()
}
constructor(private readonly patch: PatchDB<DataModel>) {}
}

View File

@@ -20,7 +20,6 @@ import { ToButtonsPipe } from './pipes/to-buttons.pipe'
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
import { ToStatusPipe } from './pipes/to-status.pipe'
import { ProgressDataPipe } from './pipes/progress-data.pipe'
import { ActionMarketplaceComponentModule } from 'src/app/modals/action-marketplace/action-marketplace.component.module'
const routes: Routes = [
{
@@ -54,7 +53,6 @@ const routes: Routes = [
EmverPipesModule,
LaunchablePipeModule,
UiPipeModule,
ActionMarketplaceComponentModule,
],
})
export class AppShowPageModule {}

View File

@@ -22,9 +22,7 @@
[dependencies]="dependencies"
></app-show-dependencies>
<!-- ** menu ** -->
<app-show-menu
[buttons]="pkg | toButtons: (currentMarketplace$ | async): (altMarketplaceData$ | async)"
></app-show-menu>
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
</ng-container>
</ion-item-group>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { PatchDB } from 'patch-db-client'
import {
@@ -14,8 +14,6 @@ import {
import { filter, tap } from 'rxjs/operators'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
const STATES = [
PackageState.Installing,
@@ -49,16 +47,10 @@ export class AppShowPage {
),
)
readonly currentMarketplace$ = this.marketplaceService.getMarketplace()
readonly altMarketplaceData$ = this.marketplaceService.getAltMarketplaceData()
constructor(
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {}
isInstalled({ state }: PackageDataEntry): boolean {

View File

@@ -2,17 +2,14 @@ import { Inject, Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { DOCUMENT } from '@angular/common'
import { AlertController, ModalController, NavController } from '@ionic/angular'
import { getUrlHostname, MarkdownComponent } from '@start9labs/shared'
import { MarkdownComponent } from '@start9labs/shared'
import {
DataModel,
PackageDataEntry,
UIMarketplaceData,
} from 'src/app/services/patch-db/data-model'
import { ModalService } from 'src/app/services/modal.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable } from 'rxjs'
import { Marketplace } from '@start9labs/marketplace'
import { ActionMarketplaceComponent } from 'src/app/modals/action-marketplace/action-marketplace.component'
import { PatchDB } from 'patch-db-client'
export interface Button {
@@ -39,11 +36,7 @@ export class ToButtonsPipe implements PipeTransform {
private readonly patch: PatchDB<DataModel>,
) {}
transform(
pkg: PackageDataEntry,
currentMarketplace: Marketplace | null,
altMarketplaces: UIMarketplaceData | null | undefined,
): Button[] {
transform(pkg: PackageDataEntry): Button[] {
const pkgTitle = pkg.manifest.title
return [
@@ -103,7 +96,7 @@ export class ToButtonsPipe implements PipeTransform {
icon: 'receipt-outline',
},
// view in marketplace
this.viewInMarketplaceButton(pkg, currentMarketplace, altMarketplaces),
this.viewInMarketplaceButton(pkg),
// donate
{
action: () => this.donate(pkg),
@@ -116,10 +109,7 @@ export class ToButtonsPipe implements PipeTransform {
private async presentModalInstructions(pkg: PackageDataEntry) {
this.apiService
.setDbValue({
pointer: `/ack-instructions/${pkg.manifest.id}`,
value: true,
})
.setDbValue(['ack-instructions', pkg.manifest.id], true)
.catch(e => console.error('Failed to mark instructions as seen', e))
const modal = await this.modalCtrl.create({
@@ -135,51 +125,27 @@ export class ToButtonsPipe implements PipeTransform {
await modal.present()
}
private viewInMarketplaceButton(
pkg: PackageDataEntry,
currentMarketplace: Marketplace | null,
altMarketplaces: UIMarketplaceData | null | undefined,
): Button {
const pkgMarketplaceUrl = pkg.installed?.['marketplace-url']
// default button if package marketplace and current marketplace are the same
private viewInMarketplaceButton(pkg: PackageDataEntry): Button {
const url = pkg.installed?.['marketplace-url']
const queryParams = url ? { url } : {}
let button: Button = {
title: 'Marketplace',
icon: 'storefront-outline',
action: () =>
this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`]),
this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`], {
queryParams,
}),
disabled: false,
description: 'View service in marketplace',
}
if (!pkgMarketplaceUrl) {
if (!url) {
button.disabled = true
button.description = 'This package was not installed from a marketplace.'
button.action = () => {}
} else if (
pkgMarketplaceUrl &&
currentMarketplace &&
getUrlHostname(pkgMarketplaceUrl) !==
getUrlHostname(currentMarketplace.url)
) {
// attempt to get name for pkg marketplace
let pkgMarketplaceName = getUrlHostname(pkgMarketplaceUrl)
if (altMarketplaces) {
const pkgMarketplaces = Object.values(
altMarketplaces['known-hosts'],
).filter(m => getUrlHostname(m.url) === pkgMarketplaceName)
if (pkgMarketplaces.length) {
// if multiple of the same url exist, they will have the same name, so fine to grab first
pkgMarketplaceName = pkgMarketplaces[0].name
}
}
button.action = () =>
this.differentMarketplaceAction(
pkgMarketplaceName,
currentMarketplace.name,
pkg.manifest.id,
)
button.description = 'Service was installed from a different marketplace'
}
return button
}
@@ -195,22 +161,4 @@ export class ToButtonsPipe implements PipeTransform {
await alert.present()
}
}
private async differentMarketplaceAction(
packageMarketplace: string,
currentMarketplace: string,
pkgId: string,
) {
const modal = await this.modalCtrl.create({
component: ActionMarketplaceComponent,
componentProps: {
title: 'Marketplace Conflict',
packageMarketplace,
currentMarketplace,
pkgId,
},
cssClass: 'medium-modal',
})
await modal.present()
}
}

View File

@@ -5,8 +5,8 @@ import {
PackageDataEntry,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import { exists, isEmptyObject } from '@start9labs/shared'
import { filter, map, startWith } from 'rxjs/operators'
import { isEmptyObject } from '@start9labs/shared'
import { map, startWith } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { Observable } from 'rxjs'
@@ -27,7 +27,6 @@ export class ToHealthChecksPipe implements PipeTransform {
const healthChecks$ = this.patch
.watch$('package-data', pkg.manifest.id, 'installed', 'status', 'main')
.pipe(
filter(obj => exists(obj)),
map(main => {
// Question: is this ok or do we have to use Object.keys
// to maintain order and the keys initially present in pkg?

View File

@@ -69,10 +69,7 @@ export class DevConfigPage {
async save() {
this.saving = true
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/config`,
value: this.code,
})
await this.api.setDbValue(['dev', this.projectId, 'config'], this.code)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -56,10 +56,10 @@ export class DevInstructionsPage {
async save() {
this.saving = true
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/instructions`,
value: this.code,
})
await this.api.setDbValue(
['dev', this.projectId, 'instructions'],
this.code,
)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -148,7 +148,7 @@ export class DeveloperListPage {
.replace(/warning:/g, '# Optional\n warning:')
const def = { name, config, instructions: SAMPLE_INSTUCTIONS }
await this.api.setDbValue({ pointer: `/dev/${id}`, value: def })
await this.api.setDbValue(['dev', id], def)
} catch (e: any) {
this.errToast.present(e)
} finally {
@@ -184,7 +184,7 @@ export class DeveloperListPage {
await loader.present()
try {
await this.api.setDbValue({ pointer: `/dev/${id}/name`, value: newName })
await this.api.setDbValue(['dev', id, 'name'], newName)
} catch (e: any) {
this.errToast.present(e)
} finally {
@@ -201,7 +201,7 @@ export class DeveloperListPage {
try {
const devDataToSave: DevData = JSON.parse(JSON.stringify(this.devData))
delete devDataToSave[id]
await this.api.setDbValue({ pointer: `/dev`, value: devDataToSave })
await this.api.setDbValue(['dev'], devDataToSave)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -55,10 +55,10 @@ export class DeveloperMenuPage {
await loader.present()
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/basic-info`,
value: basicInfo,
})
await this.api.setDbValue(
['dev', this.projectId, 'basic-info'],
basicInfo,
)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -1,5 +1,6 @@
<ion-header>
<ion-toolbar>
<ion-title>Marketplace</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
@@ -28,14 +29,13 @@
</ion-row>
<ion-row class="ion-align-items-center">
<ion-col size="12">
<ng-container *ngIf="pkgs$ | async as pkgs; else loading">
<ng-container *ngIf="marketplace$ | async as marketplace; else loading">
<ng-container *ngIf="localPkgs$ | async as localPkgs">
<marketplace-categories
*ngIf="categories$ | async as categories"
[categories]="categories"
[categories]="marketplace.categories"
[category]="category"
[updatesAvailable]="
(pkgs | filterPackages: '':'updates':localPkgs).length
(marketplace.pkgs | filterPackages: '':'updates':localPkgs).length
"
(categoryChange)="onCategoryChange($event)"
></marketplace-categories>
@@ -43,7 +43,7 @@
<div class="divider"></div>
<ion-grid
*ngIf="pkgs | filterPackages: query:category:localPkgs as filtered"
*ngIf="marketplace.pkgs | filterPackages: query:category:localPkgs as filtered"
>
<div
*ngIf="!filtered.length && category === 'updates'"

View File

@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { getUrlHostname } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { combineLatest, map } from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
@@ -12,25 +12,45 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceListPage {
private readonly pkgs$ = this.marketplaceService.getPackages$()
private readonly categories$ = this.marketplaceService
.getMarketplaceInfo$()
.pipe(
map(({ categories }) => {
const set = new Set<string>()
if (categories.includes('featured')) set.add('featured')
set.add('updates')
categories.forEach(c => set.add(c))
set.add('all')
return set
}),
)
readonly marketplace$ = combineLatest([this.pkgs$, this.categories$]).pipe(
map(arr => {
return { pkgs: arr[0], categories: arr[1] }
}),
)
readonly localPkgs$ = this.patch.watch$('package-data')
readonly categories$ = this.marketplaceService.getCategories()
readonly pkgs$ = this.marketplaceService.getPackages()
readonly details$ = this.marketplaceService.getMarketplace().pipe(
map(d => {
readonly details$ = this.marketplaceService.getUiMarketplace$().pipe(
map(({ url, name }) => {
let color: string
let description: string
switch (getUrlHostname(d.url)) {
case 'registry.start9.com':
switch (url) {
case 'https://registry.start9.com/':
color = 'success'
description =
'Services in this marketplace are packaged and maintained by the Start9 team. If you experience an issue or have a questions related to a service in this marketplace, one of our dedicated support staff will be happy to assist you.'
break
case 'beta-registry-0-3.start9labs.com':
case 'https://beta-registry-0-3.start9labs.com/':
color = 'primary'
description =
'Services in this marketplace are undergoing active testing and may contain bugs. <b>Install at your own risk</b>. If you discover a bug or have a suggestion for improvement, please report it to the Start9 team in our community testing channel on Matrix.'
break
case 'community.start9labs.com':
case 'https://community.start9labs.com/':
color = 'tertiary'
description =
'Services in this marketplace are packaged and maintained by members of the Start9 community. <b>Install at your own risk</b>. If you experience an issue or have a question related to a service in this marketplace, please reach out to the package developer for assistance.'
@@ -43,7 +63,8 @@ export class MarketplaceListPage {
}
return {
...d,
name,
url,
color,
description,
}
@@ -52,7 +73,8 @@ export class MarketplaceListPage {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly marketplaceService: AbstractMarketplaceService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {}
category = 'featured'

View File

@@ -31,6 +31,9 @@ import { firstValueFrom } from 'rxjs'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceShowControlsComponent {
@Input()
url?: string
@Input()
pkg!: MarketplacePkg
@@ -58,22 +61,81 @@ export class MarketplaceShowControlsComponent {
}
async tryInstall() {
const currentMarketplace = await firstValueFrom(
this.marketplaceService.getUiMarketplace$(),
)
const url = this.url || currentMarketplace.url
if (!this.localPkg) {
this.alertInstall()
this.alertInstall(url)
} else {
const originalUrl = this.localPkg.installed?.['marketplace-url']
if (url !== originalUrl) {
const proceed = await this.presentAlertDifferentMarketplace(
url,
originalUrl,
)
if (!proceed) return
}
if (
this.emver.compare(this.localVersion, this.pkg.manifest.version) !==
0 &&
hasCurrentDeps(this.localPkg)
) {
this.dryInstall()
this.dryInstall(url)
} else {
this.install()
this.install(url)
}
}
}
private async dryInstall() {
private async presentAlertDifferentMarketplace(
url: string,
originalUrl: string | null | undefined,
): Promise<boolean> {
const marketplaces = await firstValueFrom(
this.patch.watch$('ui', 'marketplace'),
)
const name = marketplaces['known-hosts'][url] || url
let originalName: string | undefined
if (originalUrl) {
originalName = marketplaces['known-hosts'][originalUrl] || originalUrl
}
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message: `This service was originally ${
originalName ? 'installed from ' + originalName : 'side loaded'
}, but you are currently connected to ${name}. To install from ${name} anyway, click "Continue".`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
})
}
private async dryInstall(url: string) {
const loader = await this.loadingCtrl.create({
message: 'Checking dependent services...',
})
@@ -88,12 +150,12 @@ export class MarketplaceShowControlsComponent {
})
if (isEmptyObject(breakages)) {
this.install(loader)
this.install(url, loader)
} else {
await loader.dismiss()
const proceed = await this.presentAlertBreakages(breakages)
if (proceed) {
this.install()
this.install(url)
}
}
} catch (e: any) {
@@ -101,10 +163,10 @@ export class MarketplaceShowControlsComponent {
}
}
private async alertInstall() {
private async alertInstall(url: string) {
const installAlert = this.pkg.manifest.alerts.install
if (!installAlert) return this.install()
if (!installAlert) return this.install(url)
const alert = await this.alertCtrl.create({
header: 'Alert',
@@ -117,7 +179,7 @@ export class MarketplaceShowControlsComponent {
{
text: 'Install',
handler: () => {
this.install()
this.install(url)
},
cssClass: 'enter-click',
},
@@ -126,7 +188,7 @@ export class MarketplaceShowControlsComponent {
await alert.present()
}
private async install(loader?: HTMLIonLoadingElement) {
private async install(url: string, loader?: HTMLIonLoadingElement) {
const message = 'Beginning Install...'
if (loader) {
loader.message = message
@@ -138,12 +200,7 @@ export class MarketplaceShowControlsComponent {
const { id, version } = this.pkg.manifest
try {
await firstValueFrom(
this.marketplaceService.installPackage({
id,
'version-spec': `=${version}`,
}),
)
await this.marketplaceService.installPackage(id, version, url)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -5,6 +5,7 @@
<ng-container *ngIf="!(pkg | empty)">
<marketplace-package [pkg]="pkg"></marketplace-package>
<marketplace-show-controls
[url]="url"
[pkg]="pkg"
[localPkg]="localPkg$ | async"
></marketplace-show-controls>

View File

@@ -1,13 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ErrorToastService, getPkgId } from '@start9labs/shared'
import {
MarketplacePkg,
AbstractMarketplaceService,
} from '@start9labs/marketplace'
import { getPkgId } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject, Observable, of } from 'rxjs'
import { catchError, filter, shareReplay, switchMap } from 'rxjs/operators'
import { BehaviorSubject } from 'rxjs'
import { filter, shareReplay, switchMap } from 'rxjs/operators'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
@@ -18,6 +15,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
})
export class MarketplaceShowPage {
private readonly pkgId = getPkgId(this.route)
readonly url = this.route.snapshot.queryParamMap.get('url') || undefined
readonly loadVersion$ = new BehaviorSubject<string>('*')
@@ -25,26 +23,15 @@ export class MarketplaceShowPage {
.watch$('package-data', this.pkgId)
.pipe(filter(Boolean), shareReplay({ bufferSize: 1, refCount: true }))
readonly pkg$: Observable<MarketplacePkg | null> = this.loadVersion$.pipe(
readonly pkg$ = this.loadVersion$.pipe(
switchMap(version =>
this.marketplaceService.getPackage(this.pkgId, version),
this.marketplaceService.getPackage(this.pkgId, version, this.url),
),
// TODO: Better fallback
catchError(e => {
this.errToast.present(e)
return of({} as MarketplacePkg)
}),
)
constructor(
private readonly route: ActivatedRoute,
private readonly errToast: ErrorToastService,
private readonly patch: PatchDB<DataModel>,
private readonly marketplaceService: AbstractMarketplaceService,
) {}
getIcon(icon: string): string {
return `data:image/png;base64,${icon}`
}
}

View File

@@ -8,8 +8,34 @@
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group>
<ion-item-divider>Saved Marketplaces</ion-item-divider>
<ion-item-group *ngIf="marketplace$ | async as m">
<ion-item>
<ion-label>
Connect to a standard marketplaces or an alternative marketplace.
</ion-label>
</ion-item>
<ion-item-divider>Standard Marketplaces</ion-item-divider>
<ion-item
*ngFor="let s of m.standard"
detail="false"
[button]="s.url !== m.selected"
(click)="s.url === m.selected ? '' : presentAction(s)"
>
<ion-icon
*ngIf="s.url === m.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
<ion-label>
<h2>{{ s.name }}</h2>
<p>{{ s.url }}</p>
</ion-label>
</ion-item>
<ion-item-divider>Alt Marketplaces</ion-item-divider>
<ion-item button detail="false" (click)="presentModalAdd()">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
@@ -20,22 +46,21 @@
</ion-item>
<ion-item
*ngFor="let mp of marketplaces"
*ngFor="let a of m.alt"
detail="false"
[button]="mp.id !== selectedId"
(click)="presentAction(mp.id)"
[button]="a.url !== m.selected"
(click)="a.url === m.selected ? '' : presentAction(a, true)"
>
<div *ngIf="mp.id !== selectedId" slot="start" class="padding"></div>
<ion-icon
*ngIf="mp.id === selectedId"
slot="start"
*ngIf="a.url === m.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
<ion-label>
<h2>{{ mp.name }}</h2>
<p>{{ mp.url }}</p>
<h2>{{ a.name }}</h2>
<p>{{ a.url }}</p>
</ion-label>
</ion-item>
</ion-item-group>

View File

@@ -1,4 +1,4 @@
import { Component, Inject } from '@angular/core'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import {
ActionSheetController,
AlertController,
@@ -6,47 +6,44 @@ import {
ModalController,
} from '@ionic/angular'
import { ActionSheetButton } from '@ionic/core'
import {
DestroyService,
ErrorToastService,
getUrlHostname,
} from '@start9labs/shared'
import { ErrorToastService } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ValueSpecObject } from 'src/app/pkg-config/config-types'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { PatchDB } from 'patch-db-client'
import { v4 } from 'uuid'
import {
DataModel,
UIMarketplaceData,
} from '../../../services/patch-db/data-model'
import { ConfigService } from '../../../services/config.service'
import { DataModel } from '../../../services/patch-db/data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
distinctUntilChanged,
finalize,
first,
takeUntil,
} from 'rxjs/operators'
import { getServerInfo } from '../../../util/get-server-info'
import { getMarketplace } from '../../../util/get-marketplace'
type Marketplaces = {
id: string | null
name: string
url: string
}[]
import { map } from 'rxjs/operators'
import { firstValueFrom } from 'rxjs'
@Component({
selector: 'marketplaces',
templateUrl: 'marketplaces.page.html',
styleUrls: ['marketplaces.page.scss'],
providers: [DestroyService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplacesPage {
selectedId: string | null = null
marketplaces: Marketplaces = []
marketplace$ = this.patch.watch$('ui', 'marketplace').pipe(
map(m => {
const selected = m['selected-url']
const hosts = Object.entries(m['known-hosts'])
const standard = hosts
.map(([url, name]) => {
return { url, name }
})
.slice(0, 2) // 0 and 1 will always be prod and community
const alt = hosts
.map(([url, name]) => {
return { url, name }
})
.slice(2) // 2 and beyond will always be alts
return { selected, standard, alt }
}),
)
constructor(
private readonly api: ApiService,
@@ -56,55 +53,28 @@ export class MarketplacesPage {
private readonly actionCtrl: ActionSheetController,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly config: ConfigService,
private readonly patch: PatchDB<DataModel>,
private readonly destroy$: DestroyService,
private readonly alertCtrl: AlertController,
) {}
ngOnInit() {
this.patch
.watch$('ui', 'marketplace')
.pipe(distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((mp: UIMarketplaceData) => {
let marketplaces: Marketplaces = [
{
id: null,
name: this.config.marketplace.name,
url: this.config.marketplace.url,
},
]
this.selectedId = mp['selected-id']
const alts = Object.entries(mp['known-hosts']).map(([k, v]) => {
return {
id: k,
name: v.name,
url: v.url,
}
})
marketplaces = marketplaces.concat(alts)
this.marketplaces = marketplaces
})
}
async presentModalAdd() {
const marketplaceSpec = getMarketplaceValueSpec()
const { name, spec } = getMarketplaceValueSpec()
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: marketplaceSpec.name,
spec: marketplaceSpec.spec,
title: name,
spec,
buttons: [
{
text: 'Save for Later',
handler: (value: { url: string }) => {
this.save(value.url)
this.saveOnly(new URL(value.url))
},
},
{
text: 'Save and Connect',
handler: (value: { url: string }) => {
this.saveAndConnect(value.url)
this.saveAndConnect(new URL(value.url))
},
isSubmit: true,
},
@@ -116,32 +86,31 @@ export class MarketplacesPage {
await modal.present()
}
async presentAction(id: string | null) {
// no need to view actions if is selected marketplace
const marketplace = await getMarketplace(this.patch)
if (id === marketplace['selected-id']) return
async presentAction(
{ url, name }: { url: string; name: string },
canDelete = false,
) {
const buttons: ActionSheetButton[] = [
{
text: 'Connect',
handler: () => {
this.connect(id)
this.connect(url)
},
},
]
if (id) {
if (canDelete) {
buttons.unshift({
text: 'Delete',
role: 'destructive',
handler: () => {
this.presentAlertDelete(id)
this.presentAlertDelete(url, name)
},
})
}
const action = await this.actionCtrl.create({
header: this.marketplaces.find(mp => mp.id === id)?.name,
header: name,
mode: 'ios',
buttons,
})
@@ -149,55 +118,7 @@ export class MarketplacesPage {
await action.present()
}
private async connect(id: string | null): Promise<void> {
const marketplace = await getMarketplace(this.patch)
const url = id
? marketplace['known-hosts'][id].url
: this.config.marketplace.url
const loader = await this.loadingCtrl.create({
message: 'Validating Marketplace...',
})
await loader.present()
try {
const { id } = await getServerInfo(this.patch)
await this.marketplaceService.getMarketplaceData({ 'server-id': id }, url)
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Changing Marketplace...'
const value: UIMarketplaceData = {
...marketplace,
'selected-id': id,
}
try {
await this.api.setDbValue({ pointer: `/marketplace`, value })
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
}
loader.message = 'Syncing store...'
this.marketplaceService
.getPackages()
.pipe(
first(),
finalize(() => loader.dismiss()),
)
.subscribe()
}
private async presentAlertDelete(id: string) {
const name = this.marketplaces.find(m => m.id === id)?.name
private async presentAlertDelete(url: string, name: string) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to delete ${name}?`,
@@ -208,7 +129,7 @@ export class MarketplacesPage {
},
{
text: 'Delete',
handler: () => this.delete(id),
handler: () => this.delete(url),
cssClass: 'enter-click',
},
],
@@ -217,125 +138,104 @@ export class MarketplacesPage {
await alert.present()
}
private async delete(id: string): Promise<void> {
const data = await getMarketplace(this.patch)
const marketplace: UIMarketplaceData = JSON.parse(JSON.stringify(data))
private async connect(
url: string,
loader?: HTMLIonLoadingElement,
): Promise<void> {
const message = 'Changing Marketplace...'
if (!loader) {
loader = await this.loadingCtrl.create({ message })
await loader.present()
} else {
loader.message = message
}
try {
await this.api.setDbValue(['marketplace', 'selected-url'], url)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async saveOnly(url: URL): Promise<void> {
const loader = await this.loadingCtrl.create()
try {
await this.validateAndSave(url, loader)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async saveAndConnect(url: URL): Promise<void> {
const loader = await this.loadingCtrl.create()
try {
await this.validateAndSave(url, loader)
await this.connect(url.toString(), loader)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async validateAndSave(
urlObj: URL,
loader: HTMLIonLoadingElement,
): Promise<void> {
const url = urlObj.toString()
// Error on duplicates
const hosts = await firstValueFrom(
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
)
const currentUrls = Object.keys(hosts)
if (currentUrls.includes(url)) throw new Error('marketplace already added')
// Validate
loader.message = 'Validating marketplace...'
await loader.present()
const name = await this.marketplaceService.validateMarketplace(url)
// Save
loader.message = 'Saving...'
await this.api.setDbValue(['marketplace', 'known-hosts', url], name)
}
private async delete(url: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Deleting...',
})
await loader.present()
const hosts = await firstValueFrom(
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
)
const filtered = Object.keys(hosts)
.filter(key => key !== url)
.reduce((prev, curr) => {
const name = hosts[curr]
return {
...prev,
[curr]: name,
}
}, {})
try {
delete marketplace['known-hosts'][id]
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
await this.api.setDbValue(['marketplace', 'known-hosts'], filtered)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async save(url: string): Promise<void> {
const data = await getMarketplace(this.patch)
const marketplace: UIMarketplaceData = data
? JSON.parse(JSON.stringify(data))
: {
'selected-id': null,
'known-hosts': {},
}
// no-op on duplicates
const currentUrls = this.marketplaces.map(mp => getUrlHostname(mp.url))
if (currentUrls.includes(getUrlHostname(url))) return
const loader = await this.loadingCtrl.create({
message: 'Validating Marketplace...',
})
await loader.present()
try {
const id = v4()
const { id: serverId } = await getServerInfo(this.patch)
const { name } = await this.marketplaceService.getMarketplaceData(
{ 'server-id': serverId },
url,
)
marketplace['known-hosts'][id] = { name, url }
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Saving...'
try {
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async saveAndConnect(url: string): Promise<void> {
const data = await getMarketplace(this.patch)
const marketplace: UIMarketplaceData = data
? JSON.parse(JSON.stringify(data))
: {
'selected-id': null,
'known-hosts': {},
}
// no-op on duplicates
const currentUrls = this.marketplaces.map(mp => getUrlHostname(mp.url))
if (currentUrls.includes(getUrlHostname(url))) {
this.errToast.present({ message: 'Marketplace already added' })
return
}
const loader = await this.loadingCtrl.create({
message: 'Validating Marketplace...',
})
await loader.present()
try {
const id = v4()
const { id: serverId } = await getServerInfo(this.patch)
const { name } = await this.marketplaceService.getMarketplaceData(
{ 'server-id': serverId },
url,
)
marketplace['known-hosts'][id] = { name, url }
marketplace['selected-id'] = id
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Saving...'
try {
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Syncing marketplace data...'
this.marketplaceService
.getPackages()
.pipe(
first(),
finalize(() => loader.dismiss()),
)
.subscribe()
}
}
function getMarketplaceValueSpec(): ValueSpecObject {

View File

@@ -73,7 +73,7 @@ export class PreferencesPage {
await loader.present()
try {
await this.api.setDbValue({ pointer: `/${key}`, value })
await this.api.setDbValue([key], value)
} finally {
loader.dismiss()
}

View File

@@ -48,9 +48,6 @@ export class ServerShowPage {
async updateEos(): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
releaseNotes: this.eosService.eos?.['release-notes'],
},
component: OSUpdatePage,
})
modal.present()

View File

@@ -1,5 +1,5 @@
import { Dump, Revision } from 'patch-db-client'
import { MarketplaceData, MarketplacePkg } from '@start9labs/marketplace'
import { MarketplaceInfo, MarketplacePkg } from '@start9labs/marketplace'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import {
@@ -242,7 +242,7 @@ export module RR {
// marketplace
export type GetMarketplaceDataReq = { 'server-id': string }
export type GetMarketplaceDataRes = MarketplaceData
export type GetMarketplaceDataRes = MarketplaceInfo
export type GetMarketplaceEOSReq = {
'server-id': string

View File

@@ -18,7 +18,10 @@ export abstract class ApiService {
// db
abstract setDbValue(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes>
abstract setDbValue(
pathArr: Array<string | number>,
value: any,
): Promise<RR.SetDBValueRes>
// auth
@@ -64,7 +67,7 @@ export abstract class ApiService {
params: RR.GetPackageMetricsReq,
): Promise<RR.GetPackageMetricsRes>
abstract updateServer(params: RR.UpdateServerReq): Promise<RR.UpdateServerRes>
abstract updateServer(url?: string): Promise<RR.UpdateServerRes>
abstract restartServer(
params: RR.RestartServerReq,

View File

@@ -18,10 +18,12 @@ import { Observable } from 'rxjs'
import { AuthService } from '../auth.service'
import { DOCUMENT } from '@angular/common'
import { DataModel } from '../patch-db/data-model'
import { PatchDB, Update } from 'patch-db-client'
import { PatchDB, pathFromArray, Update } from 'patch-db-client'
@Injectable()
export class LiveApiService extends ApiService {
readonly eosMarketplaceUrl = 'https://registry.start9.com/'
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly http: HttpService,
@@ -52,7 +54,12 @@ export class LiveApiService extends ApiService {
// db
async setDbValue(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
async setDbValue(
pathArr: Array<string | number>,
value: any,
): Promise<RR.SetDBValueRes> {
const pointer = pathFromArray(pathArr)
const params: RR.SetDBValueReq = { pointer, value }
return this.rpcRequest({ method: 'db.put.ui', params })
}
@@ -127,13 +134,11 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.metrics', params })
}
async updateServer(params: RR.UpdateServerReq): Promise<RR.UpdateServerRes> {
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
const params = {
'marketplace-url': url || this.eosMarketplaceUrl,
}
return this.rpcRequest({ method: 'server.update', params })
// const res = await this.updateServer(params)
// if (res.response === 'no-updates') {
// throw new Error('Could not find a newer version of embassyOS')
// }
// return res
}
async restartServer(
@@ -160,12 +165,12 @@ export class LiveApiService extends ApiService {
// marketplace URLs
async marketplaceProxy<T>(path: string, qp: {}, url: string): Promise<T> {
async marketplaceProxy<T>(path: string, qp: {}, baseUrl: string): Promise<T> {
Object.assign(qp, { arch: this.config.targetArch })
const fullURL = `${url}${path}?${new URLSearchParams(qp).toString()}`
const fullUrl = `${baseUrl}${path}?${new URLSearchParams(qp).toString()}`
return this.rpcRequest({
method: 'marketplace.get',
params: { url: fullURL },
params: { url: fullUrl },
})
}
@@ -175,7 +180,7 @@ export class LiveApiService extends ApiService {
return this.marketplaceProxy(
'/eos/v0/latest',
params,
this.config.marketplace.url,
this.eosMarketplaceUrl,
)
}

View File

@@ -1,7 +1,13 @@
import { Injectable } from '@angular/core'
import { pauseFor, Log } from '@start9labs/shared'
import { ApiService } from './embassy-api.service'
import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client'
import {
PatchOp,
Update,
Operation,
RemoveOperation,
pathFromArray,
} from 'patch-db-client'
import {
DataModel,
DependencyErrorType,
@@ -15,11 +21,22 @@ import { CifsBackupTarget, RR } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures'
import markdown from 'raw-loader!../../../../../../assets/markdown/md-sample.md'
import { BehaviorSubject, interval, map, Observable } from 'rxjs'
import {
EMPTY,
iif,
interval,
map,
Observable,
ReplaySubject,
switchMap,
tap,
timer,
} from 'rxjs'
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
import { mockPatchData } from './mock-patch'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
const PROGRESS: InstallProgress = {
size: 120,
@@ -33,28 +50,35 @@ const PROGRESS: InstallProgress = {
@Injectable()
export class MockApiService extends ApiService {
readonly mockWsSource$ = new BehaviorSubject<Update<DataModel>>({
id: 1,
value: mockPatchData,
})
readonly mockWsSource$ = new ReplaySubject<Update<DataModel>>()
private readonly revertTime = 2000
sequence = 0
constructor(
private readonly bootstrapper: LocalStorageBootstrap,
private readonly connectionService: ConnectionService,
private readonly auth: AuthService,
) {
super()
this.auth.isVerified$.subscribe(verified => {
if (!verified) {
this.patchStream$.next([])
this.mockWsSource$.next({
id: 1,
value: mockPatchData,
})
this.sequence = 0
}
})
this.auth.isVerified$
.pipe(
tap(() => {
this.sequence = 0
this.patchStream$.next([])
}),
switchMap(verified =>
iif(
() => verified,
timer(2000).pipe(
tap(() => {
this.connectionService.websocketConnected$.next(true)
}),
),
EMPTY,
),
),
)
.subscribe()
}
async getStatic(url: string): Promise<string> {
@@ -69,7 +93,12 @@ export class MockApiService extends ApiService {
// db
async setDbValue(params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
async setDbValue(
pathArr: Array<string | number>,
value: any,
): Promise<RR.SetDBValueRes> {
const pointer = pathFromArray(pathArr)
const params: RR.SetDBValueReq = { pointer, value }
await pauseFor(2000)
const patch = [
{
@@ -198,7 +227,7 @@ export class MockApiService extends ApiService {
return Mock.getAppMetrics()
}
async updateServer(params: RR.UpdateServerReq): Promise<RR.UpdateServerRes> {
async updateServer(url?: string): Promise<RR.UpdateServerRes> {
await pauseFor(2000)
const initialProgress = {
size: 10000,
@@ -254,10 +283,10 @@ export class MockApiService extends ApiService {
return {
name: 'Dark69',
categories: [
'featured',
'bitcoin',
'lightning',
'data',
'featured',
'messaging',
'social',
'alt coin',

View File

@@ -15,12 +15,11 @@ export const mockPatchData: DataModel = {
'pkg-order': [],
'ack-welcome': '1.0.0',
marketplace: {
'selected-id': '1234',
'selected-url': 'https://registry.start9.com/',
'known-hosts': {
'1234': {
name: 'Dark9',
url: 'https://test-marketplace.com',
},
'https://registry.start9.com/': 'Start9 Marketplace',
'https://community-registry.start9.com/': 'Community Marketplace',
'https://dark9-marketplace.com/': 'Dark9',
},
},
dev: {},

View File

@@ -11,7 +11,7 @@ const {
targetArch,
gitHash,
useMocks,
ui: { api, mocks, marketplace },
ui: { api, mocks },
} = require('../../../../../config.json') as WorkspaceConfig
@Injectable({
@@ -25,7 +25,6 @@ export class ConfigService {
targetArch = targetArch
gitHash = gitHash
api = api
marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts
isConsulate = (window as any)['platform'] === 'ios'
supportsWebSockets = !!window.WebSocket || this.isConsulate

View File

@@ -1,26 +1,21 @@
import { Injectable } from '@angular/core'
import { Emver, ErrorToastService } from '@start9labs/shared'
import { Emver } from '@start9labs/shared'
import {
MarketplacePkg,
AbstractMarketplaceService,
Marketplace,
FilterPackagesPipe,
MarketplaceData,
MarketplaceInfo,
} from '@start9labs/marketplace'
import { from, Observable, of, Subject } from 'rxjs'
import { combineLatest, from, Observable, of } from 'rxjs'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import {
DataModel,
ServerInfo,
UIMarketplaceData,
Manifest,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import {
catchError,
distinctUntilChanged,
filter,
map,
shareReplay,
startWith,
@@ -28,275 +23,321 @@ import {
take,
tap,
} from 'rxjs/operators'
import { getServerInfo } from '../util/get-server-info'
type MarketplaceURL = string
interface MarketplaceData {
info: MarketplaceInfo | null
packages: MarketplacePkg[]
}
type MasterCache = Map<MarketplaceURL, MarketplaceData>
@Injectable()
export class MarketplaceService extends AbstractMarketplaceService {
private readonly notes = new Map<string, Record<string, string>>()
private readonly hasPackages$ = new Subject<boolean>()
export class MarketplaceService implements AbstractMarketplaceService {
private readonly cache: MasterCache = new Map()
private readonly uiMarketplaceData$ = this.patch
.watch$('ui', 'marketplace')
.pipe(
private readonly uiMarketplace$: Observable<{ url: string; name: string }> =
this.patch.watch$('ui', 'marketplace').pipe(
distinctUntilChanged(
(prev, curr) => prev['selected-id'] === curr['selected-id'],
(prev, curr) => prev['selected-url'] === curr['selected-url'],
),
map(data => {
const url = data['selected-url']
return {
url,
name: data['known-hosts'][url],
}
}),
shareReplay(1),
)
private readonly marketplace$ = this.uiMarketplaceData$.pipe(
map(data => this.toMarketplace(data)),
)
private readonly serverInfo$: Observable<ServerInfo> = this.patch
.watch$('server-info')
.pipe(take(1), shareReplay())
private readonly registryData$: Observable<MarketplaceData> =
this.uiMarketplaceData$.pipe(
switchMap(data =>
this.serverInfo$.pipe(
switchMap(({ id }) =>
from(
this.getMarketplaceData(
{ 'server-id': id },
this.toMarketplace(data).url,
),
).pipe(tap(({ name }) => this.updateName(data, name))),
),
private readonly marketplaceData$: Observable<MarketplaceData> =
this.uiMarketplace$.pipe(
switchMap(({ url, name }) =>
from(this.loadMarketplace(url)).pipe(
tap(data => {
this.updateName(url, name, data.info!.name)
}),
),
),
shareReplay(1),
)
private readonly categories$: Observable<Set<string>> =
this.registryData$.pipe(
map(
({ categories }) =>
new Set(['featured', 'updates', ...categories, 'all']),
),
)
private readonly marketplaceInfo$: Observable<MarketplaceInfo> =
this.marketplaceData$.pipe(map(data => data.info!))
private readonly pkgs$: Observable<MarketplacePkg[]> = this.marketplace$.pipe(
switchMap(({ url }) =>
this.serverInfo$.pipe(
switchMap(info =>
from(
this.getMarketplacePkgs(
{ page: 1, 'per-page': 100 },
url,
info['eos-version-compat'],
),
).pipe(tap(() => this.hasPackages$.next(true))),
),
),
),
catchError(e => {
this.errToast.present(e)
private readonly marketplacePkgs$: Observable<MarketplacePkg[]> =
this.marketplaceData$.pipe(map(data => data.packages))
return of([])
private readonly updates$: Observable<
{ url: string; pkgs: MarketplacePkg[] }[]
> = this.patch.watch$('package-data').pipe(
take(1), // check once per app instance
map(localPkgs => {
return Object.values(localPkgs)
.filter(localPkg => localPkg.state === PackageState.Installed)
.reduce((localPkgMap, pkg) => {
const url = pkg.installed!['marketplace-url'] || '' // side-laoded services will not have marketplace-url
const cached = this.cache
.get(url)
?.packages.find(p => p.manifest.id === pkg.manifest.id)
if (url && !cached) {
const arr = localPkgMap.get(url) || []
localPkgMap.set(url, arr.concat(pkg.manifest))
}
return localPkgMap
}, new Map<string, Manifest[]>())
}),
switchMap(localPkgMap => {
const urls = Array.from(localPkgMap.keys())
const requests = urls.map(url => {
const ids = localPkgMap.get(url)?.map(({ id }) => {
return { id, version: '*' }
})
return from(this.loadPackages({ ids }, url)).pipe(
map(pkgs => {
const manifests = localPkgMap.get(url)!
const filtered = pkgs.filter(pkg => {
const localVersion = manifests.find(
m => m.id === pkg.manifest.id,
)?.version
return (
localVersion &&
this.emver.compare(pkg.manifest.version, localVersion) === 1
)
})
return { url, pkgs: filtered }
}),
startWith({ url, pkgs: [] }), // needed for combineLatest to emit right away
)
})
return combineLatest(requests)
}),
shareReplay(1),
)
private readonly updates$: Observable<MarketplacePkg[]> =
this.hasPackages$.pipe(
switchMap(() =>
this.patch.watch$('package-data').pipe(
switchMap(localPkgs =>
this.pkgs$.pipe(
map(pkgs => {
return this.filterPkgsPipe.transform(
pkgs,
'',
'updates',
localPkgs,
)
}),
),
),
),
),
)
constructor(
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
private readonly errToast: ErrorToastService,
private readonly emver: Emver,
private readonly filterPkgsPipe: FilterPackagesPipe,
) {
super()
) {}
getUiMarketplace$(): Observable<{ url: string; name: string }> {
return this.uiMarketplace$
}
getMarketplace(): Observable<Marketplace> {
return this.marketplace$
getMarketplaceInfo$(): Observable<MarketplaceInfo> {
return this.marketplaceInfo$
}
getAltMarketplaceData() {
return this.uiMarketplaceData$
getPackages$(): Observable<MarketplacePkg[]> {
return this.marketplacePkgs$
}
getCategories(): Observable<Set<string>> {
return this.categories$
}
getPackages(): Observable<MarketplacePkg[]> {
return this.pkgs$
}
getPackage(id: string, version: string): Observable<MarketplacePkg | null> {
const params = { ids: [{ id, version }] }
const fallback$ = this.marketplace$.pipe(
switchMap(({ url }) =>
this.serverInfo$.pipe(
switchMap(info =>
from(
this.getMarketplacePkgs(params, url, info['eos-version-compat']),
),
),
),
),
map(pkgs => this.findPackage(pkgs, id, version)),
startWith(null),
)
return this.getPackages().pipe(
map(pkgs => this.findPackage(pkgs, id, version)),
switchMap(pkg => (pkg ? of(pkg) : fallback$)),
filter((pkg): pkg is MarketplacePkg | null => {
if (pkg === undefined) {
throw new Error(`No results for ${id}${version ? ' ' + version : ''}`)
getPackage(
id: string,
version: string,
url?: string,
): Observable<MarketplacePkg | undefined> {
return this.uiMarketplace$.pipe(
switchMap(m => {
url = url || m.url
if (this.cache.has(url)) {
const pkg = this.getPkgFromCache(id, version, url)
if (pkg) return of(pkg)
}
return true
if (version === '*') {
return from(this.loadPackage(id, url))
} else {
return from(this.fetchPackage(id, version, url))
}
}),
)
}
getUpdates(): Observable<MarketplacePkg[]> {
getUpdates$(): Observable<{ url: string; pkgs: MarketplacePkg[] }[]> {
return this.updates$
}
getReleaseNotes(id: string): Observable<Record<string, string>> {
if (this.notes.has(id)) {
return of(this.notes.get(id) || {})
async installPackage(
id: string,
version: string,
url: string,
): Promise<void> {
const params: RR.InstallPackageReq = {
id,
'version-spec': `=${version}`,
'marketplace-url': url,
}
return this.marketplace$.pipe(
switchMap(({ url }) => this.loadReleaseNotes(id, url)),
tap(response => this.notes.set(id, response)),
catchError(e => {
this.errToast.present(e)
await this.api.installPackage(params)
}
return of({})
async validateMarketplace(url: string): Promise<string> {
await this.loadInfo(url)
return this.cache.get(url)!.info!.name
}
fetchReleaseNotes(
id: string,
url?: string,
): Observable<Record<string, string>> {
return this.uiMarketplace$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<Record<string, string>>(
`/package/v0/release-notes/${id}`,
{},
url || m.url,
),
)
}),
)
}
installPackage(
req: Omit<RR.InstallPackageReq, 'marketplace-url'>,
): Observable<unknown> {
return this.getMarketplace().pipe(
take(1),
switchMap(({ url }) =>
from(
this.api.installPackage({
...req,
'marketplace-url': url,
}),
),
),
)
}
getPackageMarkdown(type: string, pkgId: string): Observable<string> {
return this.getMarketplace().pipe(
switchMap(({ url }) =>
from(
fetchPackageMarkdown(
id: string,
type: string,
url?: string,
): Observable<string> {
return this.uiMarketplace$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<string>(
`/package/v0/${type}/${pkgId}`,
`/package/v0/${type}/${id}`,
{},
url,
url || m.url,
),
),
),
)
}),
)
}
async getMarketplaceData(
params: RR.GetMarketplaceDataReq,
url: string,
): Promise<RR.GetMarketplaceDataRes> {
return this.api.marketplaceProxy('/package/v0/info', params, url)
private async loadMarketplace(url: string): Promise<MarketplaceData> {
const cachedInfo = this.cache.get(url)?.info
const [info, packages] = await Promise.all([
cachedInfo || this.loadInfo(url),
this.loadPackages({}, url),
])
return { info, packages }
}
async getMarketplacePkgs(
params: Omit<RR.GetMarketplacePackagesReq, 'eos-version-compat'>,
url: string,
eosVersionCompat: string,
): Promise<RR.GetMarketplacePackagesRes> {
let clonedParams = { ...params }
if (params.query) delete params.category
if (clonedParams.ids) clonedParams.ids = JSON.stringify(clonedParams.ids)
const qp: RR.GetMarketplacePackagesReq = {
...clonedParams,
'eos-version-compat': eosVersionCompat,
}
return this.api.marketplaceProxy('/package/v0/index', qp, url)
private async loadInfo(url: string): Promise<MarketplaceInfo> {
const info = await this.fetchInfo(url)
this.updateCache(url, info)
return info
}
private loadReleaseNotes(
private async loadPackage(
id: string,
url: string,
): Observable<Record<string, string>> {
return from(
this.api.marketplaceProxy<Record<string, string>>(
`/package/v0/release-notes/${id}`,
{},
url,
),
): Promise<MarketplacePkg | undefined> {
const pkgs = await this.loadPackages({ ids: [{ id, version: '*' }] }, url)
return pkgs[0]
}
private async loadPackages(
params: Omit<
RR.GetMarketplacePackagesReq,
'eos-version-compat' | 'page' | 'per-page'
>,
url: string,
): Promise<MarketplacePkg[]> {
const pkgs = await this.fetchPackages(params, url)
this.updateCache(url, undefined, pkgs)
return pkgs
}
private async fetchInfo(url: string): Promise<RR.GetMarketplaceDataRes> {
const { id } = await getServerInfo(this.patch)
const params: RR.GetMarketplaceDataReq = {
'server-id': id,
}
return this.api.marketplaceProxy<RR.GetMarketplaceDataRes>(
'/package/v0/info',
params,
url,
)
}
private updateName(
uiMarketplaceData: UIMarketplaceData | undefined,
name: string,
) {
if (!uiMarketplaceData?.['selected-id']) {
return
}
const selectedId = uiMarketplaceData['selected-id']
const knownHosts = uiMarketplaceData['known-hosts']
if (knownHosts[selectedId].name !== name) {
this.api.setDbValue({
pointer: `/marketplace/known-hosts/${selectedId}/name`,
value: name,
})
}
}
private toMarketplace(marketplace: UIMarketplaceData): Marketplace {
return marketplace['selected-id']
? marketplace['known-hosts'][marketplace['selected-id']]
: this.config.marketplace
}
private findPackage(
pkgs: readonly MarketplacePkg[],
private async fetchPackage(
id: string,
version: string,
): MarketplacePkg | undefined {
return pkgs.find(pkg => {
const versionIsSame =
version === '*' ||
this.emver.compare(pkg.manifest.version, version) === 0
url: string,
): Promise<MarketplacePkg | undefined> {
const pkgs = await this.fetchPackages({ ids: [{ id, version }] }, url)
return pkgs[0]
}
return pkg.manifest.id === id && versionIsSame
private async fetchPackages(
params: Omit<
RR.GetMarketplacePackagesReq,
'eos-version-compat' | 'page' | 'per-page'
>,
url: string,
): Promise<RR.GetMarketplacePackagesRes> {
const qp: RR.GetMarketplacePackagesReq = {
...params,
'eos-version-compat': (await getServerInfo(this.patch))[
'eos-version-compat'
],
page: 1,
'per-page': 100,
}
if (qp.ids) qp.ids = JSON.stringify(qp.ids)
return this.api.marketplaceProxy<RR.GetMarketplacePackagesRes>(
'/package/v0/index',
qp,
url,
)
}
private async updateName(
url: string,
name: string,
newName: string,
): Promise<void> {
if (name !== newName) {
this.api.setDbValue(['marketplace', 'known-hosts', url], newName)
}
}
private getPkgFromCache(
id: string,
version: string,
url: string,
): MarketplacePkg | undefined {
return this.cache.get(url)?.packages.find(p => {
const versionIsSame =
version === '*' || this.emver.compare(p.manifest.version, version) === 0
return p.manifest.id === id && versionIsSame
})
}
private updateCache(
url: string,
info?: MarketplaceInfo,
pkgs?: MarketplacePkg[],
): void {
const cache = this.cache.get(url)
let packages = cache?.packages || []
if (pkgs) {
const filtered = packages.filter(
cachedPkg =>
!pkgs.find(pkg => pkg.manifest.id === cachedPkg.manifest.id),
)
packages = filtered.concat(pkgs)
}
this.cache.set(url, {
info: info || cache?.info || null,
packages,
})
}
}

View File

@@ -2,7 +2,6 @@ import { Inject, Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { Observable } from 'rxjs'
import { filter, share, switchMap, take, tap } from 'rxjs/operators'
import { exists, isEmptyObject } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { DataModel, UIData } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service'
@@ -21,7 +20,6 @@ export class PatchDataService extends Observable<DataModel> {
private readonly stream$ = this.connectionService.connected$.pipe(
filter(Boolean),
switchMap(() => this.patch.watch$()),
filter(obj => exists(obj) && !isEmptyObject(obj)),
take(1),
tap(({ ui }) => {
// check for updates to EOS and services
@@ -48,8 +46,8 @@ export class PatchDataService extends Observable<DataModel> {
private checkForUpdates(ui: UIData): void {
if (ui['auto-check-updates'] !== false) {
this.eosService.getEOS()
this.marketplaceService.getPackages().pipe(take(1)).subscribe()
this.marketplaceService.getCategories().pipe(take(1)).subscribe()
this.marketplaceService.getMarketplaceInfo$().pipe(take(1)).subscribe()
this.marketplaceService.getUpdates$().pipe(take(1)).subscribe()
}
}
@@ -64,9 +62,7 @@ export class PatchDataService extends Observable<DataModel> {
backdropDismiss: false,
})
modal.onWillDismiss().then(() => {
this.embassyApi
.setDbValue({ pointer: '/ack-welcome', value: this.config.version })
.catch()
this.embassyApi.setDbValue(['ack-welcome'], this.config.version).catch()
})
await modal.present()

View File

@@ -25,12 +25,11 @@ export interface UIData {
}
export interface UIMarketplaceData {
'selected-id': string | null
'selected-url': string
'known-hosts': {
[id: string]: {
url: string
name: string
}
'https://registry.start9.com/': string
'https://community-registry.start9.com/': string
[url: string]: string
}
}

View File

@@ -100,10 +100,7 @@ export class ServerConfigService {
saveFns: { [key: string]: (val: any) => Promise<any> } = {
'auto-check-updates': async (enabled: boolean) => {
return this.embassyApi.setDbValue({
pointer: '/auto-check-updates',
value: enabled,
})
return this.embassyApi.setDbValue(['auto-check-updates'], enabled)
},
}
}

View File

@@ -1,12 +0,0 @@
import { PatchDB } from 'patch-db-client'
import {
DataModel,
UIMarketplaceData,
} from 'src/app/services/patch-db/data-model'
import { firstValueFrom, map } from 'rxjs'
export function getMarketplace(
patch: PatchDB<DataModel>,
): Promise<UIMarketplaceData> {
return firstValueFrom(patch.watch$('ui', 'marketplace'))
}

View File

@@ -3,16 +3,16 @@ import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { filter, firstValueFrom } from 'rxjs'
import { firstValueFrom } from 'rxjs'
export function getPackage(
export async function getPackage(
patch: PatchDB<DataModel>,
id: string,
): Promise<PackageDataEntry | undefined> {
return firstValueFrom(patch.watch$('package-data', id))
}
export function getAllPackages(
export async function getAllPackages(
patch: PatchDB<DataModel>,
): Promise<Record<string, PackageDataEntry>> {
return firstValueFrom(patch.watch$('package-data'))

View File

@@ -2,6 +2,8 @@ import { PatchDB } from 'patch-db-client'
import { DataModel, ServerInfo } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs'
export function getServerInfo(patch: PatchDB<DataModel>): Promise<ServerInfo> {
export async function getServerInfo(
patch: PatchDB<DataModel>,
): Promise<ServerInfo> {
return firstValueFrom(patch.watch$('server-info'))
}