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": { "mocks": {
"maskAs": "tor", "maskAs": "tor",
"skipStartupAlerts": true "skipStartupAlerts": true
},
"marketplace": {
"url": "https://registry.start9.com/",
"name": "Start9 Marketplace"
} }
}, },
"gitHash": "" "gitHash": ""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import { NgModule, Pipe, PipeTransform } from '@angular/core' import { NgModule, Pipe, PipeTransform } from '@angular/core'
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'
import { Emver } from '@start9labs/shared' import { Emver } from '@start9labs/shared'
import Fuse from 'fuse.js'
@Pipe({ @Pipe({
name: 'filterPackages', name: 'filterPackages',

View File

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

View File

@@ -1,20 +1,26 @@
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { MarketplaceInfo } from '../types/marketplace-info'
import { MarketplacePkg } from '../types/marketplace-pkg' import { MarketplacePkg } from '../types/marketplace-pkg'
import { Marketplace } from '../types/marketplace'
export abstract class AbstractMarketplaceService { export abstract class AbstractMarketplaceService {
abstract getMarketplace(): Observable<Marketplace> abstract getMarketplaceInfo$(): Observable<MarketplaceInfo>
abstract getReleaseNotes(id: string): Observable<Record<string, string>> abstract getPackages$(): Observable<MarketplacePkg[]>
abstract getCategories(): Observable<Set<string>>
abstract getPackages(): Observable<MarketplacePkg[]>
abstract getPackageMarkdown(type: string, pkgId: string): Observable<string>
abstract getPackage( abstract getPackage(
id: string, id: string,
version: 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 { export interface MarketplaceInfo {
categories: string[]
name: string 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 { try {
return this.emver.compare(first, second) as SemverResult return this.emver.compare(first, second) as SemverResult
} catch (e) { } catch (e) {
console.warn(`emver comparison failed`, e, first, second) console.error(`emver comparison failed`, e, first, second)
return 'comparison-impossible' return 'comparison-impossible'
} }
} }

View File

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

View File

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

View File

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

View File

@@ -38,10 +38,10 @@ export class SnekDirective {
await loader.present() await loader.present()
try { try {
await this.embassyApi.setDbValue({ await this.embassyApi.setDbValue(
pointer: '/gaming/snake/high-score', ['gaming', 'snake', 'high-score'],
value: data.highScore, data.highScore,
}) )
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)
} finally { } 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 { LoadingController, ModalController } from '@ionic/angular'
import { ConfigService } from '../../services/config.service'
import { ApiService } from '../../services/api/embassy-api.service' import { ApiService } from '../../services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared' 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({ @Component({
selector: 'os-update', selector: 'os-update',
templateUrl: './os-update.page.html', templateUrl: './os-update.page.html',
styleUrls: ['./os-update.page.scss'], styleUrls: ['./os-update.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class OSUpdatePage { export class OSUpdatePage {
@Input() releaseNotes!: { [version: string]: string }
versions: { version: string; notes: string }[] = [] versions: { version: string; notes: string }[] = []
constructor( constructor(
private readonly modalCtrl: ModalController, private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController, private readonly loadingCtrl: LoadingController,
private readonly config: ConfigService,
private readonly errToast: ErrorToastService, private readonly errToast: ErrorToastService,
private readonly embassyApi: ApiService, private readonly embassyApi: ApiService,
private readonly eosService: EOSService,
private readonly patch: PatchDB<DataModel>,
) {} ) {}
ngOnInit() { ngOnInit() {
this.versions = Object.keys(this.releaseNotes) const releaseNotes = this.eosService.eos?.['release-notes']!
this.versions = Object.keys(releaseNotes)
.sort() .sort()
.reverse() .reverse()
.map(version => { .map(version => {
return { return {
version, version,
notes: this.releaseNotes[version], notes: releaseNotes[version],
} }
}) })
} }
@@ -45,9 +49,7 @@ export class OSUpdatePage {
await loader.present() await loader.present()
try { try {
await this.embassyApi.updateServer({ await this.embassyApi.updateServer()
'marketplace-url': this.config.marketplace.url,
})
this.dismiss() this.dismiss()
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)

View File

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

View File

@@ -8,15 +8,10 @@
</ion-header> </ion-header>
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<!-- loading -->
<ng-container *ngIf="loading else loaded">
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-container>
<!-- loaded --> <!-- loaded -->
<ng-template #loaded> <ng-container *ngIf="pkgs$ | async as pkgs; else loading">
<app-list-empty <app-list-empty
*ngIf="empty; else list" *ngIf="!pkgs.length; else list"
class="ion-text-center ion-padding" class="ion-text-center ion-padding"
></app-list-empty> ></app-list-empty>
@@ -36,5 +31,10 @@
</ion-row> </ion-row>
</ion-grid> </ion-grid>
</ng-template> </ng-template>
</ng-container>
<!-- loading -->
<ng-template #loading>
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-template> </ng-template>
</ion-content> </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 { PatchDB } from 'patch-db-client'
import { import { DataModel } from 'src/app/services/patch-db/data-model'
DataModel, import { filter, map, pairwise, startWith } from 'rxjs/operators'
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'
@Component({ @Component({
selector: 'app-list', selector: 'app-list',
templateUrl: './app-list.page.html', templateUrl: './app-list.page.html',
styleUrls: ['./app-list.page.scss'], styleUrls: ['./app-list.page.scss'],
providers: [DestroyService], changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppListPage { export class AppListPage {
loading = true readonly pkgs$ = this.patch.watch$('package-data').pipe(
pkgs: readonly PackageDataEntry[] = [] 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( constructor(private readonly patch: PatchDB<DataModel>) {}
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()
}
} }

View File

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

View File

@@ -22,9 +22,7 @@
[dependencies]="dependencies" [dependencies]="dependencies"
></app-show-dependencies> ></app-show-dependencies>
<!-- ** menu ** --> <!-- ** menu ** -->
<app-show-menu <app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
[buttons]="pkg | toButtons: (currentMarketplace$ | async): (altMarketplaceData$ | async)"
></app-show-menu>
</ng-container> </ng-container>
</ion-item-group> </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 { NavController } from '@ionic/angular'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { import {
@@ -14,8 +14,6 @@ import {
import { filter, tap } from 'rxjs/operators' import { filter, tap } from 'rxjs/operators'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared' import { getPkgId } from '@start9labs/shared'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
const STATES = [ const STATES = [
PackageState.Installing, PackageState.Installing,
@@ -49,16 +47,10 @@ export class AppShowPage {
), ),
) )
readonly currentMarketplace$ = this.marketplaceService.getMarketplace()
readonly altMarketplaceData$ = this.marketplaceService.getAltMarketplaceData()
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly navCtrl: NavController, private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {} ) {}
isInstalled({ state }: PackageDataEntry): boolean { isInstalled({ state }: PackageDataEntry): boolean {

View File

@@ -2,17 +2,14 @@ import { Inject, Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { DOCUMENT } from '@angular/common' import { DOCUMENT } from '@angular/common'
import { AlertController, ModalController, NavController } from '@ionic/angular' import { AlertController, ModalController, NavController } from '@ionic/angular'
import { getUrlHostname, MarkdownComponent } from '@start9labs/shared' import { MarkdownComponent } from '@start9labs/shared'
import { import {
DataModel, DataModel,
PackageDataEntry, PackageDataEntry,
UIMarketplaceData,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
import { ModalService } from 'src/app/services/modal.service' import { ModalService } from 'src/app/services/modal.service'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable } from 'rxjs' 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' import { PatchDB } from 'patch-db-client'
export interface Button { export interface Button {
@@ -39,11 +36,7 @@ export class ToButtonsPipe implements PipeTransform {
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
) {} ) {}
transform( transform(pkg: PackageDataEntry): Button[] {
pkg: PackageDataEntry,
currentMarketplace: Marketplace | null,
altMarketplaces: UIMarketplaceData | null | undefined,
): Button[] {
const pkgTitle = pkg.manifest.title const pkgTitle = pkg.manifest.title
return [ return [
@@ -103,7 +96,7 @@ export class ToButtonsPipe implements PipeTransform {
icon: 'receipt-outline', icon: 'receipt-outline',
}, },
// view in marketplace // view in marketplace
this.viewInMarketplaceButton(pkg, currentMarketplace, altMarketplaces), this.viewInMarketplaceButton(pkg),
// donate // donate
{ {
action: () => this.donate(pkg), action: () => this.donate(pkg),
@@ -116,10 +109,7 @@ export class ToButtonsPipe implements PipeTransform {
private async presentModalInstructions(pkg: PackageDataEntry) { private async presentModalInstructions(pkg: PackageDataEntry) {
this.apiService this.apiService
.setDbValue({ .setDbValue(['ack-instructions', pkg.manifest.id], true)
pointer: `/ack-instructions/${pkg.manifest.id}`,
value: true,
})
.catch(e => console.error('Failed to mark instructions as seen', e)) .catch(e => console.error('Failed to mark instructions as seen', e))
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
@@ -135,51 +125,27 @@ export class ToButtonsPipe implements PipeTransform {
await modal.present() await modal.present()
} }
private viewInMarketplaceButton( private viewInMarketplaceButton(pkg: PackageDataEntry): Button {
pkg: PackageDataEntry, const url = pkg.installed?.['marketplace-url']
currentMarketplace: Marketplace | null, const queryParams = url ? { url } : {}
altMarketplaces: UIMarketplaceData | null | undefined,
): Button {
const pkgMarketplaceUrl = pkg.installed?.['marketplace-url']
// default button if package marketplace and current marketplace are the same
let button: Button = { let button: Button = {
title: 'Marketplace', title: 'Marketplace',
icon: 'storefront-outline', icon: 'storefront-outline',
action: () => action: () =>
this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`]), this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`], {
queryParams,
}),
disabled: false, disabled: false,
description: 'View service in marketplace', description: 'View service in marketplace',
} }
if (!pkgMarketplaceUrl) {
if (!url) {
button.disabled = true button.disabled = true
button.description = 'This package was not installed from a marketplace.' button.description = 'This package was not installed from a marketplace.'
button.action = () => {} 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 return button
} }
@@ -195,22 +161,4 @@ export class ToButtonsPipe implements PipeTransform {
await alert.present() 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, PackageDataEntry,
PackageMainStatus, PackageMainStatus,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
import { exists, isEmptyObject } from '@start9labs/shared' import { isEmptyObject } from '@start9labs/shared'
import { filter, map, startWith } from 'rxjs/operators' import { map, startWith } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
@@ -27,7 +27,6 @@ export class ToHealthChecksPipe implements PipeTransform {
const healthChecks$ = this.patch const healthChecks$ = this.patch
.watch$('package-data', pkg.manifest.id, 'installed', 'status', 'main') .watch$('package-data', pkg.manifest.id, 'installed', 'status', 'main')
.pipe( .pipe(
filter(obj => exists(obj)),
map(main => { map(main => {
// Question: is this ok or do we have to use Object.keys // Question: is this ok or do we have to use Object.keys
// to maintain order and the keys initially present in pkg? // to maintain order and the keys initially present in pkg?

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title>Marketplace</ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<badge-menu-button></badge-menu-button> <badge-menu-button></badge-menu-button>
</ion-buttons> </ion-buttons>
@@ -28,14 +29,13 @@
</ion-row> </ion-row>
<ion-row class="ion-align-items-center"> <ion-row class="ion-align-items-center">
<ion-col size="12"> <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"> <ng-container *ngIf="localPkgs$ | async as localPkgs">
<marketplace-categories <marketplace-categories
*ngIf="categories$ | async as categories" [categories]="marketplace.categories"
[categories]="categories"
[category]="category" [category]="category"
[updatesAvailable]=" [updatesAvailable]="
(pkgs | filterPackages: '':'updates':localPkgs).length (marketplace.pkgs | filterPackages: '':'updates':localPkgs).length
" "
(categoryChange)="onCategoryChange($event)" (categoryChange)="onCategoryChange($event)"
></marketplace-categories> ></marketplace-categories>
@@ -43,7 +43,7 @@
<div class="divider"></div> <div class="divider"></div>
<ion-grid <ion-grid
*ngIf="pkgs | filterPackages: query:category:localPkgs as filtered" *ngIf="marketplace.pkgs | filterPackages: query:category:localPkgs as filtered"
> >
<div <div
*ngIf="!filtered.length && category === 'updates'" *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 { AbstractMarketplaceService } from '@start9labs/marketplace'
import { getUrlHostname } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client' 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' import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({ @Component({
@@ -12,25 +12,45 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MarketplaceListPage { 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 localPkgs$ = this.patch.watch$('package-data')
readonly categories$ = this.marketplaceService.getCategories()
readonly pkgs$ = this.marketplaceService.getPackages() readonly details$ = this.marketplaceService.getUiMarketplace$().pipe(
readonly details$ = this.marketplaceService.getMarketplace().pipe( map(({ url, name }) => {
map(d => {
let color: string let color: string
let description: string let description: string
switch (getUrlHostname(d.url)) { switch (url) {
case 'registry.start9.com': case 'https://registry.start9.com/':
color = 'success' color = 'success'
description = 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.' '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 break
case 'beta-registry-0-3.start9labs.com': case 'https://beta-registry-0-3.start9labs.com/':
color = 'primary' color = 'primary'
description = 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.' '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 break
case 'community.start9labs.com': case 'https://community.start9labs.com/':
color = 'tertiary' color = 'tertiary'
description = 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.' '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 { return {
...d, name,
url,
color, color,
description, description,
} }
@@ -52,7 +73,8 @@ export class MarketplaceListPage {
constructor( constructor(
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
private readonly marketplaceService: AbstractMarketplaceService, @Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {} ) {}
category = 'featured' category = 'featured'

View File

@@ -31,6 +31,9 @@ import { firstValueFrom } from 'rxjs'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MarketplaceShowControlsComponent { export class MarketplaceShowControlsComponent {
@Input()
url?: string
@Input() @Input()
pkg!: MarketplacePkg pkg!: MarketplacePkg
@@ -58,22 +61,81 @@ export class MarketplaceShowControlsComponent {
} }
async tryInstall() { async tryInstall() {
const currentMarketplace = await firstValueFrom(
this.marketplaceService.getUiMarketplace$(),
)
const url = this.url || currentMarketplace.url
if (!this.localPkg) { if (!this.localPkg) {
this.alertInstall() this.alertInstall(url)
} else { } else {
const originalUrl = this.localPkg.installed?.['marketplace-url']
if (url !== originalUrl) {
const proceed = await this.presentAlertDifferentMarketplace(
url,
originalUrl,
)
if (!proceed) return
}
if ( if (
this.emver.compare(this.localVersion, this.pkg.manifest.version) !== this.emver.compare(this.localVersion, this.pkg.manifest.version) !==
0 && 0 &&
hasCurrentDeps(this.localPkg) hasCurrentDeps(this.localPkg)
) { ) {
this.dryInstall() this.dryInstall(url)
} else { } 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({ const loader = await this.loadingCtrl.create({
message: 'Checking dependent services...', message: 'Checking dependent services...',
}) })
@@ -88,12 +150,12 @@ export class MarketplaceShowControlsComponent {
}) })
if (isEmptyObject(breakages)) { if (isEmptyObject(breakages)) {
this.install(loader) this.install(url, loader)
} else { } else {
await loader.dismiss() await loader.dismiss()
const proceed = await this.presentAlertBreakages(breakages) const proceed = await this.presentAlertBreakages(breakages)
if (proceed) { if (proceed) {
this.install() this.install(url)
} }
} }
} catch (e: any) { } 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 const installAlert = this.pkg.manifest.alerts.install
if (!installAlert) return this.install() if (!installAlert) return this.install(url)
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Alert', header: 'Alert',
@@ -117,7 +179,7 @@ export class MarketplaceShowControlsComponent {
{ {
text: 'Install', text: 'Install',
handler: () => { handler: () => {
this.install() this.install(url)
}, },
cssClass: 'enter-click', cssClass: 'enter-click',
}, },
@@ -126,7 +188,7 @@ export class MarketplaceShowControlsComponent {
await alert.present() await alert.present()
} }
private async install(loader?: HTMLIonLoadingElement) { private async install(url: string, loader?: HTMLIonLoadingElement) {
const message = 'Beginning Install...' const message = 'Beginning Install...'
if (loader) { if (loader) {
loader.message = message loader.message = message
@@ -138,12 +200,7 @@ export class MarketplaceShowControlsComponent {
const { id, version } = this.pkg.manifest const { id, version } = this.pkg.manifest
try { try {
await firstValueFrom( await this.marketplaceService.installPackage(id, version, url)
this.marketplaceService.installPackage({
id,
'version-spec': `=${version}`,
}),
)
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)
} finally { } finally {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Component, Inject } from '@angular/core' import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { import {
ActionSheetController, ActionSheetController,
AlertController, AlertController,
@@ -6,47 +6,44 @@ import {
ModalController, ModalController,
} from '@ionic/angular' } from '@ionic/angular'
import { ActionSheetButton } from '@ionic/core' import { ActionSheetButton } from '@ionic/core'
import { import { ErrorToastService } from '@start9labs/shared'
DestroyService,
ErrorToastService,
getUrlHostname,
} from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace' import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ValueSpecObject } from 'src/app/pkg-config/config-types' import { ValueSpecObject } from 'src/app/pkg-config/config-types'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page' import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { v4 } from 'uuid' import { DataModel } from '../../../services/patch-db/data-model'
import {
DataModel,
UIMarketplaceData,
} from '../../../services/patch-db/data-model'
import { ConfigService } from '../../../services/config.service'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { import { map } from 'rxjs/operators'
distinctUntilChanged, import { firstValueFrom } from 'rxjs'
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
}[]
@Component({ @Component({
selector: 'marketplaces', selector: 'marketplaces',
templateUrl: 'marketplaces.page.html', templateUrl: 'marketplaces.page.html',
styleUrls: ['marketplaces.page.scss'], styleUrls: ['marketplaces.page.scss'],
providers: [DestroyService], changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class MarketplacesPage { export class MarketplacesPage {
selectedId: string | null = null marketplace$ = this.patch.watch$('ui', 'marketplace').pipe(
marketplaces: Marketplaces = [] 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( constructor(
private readonly api: ApiService, private readonly api: ApiService,
@@ -56,55 +53,28 @@ export class MarketplacesPage {
private readonly actionCtrl: ActionSheetController, private readonly actionCtrl: ActionSheetController,
@Inject(AbstractMarketplaceService) @Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService, private readonly marketplaceService: MarketplaceService,
private readonly config: ConfigService,
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
private readonly destroy$: DestroyService,
private readonly alertCtrl: AlertController, 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() { async presentModalAdd() {
const marketplaceSpec = getMarketplaceValueSpec() const { name, spec } = getMarketplaceValueSpec()
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
component: GenericFormPage, component: GenericFormPage,
componentProps: { componentProps: {
title: marketplaceSpec.name, title: name,
spec: marketplaceSpec.spec, spec,
buttons: [ buttons: [
{ {
text: 'Save for Later', text: 'Save for Later',
handler: (value: { url: string }) => { handler: (value: { url: string }) => {
this.save(value.url) this.saveOnly(new URL(value.url))
}, },
}, },
{ {
text: 'Save and Connect', text: 'Save and Connect',
handler: (value: { url: string }) => { handler: (value: { url: string }) => {
this.saveAndConnect(value.url) this.saveAndConnect(new URL(value.url))
}, },
isSubmit: true, isSubmit: true,
}, },
@@ -116,32 +86,31 @@ export class MarketplacesPage {
await modal.present() await modal.present()
} }
async presentAction(id: string | null) { async presentAction(
// no need to view actions if is selected marketplace { url, name }: { url: string; name: string },
const marketplace = await getMarketplace(this.patch) canDelete = false,
if (id === marketplace['selected-id']) return ) {
const buttons: ActionSheetButton[] = [ const buttons: ActionSheetButton[] = [
{ {
text: 'Connect', text: 'Connect',
handler: () => { handler: () => {
this.connect(id) this.connect(url)
}, },
}, },
] ]
if (id) { if (canDelete) {
buttons.unshift({ buttons.unshift({
text: 'Delete', text: 'Delete',
role: 'destructive', role: 'destructive',
handler: () => { handler: () => {
this.presentAlertDelete(id) this.presentAlertDelete(url, name)
}, },
}) })
} }
const action = await this.actionCtrl.create({ const action = await this.actionCtrl.create({
header: this.marketplaces.find(mp => mp.id === id)?.name, header: name,
mode: 'ios', mode: 'ios',
buttons, buttons,
}) })
@@ -149,55 +118,7 @@ export class MarketplacesPage {
await action.present() await action.present()
} }
private async connect(id: string | null): Promise<void> { private async presentAlertDelete(url: string, name: string) {
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
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Confirm', header: 'Confirm',
message: `Are you sure you want to delete ${name}?`, message: `Are you sure you want to delete ${name}?`,
@@ -208,7 +129,7 @@ export class MarketplacesPage {
}, },
{ {
text: 'Delete', text: 'Delete',
handler: () => this.delete(id), handler: () => this.delete(url),
cssClass: 'enter-click', cssClass: 'enter-click',
}, },
], ],
@@ -217,125 +138,104 @@ export class MarketplacesPage {
await alert.present() await alert.present()
} }
private async delete(id: string): Promise<void> { private async connect(
const data = await getMarketplace(this.patch) url: string,
const marketplace: UIMarketplaceData = JSON.parse(JSON.stringify(data)) 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({ const loader = await this.loadingCtrl.create({
message: 'Deleting...', message: 'Deleting...',
}) })
await loader.present() 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 { try {
delete marketplace['known-hosts'][id] await this.api.setDbValue(['marketplace', 'known-hosts'], filtered)
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e: any) { } catch (e: any) {
this.errToast.present(e) this.errToast.present(e)
} finally { } finally {
loader.dismiss() 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 { function getMarketplaceValueSpec(): ValueSpecObject {

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,10 +18,12 @@ import { Observable } from 'rxjs'
import { AuthService } from '../auth.service' import { AuthService } from '../auth.service'
import { DOCUMENT } from '@angular/common' import { DOCUMENT } from '@angular/common'
import { DataModel } from '../patch-db/data-model' import { DataModel } from '../patch-db/data-model'
import { PatchDB, Update } from 'patch-db-client' import { PatchDB, pathFromArray, Update } from 'patch-db-client'
@Injectable() @Injectable()
export class LiveApiService extends ApiService { export class LiveApiService extends ApiService {
readonly eosMarketplaceUrl = 'https://registry.start9.com/'
constructor( constructor(
@Inject(DOCUMENT) private readonly document: Document, @Inject(DOCUMENT) private readonly document: Document,
private readonly http: HttpService, private readonly http: HttpService,
@@ -52,7 +54,12 @@ export class LiveApiService extends ApiService {
// db // 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 }) return this.rpcRequest({ method: 'db.put.ui', params })
} }
@@ -127,13 +134,11 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'server.metrics', params }) 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 }) 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( async restartServer(
@@ -160,12 +165,12 @@ export class LiveApiService extends ApiService {
// marketplace URLs // 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 }) 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({ return this.rpcRequest({
method: 'marketplace.get', method: 'marketplace.get',
params: { url: fullURL }, params: { url: fullUrl },
}) })
} }
@@ -175,7 +180,7 @@ export class LiveApiService extends ApiService {
return this.marketplaceProxy( return this.marketplaceProxy(
'/eos/v0/latest', '/eos/v0/latest',
params, params,
this.config.marketplace.url, this.eosMarketplaceUrl,
) )
} }

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,21 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Emver, ErrorToastService } from '@start9labs/shared' import { Emver } from '@start9labs/shared'
import { import {
MarketplacePkg, MarketplacePkg,
AbstractMarketplaceService, AbstractMarketplaceService,
Marketplace, MarketplaceInfo,
FilterPackagesPipe,
MarketplaceData,
} from '@start9labs/marketplace' } 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 { 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 { import {
DataModel, DataModel,
ServerInfo, Manifest,
UIMarketplaceData, PackageState,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { import {
catchError,
distinctUntilChanged, distinctUntilChanged,
filter,
map, map,
shareReplay, shareReplay,
startWith, startWith,
@@ -28,275 +23,321 @@ import {
take, take,
tap, tap,
} from 'rxjs/operators' } 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() @Injectable()
export class MarketplaceService extends AbstractMarketplaceService { export class MarketplaceService implements AbstractMarketplaceService {
private readonly notes = new Map<string, Record<string, string>>() private readonly cache: MasterCache = new Map()
private readonly hasPackages$ = new Subject<boolean>()
private readonly uiMarketplaceData$ = this.patch private readonly uiMarketplace$: Observable<{ url: string; name: string }> =
.watch$('ui', 'marketplace') this.patch.watch$('ui', 'marketplace').pipe(
.pipe(
distinctUntilChanged( 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), shareReplay(1),
) )
private readonly marketplace$ = this.uiMarketplaceData$.pipe( private readonly marketplaceData$: Observable<MarketplaceData> =
map(data => this.toMarketplace(data)), this.uiMarketplace$.pipe(
) switchMap(({ url, name }) =>
from(this.loadMarketplace(url)).pipe(
private readonly serverInfo$: Observable<ServerInfo> = this.patch tap(data => {
.watch$('server-info') this.updateName(url, name, data.info!.name)
.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))),
),
), ),
), ),
shareReplay(1), shareReplay(1),
) )
private readonly categories$: Observable<Set<string>> = private readonly marketplaceInfo$: Observable<MarketplaceInfo> =
this.registryData$.pipe( this.marketplaceData$.pipe(map(data => data.info!))
map(
({ categories }) =>
new Set(['featured', 'updates', ...categories, 'all']),
),
)
private readonly pkgs$: Observable<MarketplacePkg[]> = this.marketplace$.pipe( private readonly marketplacePkgs$: Observable<MarketplacePkg[]> =
switchMap(({ url }) => this.marketplaceData$.pipe(map(data => data.packages))
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)
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), 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( constructor(
private readonly api: ApiService, private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
private readonly errToast: ErrorToastService,
private readonly emver: Emver, private readonly emver: Emver,
private readonly filterPkgsPipe: FilterPackagesPipe, ) {}
) {
super() getUiMarketplace$(): Observable<{ url: string; name: string }> {
return this.uiMarketplace$
} }
getMarketplace(): Observable<Marketplace> { getMarketplaceInfo$(): Observable<MarketplaceInfo> {
return this.marketplace$ return this.marketplaceInfo$
} }
getAltMarketplaceData() { getPackages$(): Observable<MarketplacePkg[]> {
return this.uiMarketplaceData$ return this.marketplacePkgs$
} }
getCategories(): Observable<Set<string>> { getPackage(
return this.categories$ id: string,
} version: string,
url?: string,
getPackages(): Observable<MarketplacePkg[]> { ): Observable<MarketplacePkg | undefined> {
return this.pkgs$ return this.uiMarketplace$.pipe(
} switchMap(m => {
url = url || m.url
getPackage(id: string, version: string): Observable<MarketplacePkg | null> { if (this.cache.has(url)) {
const params = { ids: [{ id, version }] } const pkg = this.getPkgFromCache(id, version, url)
const fallback$ = this.marketplace$.pipe( if (pkg) return of(pkg)
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 : ''}`)
} }
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$ return this.updates$
} }
getReleaseNotes(id: string): Observable<Record<string, string>> { async installPackage(
if (this.notes.has(id)) { id: string,
return of(this.notes.get(id) || {}) version: string,
url: string,
): Promise<void> {
const params: RR.InstallPackageReq = {
id,
'version-spec': `=${version}`,
'marketplace-url': url,
} }
return this.marketplace$.pipe( await this.api.installPackage(params)
switchMap(({ url }) => this.loadReleaseNotes(id, url)), }
tap(response => this.notes.set(id, response)),
catchError(e => {
this.errToast.present(e)
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( fetchPackageMarkdown(
req: Omit<RR.InstallPackageReq, 'marketplace-url'>, id: string,
): Observable<unknown> { type: string,
return this.getMarketplace().pipe( url?: string,
take(1), ): Observable<string> {
switchMap(({ url }) => return this.uiMarketplace$.pipe(
from( switchMap(m => {
this.api.installPackage({ return from(
...req,
'marketplace-url': url,
}),
),
),
)
}
getPackageMarkdown(type: string, pkgId: string): Observable<string> {
return this.getMarketplace().pipe(
switchMap(({ url }) =>
from(
this.api.marketplaceProxy<string>( this.api.marketplaceProxy<string>(
`/package/v0/${type}/${pkgId}`, `/package/v0/${type}/${id}`,
{}, {},
url, url || m.url,
), ),
), )
), }),
) )
} }
async getMarketplaceData( private async loadMarketplace(url: string): Promise<MarketplaceData> {
params: RR.GetMarketplaceDataReq, const cachedInfo = this.cache.get(url)?.info
url: string, const [info, packages] = await Promise.all([
): Promise<RR.GetMarketplaceDataRes> { cachedInfo || this.loadInfo(url),
return this.api.marketplaceProxy('/package/v0/info', params, url) this.loadPackages({}, url),
])
return { info, packages }
} }
async getMarketplacePkgs( private async loadInfo(url: string): Promise<MarketplaceInfo> {
params: Omit<RR.GetMarketplacePackagesReq, 'eos-version-compat'>, const info = await this.fetchInfo(url)
url: string, this.updateCache(url, info)
eosVersionCompat: string, return info
): 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 loadReleaseNotes( private async loadPackage(
id: string, id: string,
url: string, url: string,
): Observable<Record<string, string>> { ): Promise<MarketplacePkg | undefined> {
return from( const pkgs = await this.loadPackages({ ids: [{ id, version: '*' }] }, url)
this.api.marketplaceProxy<Record<string, string>>( return pkgs[0]
`/package/v0/release-notes/${id}`, }
{},
url, 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( private async fetchPackage(
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[],
id: string, id: string,
version: string, version: string,
): MarketplacePkg | undefined { url: string,
return pkgs.find(pkg => { ): Promise<MarketplacePkg | undefined> {
const versionIsSame = const pkgs = await this.fetchPackages({ ids: [{ id, version }] }, url)
version === '*' || return pkgs[0]
this.emver.compare(pkg.manifest.version, version) === 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 { ModalController } from '@ionic/angular'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { filter, share, switchMap, take, tap } from 'rxjs/operators' import { filter, share, switchMap, take, tap } from 'rxjs/operators'
import { exists, isEmptyObject } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { DataModel, UIData } from 'src/app/services/patch-db/data-model' import { DataModel, UIData } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service' 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( private readonly stream$ = this.connectionService.connected$.pipe(
filter(Boolean), filter(Boolean),
switchMap(() => this.patch.watch$()), switchMap(() => this.patch.watch$()),
filter(obj => exists(obj) && !isEmptyObject(obj)),
take(1), take(1),
tap(({ ui }) => { tap(({ ui }) => {
// check for updates to EOS and services // check for updates to EOS and services
@@ -48,8 +46,8 @@ export class PatchDataService extends Observable<DataModel> {
private checkForUpdates(ui: UIData): void { private checkForUpdates(ui: UIData): void {
if (ui['auto-check-updates'] !== false) { if (ui['auto-check-updates'] !== false) {
this.eosService.getEOS() this.eosService.getEOS()
this.marketplaceService.getPackages().pipe(take(1)).subscribe() this.marketplaceService.getMarketplaceInfo$().pipe(take(1)).subscribe()
this.marketplaceService.getCategories().pipe(take(1)).subscribe() this.marketplaceService.getUpdates$().pipe(take(1)).subscribe()
} }
} }
@@ -64,9 +62,7 @@ export class PatchDataService extends Observable<DataModel> {
backdropDismiss: false, backdropDismiss: false,
}) })
modal.onWillDismiss().then(() => { modal.onWillDismiss().then(() => {
this.embassyApi this.embassyApi.setDbValue(['ack-welcome'], this.config.version).catch()
.setDbValue({ pointer: '/ack-welcome', value: this.config.version })
.catch()
}) })
await modal.present() await modal.present()

View File

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

View File

@@ -100,10 +100,7 @@ export class ServerConfigService {
saveFns: { [key: string]: (val: any) => Promise<any> } = { saveFns: { [key: string]: (val: any) => Promise<any> } = {
'auto-check-updates': async (enabled: boolean) => { 'auto-check-updates': async (enabled: boolean) => {
return this.embassyApi.setDbValue({ return this.embassyApi.setDbValue(['auto-check-updates'], enabled)
pointer: '/auto-check-updates',
value: 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, DataModel,
PackageDataEntry, PackageDataEntry,
} from 'src/app/services/patch-db/data-model' } 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>, patch: PatchDB<DataModel>,
id: string, id: string,
): Promise<PackageDataEntry | undefined> { ): Promise<PackageDataEntry | undefined> {
return firstValueFrom(patch.watch$('package-data', id)) return firstValueFrom(patch.watch$('package-data', id))
} }
export function getAllPackages( export async function getAllPackages(
patch: PatchDB<DataModel>, patch: PatchDB<DataModel>,
): Promise<Record<string, PackageDataEntry>> { ): Promise<Record<string, PackageDataEntry>> {
return firstValueFrom(patch.watch$('package-data')) 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 { DataModel, ServerInfo } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs' 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')) return firstValueFrom(patch.watch$('server-info'))
} }