From e6f0067728ce8a7b6c7bae790c5c05f4dc69f180 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Wed, 30 Apr 2025 13:50:08 -0600 Subject: [PATCH] rework installing page and add cancel install button (#2915) * rework installing page and add cancel install button * actually call cancel endpoint * fix two bugs * include translations in progress component * cancellable installs * fix: comments (#2916) * fix: comments * delete comments * ensure trailing slash and no qp for new registry url --------- Co-authored-by: Matt Hill * fix raspi * bump sdk --------- Co-authored-by: Aiden McClelland Co-authored-by: Alex Inkin --- core/startos/src/context/rpc.rs | 4 +- core/startos/src/install/mod.rs | 31 ++++- core/startos/src/lib.rs | 7 ++ core/startos/src/registry/asset.rs | 12 +- core/startos/src/service/service_map.rs | 93 ++++++++------ core/startos/src/upload.rs | 26 +++- image-recipe/build.sh | 4 +- sdk/package/lib/StartSdk.ts | 7 +- sdk/package/lib/mainFn/Daemon.ts | 10 +- sdk/package/lib/mainFn/Daemons.ts | 56 ++++----- sdk/package/lib/mainFn/HealthDaemon.ts | 3 - sdk/package/package-lock.json | 4 +- sdk/package/package.json | 2 +- .../install-wizard/src/app/app.component.ts | 2 +- .../src/components/registry.component.ts | 1 - web/projects/marketplace/src/types.ts | 2 +- .../shared/src/i18n/dictionaries/de.ts | 9 +- .../shared/src/i18n/dictionaries/en.ts | 9 +- .../shared/src/i18n/dictionaries/es.ts | 11 +- .../shared/src/i18n/dictionaries/pl.ts | 9 +- .../shared/src/services/loading.service.ts | 2 +- .../components/controls.component.ts | 2 +- .../marketplace/components/menu.component.ts | 3 +- .../marketplace/marketplace.component.ts | 10 +- .../marketplace/modals/preview.component.ts | 2 +- .../marketplace/modals/registry.component.ts | 14 ++- .../marketplace/services/alerts.service.ts | 10 +- .../services/components/progress.component.ts | 112 ++++++++++++++--- .../services/dashboard/status.component.ts | 4 +- .../services/routes/service.component.ts | 69 +++++------ .../routes/backups/progress.component.ts | 6 +- .../routes/updates/updates.component.ts | 14 +-- .../ui/src/app/services/api/api.types.ts | 3 + .../app/services/api/embassy-api.service.ts | 4 + .../services/api/embassy-live-api.service.ts | 6 + .../services/api/embassy-mock-api.service.ts | 21 +++- .../src/app/services/marketplace.service.ts | 116 +++++++----------- 37 files changed, 431 insertions(+), 269 deletions(-) diff --git a/core/startos/src/context/rpc.rs b/core/startos/src/context/rpc.rs index 116af59b7..32ba474fa 100644 --- a/core/startos/src/context/rpc.rs +++ b/core/startos/src/context/rpc.rs @@ -16,7 +16,7 @@ use models::{ActionId, PackageId}; use reqwest::{Client, Proxy}; use rpc_toolkit::yajrc::RpcError; use rpc_toolkit::{CallRemote, Context, Empty}; -use tokio::sync::{broadcast, watch, Mutex, RwLock}; +use tokio::sync::{broadcast, oneshot, watch, Mutex, RwLock}; use tokio::time::Instant; use tracing::instrument; @@ -56,6 +56,7 @@ pub struct RpcContextSeed { pub os_net_service: NetService, pub s9pk_arch: Option<&'static str>, pub services: ServiceMap, + pub cancellable_installs: SyncMutex>>, pub metrics_cache: Watch>, pub shutdown: broadcast::Sender>, pub tor_socks: SocketAddr, @@ -239,6 +240,7 @@ impl RpcContext { Some(crate::ARCH) }, services, + cancellable_installs: SyncMutex::new(BTreeMap::new()), metrics_cache, shutdown, tor_socks: tor_proxy, diff --git a/core/startos/src/install/mod.rs b/core/startos/src/install/mod.rs index d7a939271..06c60afdf 100644 --- a/core/startos/src/install/mod.rs +++ b/core/startos/src/install/mod.rs @@ -154,13 +154,15 @@ pub async fn install( })? .s9pk; + let progress_tracker = FullProgressTracker::new(); + let download_progress = progress_tracker.add_phase("Downloading".into(), Some(100)); let download = ctx .services .install( ctx.clone(), - || asset.deserialize_s9pk_buffered(ctx.client.clone()), + || asset.deserialize_s9pk_buffered(ctx.client.clone(), download_progress), None::, - None, + Some(progress_tracker), ) .await?; tokio::spawn(async move { download.await?.await }); @@ -188,10 +190,15 @@ pub async fn sideload( ctx: RpcContext, SideloadParams { session }: SideloadParams, ) -> Result { - let (upload, file) = upload(&ctx, session.clone()).await?; let (err_send, mut err_recv) = oneshot::channel::(); let progress = Guid::new(); let progress_tracker = FullProgressTracker::new(); + let (upload, file) = upload( + &ctx, + session.clone(), + progress_tracker.add_phase("Uploading".into(), Some(100)), + ) + .await?; let mut progress_listener = progress_tracker.stream(Some(Duration::from_millis(200))); ctx.rpc_continuations .add( @@ -268,6 +275,24 @@ pub async fn sideload( Ok(SideloadResponse { upload, progress }) } +#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[command(rename_all = "kebab-case")] +pub struct CancelInstallParams { + pub id: PackageId, +} + +#[instrument(skip_all)] +pub fn cancel_install( + ctx: RpcContext, + CancelInstallParams { id }: CancelInstallParams, +) -> Result<(), Error> { + if let Some(cancel) = ctx.cancellable_installs.mutate(|c| c.remove(&id)) { + cancel.send(()).ok(); + } + Ok(()) +} + #[derive(Deserialize, Serialize, Parser)] pub struct QueryPackageParams { id: PackageId, diff --git a/core/startos/src/lib.rs b/core/startos/src/lib.rs index 2415961e6..db759ffc9 100644 --- a/core/startos/src/lib.rs +++ b/core/startos/src/lib.rs @@ -349,6 +349,13 @@ pub fn package() -> ParentHandler { .no_display() .with_about("Install a package from a marketplace or via sideloading"), ) + .subcommand( + "cancel-install", + from_fn(install::cancel_install) + .no_display() + .with_about("Cancel an install of a package") + .with_call_remote::(), + ) .subcommand( "uninstall", from_fn_async(install::uninstall) diff --git a/core/startos/src/registry/asset.rs b/core/startos/src/registry/asset.rs index fb6dd59fc..f79dee343 100644 --- a/core/startos/src/registry/asset.rs +++ b/core/startos/src/registry/asset.rs @@ -10,6 +10,7 @@ use ts_rs::TS; use url::Url; use crate::prelude::*; +use crate::progress::PhaseProgressTrackerHandle; use crate::registry::signer::commitment::merkle_archive::MerkleArchiveCommitment; use crate::registry::signer::commitment::{Commitment, Digestable}; use crate::registry::signer::sign::{AnySignature, AnyVerifyingKey}; @@ -75,9 +76,10 @@ impl RegistryAsset { pub async fn deserialize_s9pk_buffered( &self, client: Client, + progress: PhaseProgressTrackerHandle, ) -> Result>>, Error> { S9pk::deserialize( - &Arc::new(BufferedHttpSource::new(client, self.url.clone()).await?), + &Arc::new(BufferedHttpSource::new(client, self.url.clone(), progress).await?), Some(&self.commitment), ) .await @@ -89,8 +91,12 @@ pub struct BufferedHttpSource { file: UploadingFile, } impl BufferedHttpSource { - pub async fn new(client: Client, url: Url) -> Result { - let (mut handle, file) = UploadingFile::new().await?; + pub async fn new( + client: Client, + url: Url, + progress: PhaseProgressTrackerHandle, + ) -> Result { + let (mut handle, file) = UploadingFile::new(progress).await?; let response = client.get(url).send().await?; Ok(Self { _download: tokio::spawn(async move { handle.download(response).await }).into(), diff --git a/core/startos/src/service/service_map.rs b/core/startos/src/service/service_map.rs index 777be2bd7..235f36f01 100644 --- a/core/startos/src/service/service_map.rs +++ b/core/startos/src/service/service_map.rs @@ -3,14 +3,14 @@ use std::sync::Arc; use std::time::Duration; use color_eyre::eyre::eyre; -use futures::future::BoxFuture; +use futures::future::{BoxFuture, Fuse}; use futures::stream::FuturesUnordered; use futures::{Future, FutureExt, StreamExt}; use helpers::NonDetachingJoinHandle; use imbl::OrdMap; use imbl_value::InternedString; use models::ErrorData; -use tokio::sync::{Mutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock}; +use tokio::sync::{oneshot, Mutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock}; use tracing::instrument; use crate::context::RpcContext; @@ -138,41 +138,41 @@ impl ServiceMap { Fut: Future, Error>>, S: FileSource + Clone, { + let progress = progress.unwrap_or_else(|| FullProgressTracker::new()); + let mut validate_progress = progress.add_phase("Validating Headers".into(), Some(1)); + let mut unpack_progress = progress.add_phase("Unpacking".into(), Some(100)); + let mut s9pk = s9pk().await?; + validate_progress.start(); s9pk.validate_and_filter(ctx.s9pk_arch)?; + validate_progress.complete(); let manifest = s9pk.as_manifest().clone(); let id = manifest.id.clone(); let icon = s9pk.icon_data_url().await?; let developer_key = s9pk.as_archive().signer(); let mut service = self.get_mut(&id).await; - + let size = s9pk.size(); + if let Some(size) = size { + unpack_progress.set_total(size); + } let op_name = if recovery_source.is_none() { if service.is_none() { - "Install" + "Installing" } else { - "Update" + "Updating" } } else { - "Restore" + "Restoring" }; - - let size = s9pk.size(); - let progress = progress.unwrap_or_else(|| FullProgressTracker::new()); - let download_progress_contribution = size.unwrap_or(60); - let mut download_progress = progress.add_phase( - InternedString::intern("Download"), - Some(download_progress_contribution), - ); - if let Some(size) = size { - download_progress.set_total(size); - } - let mut finalization_progress = progress.add_phase( - InternedString::intern(op_name), - Some(download_progress_contribution / 2), - ); + let mut finalization_progress = progress.add_phase(op_name.into(), Some(50)); let restoring = recovery_source.is_some(); - let mut reload_guard = ServiceRefReloadGuard::new(ctx.clone(), id.clone(), op_name); + let (cancel_send, cancel_recv) = oneshot::channel(); + ctx.cancellable_installs + .mutate(|c| c.insert(id.clone(), cancel_send)); + + let mut reload_guard = + ServiceRefReloadCancelGuard::new(ctx.clone(), id.clone(), op_name, Some(cancel_recv)); reload_guard .handle(async { @@ -256,15 +256,15 @@ impl ServiceMap { Some(Duration::from_millis(100)), ))); - download_progress.start(); + unpack_progress.start(); let mut progress_writer = ProgressTrackerWriter::new( crate::util::io::create_file(&download_path).await?, - download_progress, + unpack_progress, ); s9pk.serialize(&mut progress_writer, true).await?; - let (file, mut download_progress) = progress_writer.into_inner(); + let (file, mut unpack_progress) = progress_writer.into_inner(); file.sync_all().await?; - download_progress.complete(); + unpack_progress.complete(); let installed_path = Path::new(DATA_DIR) .join(PKG_ARCHIVE_DIR) @@ -339,7 +339,7 @@ impl ServiceMap { ) -> Result<(), Error> { let mut guard = self.get_mut(id).await; if let Some(service) = guard.take() { - ServiceRefReloadGuard::new(ctx.clone(), id.clone(), "Uninstall") + ServiceRefReloadCancelGuard::new(ctx.clone(), id.clone(), "Uninstall", None) .handle_last(async move { let res = service.uninstall(None, soft, force).await; drop(guard); @@ -370,32 +370,51 @@ impl ServiceMap { } } -pub struct ServiceRefReloadGuard(Option); -impl Drop for ServiceRefReloadGuard { +pub struct ServiceRefReloadCancelGuard( + Option, + Option>>, +); +impl Drop for ServiceRefReloadCancelGuard { fn drop(&mut self) { if let Some(info) = self.0.take() { tokio::spawn(info.reload(None)); } } } -impl ServiceRefReloadGuard { - pub fn new(ctx: RpcContext, id: PackageId, operation: &'static str) -> Self { - Self(Some(ServiceRefReloadInfo { ctx, id, operation })) +impl ServiceRefReloadCancelGuard { + pub fn new( + ctx: RpcContext, + id: PackageId, + operation: &'static str, + cancel: Option>, + ) -> Self { + Self( + Some(ServiceRefReloadInfo { ctx, id, operation }), + cancel.map(|c| c.fuse()), + ) } pub async fn handle( &mut self, operation: impl Future>, ) -> Result { - let mut errors = ErrorCollection::new(); - match operation.await { + let res = async { + if let Some(cancel) = self.1.as_mut() { + tokio::select! { + res = operation => res, + _ = cancel => Err(Error::new(eyre!("Operation Cancelled"), ErrorKind::Cancelled)), + } + } else { + operation.await + } + }.await; + match res { Ok(a) => Ok(a), Err(e) => { if let Some(info) = self.0.take() { - errors.handle(info.reload(Some(e.clone_output())).await); + tokio::spawn(info.reload(Some(e.clone_output()))); } - errors.handle::<(), _>(Err(e)); - errors.into_result().map(|_| unreachable!()) // TODO: there's gotta be a more elegant way? + Err(e) } } } diff --git a/core/startos/src/upload.rs b/core/startos/src/upload.rs index 73519c603..b54091735 100644 --- a/core/startos/src/upload.rs +++ b/core/startos/src/upload.rs @@ -18,6 +18,7 @@ use tokio::sync::watch; use crate::context::RpcContext; use crate::prelude::*; +use crate::progress::PhaseProgressTrackerHandle; use crate::rpc_continuations::{Guid, RpcContinuation}; use crate::s9pk::merkle_archive::source::multi_cursor_file::{FileCursor, MultiCursorFile}; use crate::s9pk::merkle_archive::source::ArchiveSource; @@ -26,9 +27,10 @@ use crate::util::io::{create_file, TmpDir}; pub async fn upload( ctx: &RpcContext, session: Option, + progress: PhaseProgressTrackerHandle, ) -> Result<(Guid, UploadingFile), Error> { let guid = Guid::new(); - let (mut handle, file) = UploadingFile::new().await?; + let (mut handle, file) = UploadingFile::new(progress).await?; ctx.rpc_continuations .add( guid.clone(), @@ -50,8 +52,8 @@ pub async fn upload( Ok((guid, file)) } -#[derive(Default)] struct Progress { + tracker: PhaseProgressTrackerHandle, expected_size: Option, written: u64, error: Option, @@ -69,6 +71,7 @@ impl Progress { match res { Ok(a) => { self.written += *a as u64; + self.tracker += *a as u64; true } Err(e) => self.handle_error(e), @@ -123,6 +126,7 @@ impl Progress { } } fn complete(&mut self) -> bool { + self.tracker.complete(); match self { Self { expected_size: Some(size), @@ -133,6 +137,7 @@ impl Progress { expected_size: Some(size), written, error, + .. } if *written > *size && error.is_none() => { *error = Some(Error::new( eyre!("Too many bytes received"), @@ -171,8 +176,13 @@ pub struct UploadingFile { progress: watch::Receiver, } impl UploadingFile { - pub async fn new() -> Result<(UploadHandle, Self), Error> { - let progress = watch::channel(Progress::default()); + pub async fn new(progress: PhaseProgressTrackerHandle) -> Result<(UploadHandle, Self), Error> { + let progress = watch::channel(Progress { + tracker: progress, + expected_size: None, + written: 0, + error: None, + }); let tmp_dir = Arc::new(TmpDir::new().await?); let file = create_file(tmp_dir.join("upload.tmp")).await?; let uploading = Self { @@ -327,10 +337,12 @@ impl UploadHandle { self.process_headers(request.headers()); self.process_body(request.into_body().into_data_stream()) .await; + self.progress.send_if_modified(|p| p.complete()); } pub async fn download(&mut self, response: reqwest::Response) { self.process_headers(response.headers()); self.process_body(response.bytes_stream()).await; + self.progress.send_if_modified(|p| p.complete()); } fn process_headers(&mut self, headers: &HeaderMap) { if let Some(content_length) = headers @@ -338,8 +350,10 @@ impl UploadHandle { .and_then(|a| a.to_str().log_err()) .and_then(|a| a.parse::().log_err()) { - self.progress - .send_modify(|p| p.expected_size = Some(content_length)); + self.progress.send_modify(|p| { + p.expected_size = Some(content_length); + p.tracker.set_total(content_length); + }); } } async fn process_body>>( diff --git a/image-recipe/build.sh b/image-recipe/build.sh index d5c820591..dbf5805ee 100755 --- a/image-recipe/build.sh +++ b/image-recipe/build.sh @@ -206,8 +206,8 @@ if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then echo "Configuring raspi kernel '\$v'" extract-ikconfig "/usr/lib/modules/\$v/kernel/kernel/configs.ko.xz" > /boot/config-\$v done - mkinitramfs -c gzip -o /boot/initramfs8 6.12.20-v8+ - mkinitramfs -c gzip -o /boot/initramfs_2712 6.12.20-v8-16k+ + mkinitramfs -c gzip -o /boot/initramfs8 6.12.25-v8+ + mkinitramfs -c gzip -o /boot/initramfs_2712 6.12.25-v8-16k+ fi useradd --shell /bin/bash -G startos -m start9 diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index ed1f7e722..7640ff1c2 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -25,7 +25,7 @@ import { import * as patterns from "../../base/lib/util/patterns" import { BackupSync, Backups } from "./backup/Backups" import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants" -import { CommandController, Daemons } from "./mainFn/Daemons" +import { CommandController, Daemon, Daemons } from "./mainFn/Daemons" import { HealthCheck } from "./health/HealthCheck" import { checkPortListening } from "./health/checkFns/checkPortListening" import { checkWebUrl, runHealthScript } from "./health/checkFns" @@ -734,6 +734,11 @@ export class StartSdk { spec: Spec, ) => InputSpec.of(spec), }, + Daemon: { + get of() { + return Daemon.of() + }, + }, Daemons: { of( effects: Effects, diff --git a/sdk/package/lib/mainFn/Daemon.ts b/sdk/package/lib/mainFn/Daemon.ts index 6b99ec844..bd3d13bb3 100644 --- a/sdk/package/lib/mainFn/Daemon.ts +++ b/sdk/package/lib/mainFn/Daemon.ts @@ -1,5 +1,6 @@ import * as T from "../../../base/lib/types" import { asError } from "../../../base/lib/util/asError" +import { Drop } from "../util" import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer" import { CommandController } from "./CommandController" import { Mounts } from "./Mounts" @@ -11,12 +12,14 @@ const MAX_TIMEOUT_MS = 30000 * and the others state of running, where it will keep a living running command */ -export class Daemon { +export class Daemon extends Drop { private commandController: CommandController | null = null private shouldBeRunning = false constructor( private startCommand: () => Promise>, - ) {} + ) { + super() + } get subContainerHandle(): undefined | ExecSpawnable { return this.commandController?.subContainerHandle } @@ -88,4 +91,7 @@ export class Daemon { .catch((e) => console.error(asError(e))) this.commandController = null } + onDrop(): void { + this.stop().catch((e) => console.error(asError(e))) + } } diff --git a/sdk/package/lib/mainFn/Daemons.ts b/sdk/package/lib/mainFn/Daemons.ts index 9b25d37a0..c4637e74f 100644 --- a/sdk/package/lib/mainFn/Daemons.ts +++ b/sdk/package/lib/mainFn/Daemons.ts @@ -51,21 +51,26 @@ export type Ready = { type DaemonsParams< Manifest extends T.SDKManifest, Ids extends string, - Command extends string, Id extends string, -> = { - /** The command line command to start the daemon */ - command: T.CommandType - /** Information about the subcontainer in which the daemon runs */ - subcontainer: SubContainer - env?: Record - ready: Ready - /** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */ - requires: Exclude[] - sigtermTimeout?: number - onStdout?: (chunk: Buffer | string | any) => void - onStderr?: (chunk: Buffer | string | any) => void -} +> = + | { + /** The command line command to start the daemon */ + command: T.CommandType + /** Information about the subcontainer in which the daemon runs */ + subcontainer: SubContainer + env?: Record + ready: Ready + /** An array of IDs of prior daemons whose successful initializations are required before this daemon will initialize */ + requires: Exclude[] + sigtermTimeout?: number + onStdout?: (chunk: Buffer | string | any) => void + onStderr?: (chunk: Buffer | string | any) => void + } + | { + daemon: Daemon + ready: Ready + requires: Exclude[] + } type ErrorDuplicateId = `The id '${Id}' is already used` @@ -136,27 +141,23 @@ export class Daemons * @param newDaemon * @returns */ - addDaemon( + addDaemon( // prettier-ignore id: "" extends Id ? never : ErrorDuplicateId extends Id ? never : Id extends Ids ? ErrorDuplicateId : Id, - options: DaemonsParams, + options: DaemonsParams, ) { - const daemonIndex = this.daemons.length - const daemon = Daemon.of()( - this.effects, - options.subcontainer, - options.command, - { - ...options, - }, - ) + const daemon = + "daemon" in options + ? Promise.resolve(options.daemon) + : Daemon.of()(this.effects, options.subcontainer, options.command, { + ...options, + }) const healthDaemon = new HealthDaemon( daemon, - daemonIndex, options.requires .map((x) => this.ids.indexOf(x)) .filter((x) => x >= 0) @@ -165,7 +166,6 @@ export class Daemons this.ids, options.ready, this.effects, - options.sigtermTimeout, ) const daemons = this.daemons.concat(daemon) const ids = [...this.ids, id] as (Ids | Id)[] @@ -184,7 +184,7 @@ export class Daemons try { this.healthChecks.forEach((health) => health.stop()) for (let result of await Promise.allSettled( - this.healthDaemons.map((x) => x.term({ timeout: x.sigtermTimeout })), + this.healthDaemons.map((x) => x.term()), )) { if (result.status === "rejected") { console.error(result.reason) diff --git a/sdk/package/lib/mainFn/HealthDaemon.ts b/sdk/package/lib/mainFn/HealthDaemon.ts index 0a4521883..a29e433eb 100644 --- a/sdk/package/lib/mainFn/HealthDaemon.ts +++ b/sdk/package/lib/mainFn/HealthDaemon.ts @@ -30,13 +30,11 @@ export class HealthDaemon { private readyPromise: Promise constructor( private readonly daemon: Promise>, - readonly daemonIndex: number, private readonly dependencies: HealthDaemon[], readonly id: string, readonly ids: string[], readonly ready: Ready, readonly effects: Effects, - readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT, ) { this.readyPromise = new Promise((resolve) => (this.resolveReady = resolve)) this.dependencies.forEach((d) => d.addWatcher(() => this.updateStatus())) @@ -53,7 +51,6 @@ export class HealthDaemon { await this.daemon.then((d) => d.term({ - timeout: this.sigtermTimeout, ...termOptions, }), ) diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index 296c61407..83097a9ca 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.11", + "version": "0.4.0-beta.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.11", + "version": "0.4.0-beta.12", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package.json b/sdk/package/package.json index d6f23b828..b7f936ea1 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.11", + "version": "0.4.0-beta.12", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./package/lib/index.js", "types": "./package/lib/index.d.ts", diff --git a/web/projects/install-wizard/src/app/app.component.ts b/web/projects/install-wizard/src/app/app.component.ts index b24dd1ff0..0a5f4e98d 100644 --- a/web/projects/install-wizard/src/app/app.component.ts +++ b/web/projects/install-wizard/src/app/app.component.ts @@ -55,7 +55,7 @@ export class AppComponent { ) .subscribe({ complete: async () => { - const loader = this.loader.open('' as i18nKey).subscribe() + const loader = this.loader.open().subscribe() try { await this.api.reboot() diff --git a/web/projects/marketplace/src/components/registry.component.ts b/web/projects/marketplace/src/components/registry.component.ts index 21b4f432d..618a5e17b 100644 --- a/web/projects/marketplace/src/components/registry.component.ts +++ b/web/projects/marketplace/src/components/registry.component.ts @@ -17,7 +17,6 @@ import { StoreIconComponentModule } from './store-icon/store-icon.component.modu } `, - styles: [':host { border-radius: 0.25rem; width: stretch; }'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [StoreIconComponentModule, TuiIcon, TuiTitle], }) diff --git a/web/projects/marketplace/src/types.ts b/web/projects/marketplace/src/types.ts index 9c7f375d9..032d8f546 100644 --- a/web/projects/marketplace/src/types.ts +++ b/web/projects/marketplace/src/types.ts @@ -25,7 +25,7 @@ export type StoreIdentity = { name: string } -export type Marketplace = Record +export type Marketplace = Record export type StoreData = { info: T.RegistryInfo diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index bf6ff087a..0bda129cf 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -362,8 +362,8 @@ export default { 359: 'Die Partition enthält keine gültige Sicherung', 360: 'Sicherungsfortschritt', 361: 'Abgeschlossen', - 362: 'Sicherung läuft', - 363: 'Warten', + 362: 'sicherung läuft', + 363: 'warten', 364: 'Sicherung erstellt', 365: 'Wiederherstellung ausgewählt', 366: 'Initialisierung', @@ -493,4 +493,9 @@ export default { 490: 'deutsch', 491: 'englisch', 492: 'Startmenü', + 493: 'Installationsfortschritt', + 494: 'Herunterladen', + 495: 'Validierung', + 496: 'in Bearbeitung', + 497: 'abgeschlossen', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 6333248b8..abce9c25c 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -361,8 +361,8 @@ export const ENGLISH = { 'Drive partition does not contain a valid backup': 359, 'Backup Progress': 360, 'Complete': 361, - 'Backing up': 362, - 'Waiting': 363, + 'backing up': 362, + 'waiting': 363, 'Backup made': 364, 'Restore selected': 365, 'Initializing': 366, @@ -492,4 +492,9 @@ export const ENGLISH = { 'german': 490, 'english': 491, 'Start Menu': 492, + 'Install Progress': 493, + 'Downloading': 494, + 'Validating': 495, + 'in progress': 496, + 'complete': 497, } as const diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 1f2c77617..aa5610024 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -362,8 +362,8 @@ export default { 359: 'La partición de la unidad no contiene una copia de seguridad válida', 360: 'Progreso de la copia de seguridad', 361: 'Completo', - 362: 'Haciendo copia de seguridad', - 363: 'Esperando', + 362: 'haciendo copia de seguridad', + 363: 'esperando', 364: 'Copia de seguridad realizada', 365: 'Restauración seleccionada', 366: 'Inicializando', @@ -493,4 +493,9 @@ export default { 490: 'alemán', 491: 'inglés', 492: 'Menú de Inicio', -} as any satisfies i18n + 493: 'Progreso de instalación', + 494: 'Descargando', + 495: 'Validando', + 496: 'en progreso', + 497: 'completo', +} satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 5050f2165..360f73f91 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -362,8 +362,8 @@ export default { 359: 'Partycja dysku nie zawiera prawidłowej kopii zapasowej', 360: 'Postęp tworzenia kopii zapasowej', 361: 'Zakończono', - 362: 'Tworzenie kopii zapasowej', - 363: 'Oczekiwanie', + 362: 'tworzenie kopii zapasowej', + 363: 'oczekiwanie', 364: 'Kopia zapasowa utworzona', 365: 'Wybrano przywracanie', 366: 'Inicjalizacja', @@ -493,4 +493,9 @@ export default { 490: 'niemiecki', 491: 'angielski', 492: 'Menu Startowe', + 493: 'Postęp instalacji', + 494: 'Pobieranie', + 495: 'Weryfikowanie', + 496: 'w toku', + 497: 'zakończono', } satisfies i18n diff --git a/web/projects/shared/src/services/loading.service.ts b/web/projects/shared/src/services/loading.service.ts index 605fc8134..1a8ddb758 100644 --- a/web/projects/shared/src/services/loading.service.ts +++ b/web/projects/shared/src/services/loading.service.ts @@ -39,7 +39,7 @@ class LoadingComponent { useFactory: () => new LoadingService(TUI_DIALOGS, LoadingComponent), }) export class LoadingService extends TuiPopoverService { - override open(textContent: i18nKey) { + override open(textContent: i18nKey | '' = '') { return super.open(textContent) } } diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts index f8b651001..a08939779 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/controls.component.ts @@ -127,7 +127,7 @@ export class MarketplaceControlsComponent { async tryInstall() { const currentUrl = this.file ? null - : await firstValueFrom(this.marketplaceService.getCurrentRegistryUrl$()) + : await firstValueFrom(this.marketplaceService.currentRegistryUrl$) const originalUrl = this.localPkg?.registry || null if (!this.localPkg) { diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts index ccb3a666b..ab5e8693a 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/components/menu.component.ts @@ -53,8 +53,7 @@ import { DialogService, i18nPipe } from '@start9labs/shared' }) export class MarketplaceMenuComponent { private readonly dialog = inject(DialogService) - private readonly marketplaceService = inject(MarketplaceService) - readonly registry$ = this.marketplaceService.getCurrentRegistry$() + readonly registry$ = inject(MarketplaceService).currentRegistry$ changeRegistry() { this.dialog diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts index a370cef3f..18a7b11c5 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/marketplace.component.ts @@ -29,9 +29,7 @@ import { StorageService } from 'src/app/services/storage.service'
-

- {{ category$ | async | titlecase }} -

+

{{ category$ | async | titlecase }}

@if (registry$ | async; as registry) {
@@ -178,14 +176,14 @@ export default class MarketplaceComponent { queryParamsHandling: 'merge', }) } else { - this.marketplaceService.setRegistryUrl(registry) + this.marketplaceService.currentRegistryUrl$.next(registry) } }), ) .subscribe() - readonly url$ = this.marketplaceService.getCurrentRegistryUrl$() + readonly url$ = this.marketplaceService.currentRegistryUrl$ readonly category$ = this.categoryService.getCategory$() readonly query$ = this.categoryService.getQuery$() - readonly registry$ = this.marketplaceService.getCurrentRegistry$() + readonly registry$ = this.marketplaceService.currentRegistry$ } diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts index eb0ddabe1..97f9c06c1 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/preview.component.ts @@ -194,7 +194,7 @@ export class MarketplacePreviewComponent { readonly flavors$ = this.flavor$.pipe( switchMap(current => - this.marketplaceService.getCurrentRegistry$().pipe( + this.marketplaceService.currentRegistry$.pipe( map(({ packages }) => packages.filter( ({ id, flavor }) => id === this.pkgId && flavor !== current, diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts index 40e78a9b7..c40f65dfa 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/modals/registry.component.ts @@ -41,7 +41,7 @@ import { StorageService } from 'src/app/services/storage.service' > }

{{ 'Custom Registries' | i18n }}

- @@ -71,6 +71,10 @@ import { StorageService } from 'src/app/services/storage.service' flex-direction: row; align-items: center; } + + [tuiCell] { + width: stretch; + } `, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -102,8 +106,8 @@ export class MarketplaceRegistryModal { private readonly storage = inject(StorageService) readonly registries$ = combineLatest([ - this.marketplaceService.getRegistries$(), - this.marketplaceService.getCurrentRegistryUrl$(), + this.marketplaceService.registries$, + this.marketplaceService.currentRegistryUrl$, ]).pipe( map(([registries, currentUrl]) => registries.map(s => ({ @@ -185,7 +189,7 @@ export class MarketplaceRegistryModal { loader.closed = false loader.add(this.loader.open('Changing registry').subscribe()) try { - this.marketplaceService.setRegistryUrl(url) + this.marketplaceService.currentRegistryUrl$.next(url) this.router.navigate([], { queryParams: { registry: url }, queryParamsHandling: 'merge', @@ -231,7 +235,7 @@ export class MarketplaceRegistryModal { private async save(rawUrl: string, connect = false): Promise { const loader = this.loader.open('Loading').subscribe() - const url = new URL(rawUrl).toString() + const url = new URL(rawUrl).origin + '/' try { await this.validateAndSave(url, loader) diff --git a/web/projects/ui/src/app/routes/portal/routes/marketplace/services/alerts.service.ts b/web/projects/ui/src/app/routes/portal/routes/marketplace/services/alerts.service.ts index 3f4f8a14e..3db5d5e3d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/marketplace/services/alerts.service.ts +++ b/web/projects/ui/src/app/routes/portal/routes/marketplace/services/alerts.service.ts @@ -1,7 +1,7 @@ import { inject, Injectable } from '@angular/core' import { MarketplacePkgBase } from '@start9labs/marketplace' +import { DialogService, i18nKey, i18nPipe, sameUrl } from '@start9labs/shared' import { defaultIfEmpty, firstValueFrom } from 'rxjs' -import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared' import { MarketplaceService } from 'src/app/services/marketplace.service' @Injectable({ @@ -16,14 +16,12 @@ export class MarketplaceAlertsService { url: string, originalUrl: string | null, ): Promise { - const registries = await firstValueFrom( - this.marketplaceService.getRegistries$(), - ) + const registries = await firstValueFrom(this.marketplaceService.registries$) const message = originalUrl - ? `${this.i18n.transform('installed from')} ${registries.find(h => h.url === originalUrl) || originalUrl}` + ? `${this.i18n.transform('installed from')} ${registries.find(r => sameUrl(r.url, originalUrl))?.name || originalUrl}` : this.i18n.transform('sideloaded') - const currentName = registries.find(h => h.url === url) || url + const currentName = registries.find(h => sameUrl(h.url, url))?.name || url return new Promise(async resolve => { this.dialog diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/progress.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/progress.component.ts index 6a7ef7ad2..77dc6d095 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/progress.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/progress.component.ts @@ -1,31 +1,107 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + inject, + Input, +} from '@angular/core' +import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' +import { TuiLet } from '@taiga-ui/cdk' +import { TuiButton } from '@taiga-ui/core' import { TuiProgress } from '@taiga-ui/kit' import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { PackageDataEntry } from 'src/app/services/patch-db/data-model' +import { getManifest } from 'src/app/utils/get-package-data' @Component({ - selector: '[progress]', + selector: 'service-install-progress', template: ` - - @if (progress | installingProgress; as percent) { - : {{ percent }}% - + {{ 'Install Progress' | i18n }} + + + + @for ( + phase of pkg.stateInfo.installingInfo?.progress?.phases; + track $index + ) { +
+ {{ $any(phase.name) | i18n }}: + @if (phase.progress === null) { + {{ 'waiting' | i18n }} + } @else if (phase.progress === true) { + {{ 'complete' | i18n }}! + } @else if (phase.progress === false || phase.progress.total === null) { + {{ 'in progress' | i18n }}... + } @else { + {{ percent }}% + } + +
} `, - styles: [':host { line-height: 2rem }'], + styles: ` + :host { + grid-column: span 6; + color: var(--tui-text-secondary); + } + + div { + padding: 0.25rem 0; + } + + span { + float: right; + text-transform: capitalize; + } + + progress { + margin: 0.5rem 0; + } + `, + host: { class: 'g-card' }, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [TuiProgress, InstallingProgressPipe], + imports: [TuiProgress, TuiLet, InstallingProgressPipe, i18nPipe, TuiButton], }) -export class ServiceProgressComponent { - @Input({ required: true }) progress!: T.Progress +export class ServiceInstallProgressComponent { + @Input({ required: true }) + pkg!: PackageDataEntry + + private readonly api = inject(ApiService) + private readonly errorService = inject(ErrorService) + private readonly loader = inject(LoadingService) + + isPending(progress: T.Progress): boolean { + return ( + !progress || (progress && progress !== true && progress.total === null) + ) + } + + async cancel() { + const loader = this.loader.open().subscribe() + + try { + await this.api.cancelInstallPackage({ id: getManifest(this.pkg).id }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts index a95406655..859fc46a9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/status.component.ts @@ -18,9 +18,7 @@ import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service' @if (loading) { } @else { - @if (healthy) { - - } @else { + @if (!healthy) { } } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts index d492bb0c9..386833684 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/service.component.ts @@ -25,46 +25,45 @@ import { ServiceDependenciesComponent } from '../components/dependencies.compone import { ServiceErrorComponent } from '../components/error.component' import { ServiceHealthChecksComponent } from '../components/health-checks.component' import { ServiceInterfacesComponent } from '../components/interfaces.component' -import { ServiceProgressComponent } from '../components/progress.component' +import { ServiceInstallProgressComponent } from '../components/progress.component' import { ServiceStatusComponent } from '../components/status.component' @Component({ template: ` - - @if ($any(pkg()?.status)?.started; as started) { -

- } - @if (installed() && connected() && pkg(); as pkg) { - - } -
+ @if (pkg(); as pkg) { + @if (installing()) { + + } @else if (installed()) { + + @if ($any(pkg.status)?.started; as started) { +

+ } - @if (installed() && pkg(); as pkg) { - @if (pkg.status.main === 'error') { - - } - - @if (errors() | async; as errors) { - - } - - - } + @if (connected()) { + + } +
- @if (installing() && pkg(); as pkg) { - @for ( - item of pkg.stateInfo.installingInfo?.progress?.phases; - track $index - ) { -

{{ item.name }}

+ @if (pkg.status.main === 'error') { + + } + + + + @if (errors() | async; as errors) { + + } + + + } } `, @@ -94,7 +93,7 @@ import { ServiceStatusComponent } from '../components/status.component' standalone: true, imports: [ CommonModule, - ServiceProgressComponent, + ServiceInstallProgressComponent, ServiceStatusComponent, ServiceControlsComponent, ServiceInterfacesComponent, diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/progress.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/progress.component.ts index 98828b810..16c21b385 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/progress.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/progress.component.ts @@ -27,13 +27,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model' @if (progress.complete) { - {{ 'Complete' | i18n }} + {{ 'complete' | i18n }} } @else { @if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') { - {{ 'Backing up' | i18n }} + {{ 'backing up' | i18n }} } @else { - {{ 'Waiting' | i18n }}... + {{ 'waiting' | i18n }} } } diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts index 33ea5f289..9640318b0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts @@ -224,14 +224,12 @@ export default class UpdatesComponent { readonly data = toSignal( combineLatest({ - hosts: this.marketplaceService - .getRegistries$(true) - .pipe( - tap( - ([registry]) => - !this.isMobile && registry && this.current.set(registry), - ), + hosts: this.marketplaceService.filteredRegistries$.pipe( + tap( + ([registry]) => + !this.isMobile && registry && this.current.set(registry), ), + ), marketplace: this.marketplaceService.marketplace$, localPkgs: inject>(PatchDB) .watch$('packageData') @@ -248,7 +246,7 @@ export default class UpdatesComponent { ), ), ), - errors: this.marketplaceService.getRequestErrors$(), + errors: this.marketplaceService.requestErrors$, }), ) diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 2cda22b7c..9db729445 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -319,6 +319,9 @@ export namespace RR { export type InstallPackageReq = T.InstallParams export type InstallPackageRes = null + export type CancelInstallPackageReq = { id: string } + export type CancelInstallPackageRes = null + export type GetActionInputReq = { packageId: string; actionId: string } // package.action.get-input export type GetActionInputRes = { spec: IST.InputSpec diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index fe309ac65..0730b9d3d 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -325,6 +325,10 @@ export abstract class ApiService { params: RR.InstallPackageReq, ): Promise + abstract cancelInstallPackage( + params: RR.CancelInstallPackageReq, + ): Promise + abstract getActionInput( params: RR.GetActionInputReq, ): Promise diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 88e8e428c..bcf406c7f 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -560,6 +560,12 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'package.install', params }) } + async cancelInstallPackage( + params: RR.CancelInstallPackageReq, + ): Promise { + return this.rpcRequest({ method: 'package.cancel-install', params }) + } + async getActionInput( params: RR.GetActionInputReq, ): Promise { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 9014c48d5..a5ecda239 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -50,10 +50,7 @@ const PROGRESS: T.FullProgress = { }, { name: 'Installing', - progress: { - done: 0, - total: 40, - }, + progress: null, }, ], } @@ -1077,6 +1074,22 @@ export class MockApiService extends ApiService { return null } + async cancelInstallPackage( + params: RR.CancelInstallPackageReq, + ): Promise { + await pauseFor(500) + + const patch: RemoveOperation[] = [ + { + op: PatchOp.REMOVE, + path: `/packageData/${params.id}`, + }, + ] + this.mockRevision(patch) + + return null + } + async getActionInput( params: RR.GetActionInputReq, ): Promise { diff --git a/web/projects/ui/src/app/services/marketplace.service.ts b/web/projects/ui/src/app/services/marketplace.service.ts index 6fcc25f5b..57b41a02d 100644 --- a/web/projects/ui/src/app/services/marketplace.service.ts +++ b/web/projects/ui/src/app/services/marketplace.service.ts @@ -1,13 +1,12 @@ -import { Injectable } from '@angular/core' +import { inject, Injectable } from '@angular/core' import { GetPackageRes, Marketplace, MarketplacePkg, - StoreData, StoreDataWithUrl, StoreIdentity, } from '@start9labs/marketplace' -import { Exver, defaultRegistries, sameUrl } from '@start9labs/shared' +import { defaultRegistries, Exver, sameUrl } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import { PatchDB } from 'patch-db-client' import { @@ -40,29 +39,11 @@ const { start9, community } = defaultRegistries providedIn: 'root', }) export class MarketplaceService { - private readonly currentRegistryUrlSubject$ = new ReplaySubject(1) - private readonly currentRegistryUrl$ = this.currentRegistryUrlSubject$.pipe( - distinctUntilChanged(), - ) + private readonly api = inject(ApiService) + private readonly patch: PatchDB = inject(PatchDB) + private readonly exver = inject(Exver) - private readonly currentRegistry$: Observable = - this.currentRegistryUrl$.pipe( - switchMap(url => this.fetchRegistry$(url)), - filter(Boolean), - map(registry => { - registry.info.categories = { - all: { - name: 'All', - }, - ...registry.info.categories, - } - - return registry - }), - shareReplay(1), - ) - - private readonly registries$: Observable = this.patch + readonly registries$: Observable = this.patch .watch$('ui', 'registries') .pipe( map(registries => [ @@ -74,21 +55,23 @@ export class MarketplaceService { ]), ) - private readonly filteredRegistries$: Observable = - combineLatest([ - this.clientStorageService.showDevTools$, - this.registries$, - ]).pipe( - map(([devMode, registries]) => - devMode - ? registries - : registries.filter( - ({ url }) => !url.includes('alpha') && !url.includes('beta'), - ), - ), - ) + // option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL + readonly filteredRegistries$: Observable = combineLatest([ + inject(ClientStorageService).showDevTools$, + this.registries$, + ]).pipe( + map(([devMode, registries]) => + devMode + ? registries + : registries.filter( + ({ url }) => !url.includes('alpha') && !url.includes('beta'), + ), + ), + ) - private readonly requestErrors$ = new BehaviorSubject([]) + readonly currentRegistryUrl$ = new ReplaySubject(1) + + readonly requestErrors$ = new BehaviorSubject([]) readonly marketplace$: Observable = this.registries$.pipe( startWith([]), @@ -102,11 +85,11 @@ export class MarketplaceService { if (data?.info.name) this.updateRegistryName(url, name, data.info.name) }), - map(data => [url, data]), - startWith<[string, StoreData | null]>([url, null]), + map(data => [url, data] satisfies [string, StoreDataWithUrl | null]), + startWith<[string, StoreDataWithUrl | null]>([url, null]), ), ), - scan<[string, StoreData | null], Record>( + scan<[string, StoreDataWithUrl | null], Marketplace>( (requests, [url, store]) => { requests[url] = store @@ -114,32 +97,21 @@ export class MarketplaceService { }, {}, ), - shareReplay({ bufferSize: 1, refCount: true }), + shareReplay(1), ) - constructor( - private readonly api: ApiService, - private readonly patch: PatchDB, - private readonly clientStorageService: ClientStorageService, - private readonly exver: Exver, - ) {} - - getRegistries$(filtered = false): Observable { - // option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL - return filtered ? this.filteredRegistries$ : this.registries$ - } - - getCurrentRegistryUrl$() { - return this.currentRegistryUrl$ - } - - setRegistryUrl(url: string) { - this.currentRegistryUrlSubject$.next(url) - } - - getCurrentRegistry$(): Observable { - return this.currentRegistry$ - } + readonly currentRegistry$: Observable = combineLatest([ + this.marketplace$, + this.currentRegistryUrl$, + this.currentRegistryUrl$.pipe( + distinctUntilChanged(), + switchMap(url => this.fetchRegistry$(url).pipe(startWith(null))), + ), + ]).pipe( + map(([all, url, current]) => current || all[url]), + filter(Boolean), + shareReplay(1), + ) getPackage$( id: string, @@ -161,14 +133,12 @@ export class MarketplaceService { ) } - fetchInfo$(url: string): Observable { - return from(this.api.getRegistryInfo({ registry: url })).pipe( + fetchInfo$(registry: string): Observable { + return from(this.api.getRegistryInfo({ registry })).pipe( map(info => ({ ...info, categories: { - all: { - name: 'All', - }, + all: { name: 'All' }, ...info.categories, }, })), @@ -263,10 +233,6 @@ export class MarketplaceService { } } - getRequestErrors$(): Observable { - return this.requestErrors$ - } - async installPackage( id: string, version: string,