sideload wip, websockets, styling, multiple todos (#2865)

* sideload wip, websockets, styling, multiple todos

* sideload

* misc backend updates

* chore: comments

* prep for license and instructions display

* comment for Matt

* s9pk updates and 040 sdk

* fix dependency error for actions

* 0.4.0-beta.1

* beta.2

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
This commit is contained in:
Matt Hill
2025-04-10 13:51:05 -06:00
committed by GitHub
parent ab4336cfd7
commit fc2be42418
88 changed files with 773 additions and 965 deletions

View File

@@ -197,9 +197,6 @@ pub struct LoginParams {
user_agent: Option<String>,
#[serde(default)]
ephemeral: bool,
#[serde(default)]
#[ts(type = "any")]
metadata: Value,
}
#[instrument(skip_all)]
@@ -209,7 +206,6 @@ pub async fn login_impl(
password,
user_agent,
ephemeral,
metadata,
}: LoginParams,
) -> Result<LoginRes, Error> {
let password = password.unwrap_or_default().decrypt(&ctx)?;
@@ -224,7 +220,6 @@ pub async fn login_impl(
logged_in: Utc::now(),
last_active: Utc::now(),
user_agent,
metadata,
},
)
});
@@ -240,7 +235,6 @@ pub async fn login_impl(
logged_in: Utc::now(),
last_active: Utc::now(),
user_agent,
metadata,
},
)?;
@@ -277,10 +271,7 @@ pub struct Session {
pub logged_in: DateTime<Utc>,
#[ts(type = "string")]
pub last_active: DateTime<Utc>,
#[ts(skip)]
pub user_agent: Option<String>,
#[ts(type = "any")]
pub metadata: Value,
}
#[derive(Deserialize, Serialize, TS)]
@@ -327,7 +318,6 @@ fn display_sessions(params: WithIoFormat<ListParams>, arg: SessionList) {
"LOGGED IN",
"LAST ACTIVE",
"USER AGENT",
"METADATA",
]);
for (id, session) in arg.sessions.0 {
let mut row = row![
@@ -335,7 +325,6 @@ fn display_sessions(params: WithIoFormat<ListParams>, arg: SessionList) {
&format!("{}", session.logged_in),
&format!("{}", session.last_active),
session.user_agent.as_deref().unwrap_or("N/A"),
&format!("{}", session.metadata),
];
if Some(id) == arg.current {
row.iter_mut()

View File

@@ -186,6 +186,7 @@ pub async fn remove_before(
Ok(())
})
.await
.result
}
pub async fn mark_seen(
@@ -213,6 +214,7 @@ pub async fn mark_seen(
Ok(())
})
.await
.result
}
pub async fn mark_seen_before(
@@ -240,6 +242,7 @@ pub async fn mark_seen_before(
Ok(())
})
.await
.result
}
pub async fn mark_unseen(

View File

@@ -72,7 +72,7 @@ pub struct PackageVersionInfo {
pub icon: DataUrl<'static>,
pub description: Description,
pub release_notes: String,
pub git_hash: GitHash,
pub git_hash: Option<GitHash>,
#[ts(type = "string")]
pub license: InternedString,
#[ts(type = "string")]
@@ -115,7 +115,7 @@ impl PackageVersionInfo {
icon: s9pk.icon_data_url().await?,
description: manifest.description.clone(),
release_notes: manifest.release_notes.clone(),
git_hash: manifest.git_hash.clone().or_not_found("git hash")?,
git_hash: manifest.git_hash.clone(),
license: manifest.license.clone(),
wrapper_repo: manifest.wrapper_repo.clone(),
upstream_repo: manifest.upstream_repo.clone(),
@@ -153,7 +153,7 @@ impl PackageVersionInfo {
br -> "DESCRIPTION",
&textwrap::wrap(&self.description.long, 80).join("\n")
]);
table.add_row(row![br -> "GIT HASH", AsRef::<str>::as_ref(&self.git_hash)]);
table.add_row(row![br -> "GIT HASH", self.git_hash.as_deref().unwrap_or("N/A")]);
table.add_row(row![br -> "LICENSE", &self.license]);
table.add_row(row![br -> "PACKAGE REPO", &self.wrapper_repo.to_string()]);
table.add_row(row![br -> "SERVICE REPO", &self.upstream_repo.to_string()]);

View File

@@ -1,3 +1,4 @@
use std::ops::Deref;
use std::path::Path;
use tokio::process::Command;
@@ -66,6 +67,13 @@ impl AsRef<str> for GitHash {
}
}
impl Deref for GitHash {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_ref()
}
}
// #[tokio::test]
// async fn test_githash_for_current() {
// let answer: GitHash = GitHash::from_path(std::env::current_dir().unwrap())

View File

@@ -26,7 +26,6 @@ use crate::s9pk::merkle_archive::source::{
into_dyn_read, ArchiveSource, DynFileSource, DynRead, FileSource, TmpSource,
};
use crate::s9pk::merkle_archive::{Entry, MerkleArchive};
use crate::s9pk::v2::recipe::DirRecipe;
use crate::s9pk::v2::SIG_CONTEXT;
use crate::s9pk::S9pk;
use crate::util::io::{create_file, open_file, TmpDir};
@@ -736,25 +735,34 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> {
let dep_path = Path::new("dependencies").join(id);
to_insert.push((
dep_path.join("metadata.json"),
Entry::file(PackSource::Buffered(
IoFormat::Json
.to_vec(&DependencyMetadata {
title: s9pk.as_manifest().title.clone(),
})?
.into(),
Entry::file(TmpSource::new(
tmp_dir.clone(),
PackSource::Buffered(
IoFormat::Json
.to_vec(&DependencyMetadata {
title: s9pk.as_manifest().title.clone(),
})?
.into(),
),
)),
));
let icon = s9pk.icon().await?;
to_insert.push((
dep_path.join(&*icon.0),
Entry::file(PackSource::Buffered(
icon.1.expect_file()?.to_vec(icon.1.hash()).await?.into(),
Entry::file(TmpSource::new(
tmp_dir.clone(),
PackSource::Buffered(icon.1.expect_file()?.to_vec(icon.1.hash()).await?.into()),
)),
));
} else {
warn!("no s9pk specified for {id}, leaving metadata empty");
}
}
for (path, source) in to_insert {
s9pk.as_archive_mut()
.contents_mut()
.insert_path(path, source)?;
}
s9pk.validate_and_filter(None)?;

View File

@@ -5,7 +5,10 @@ use imbl_value::json;
use models::{ActionId, PackageId, ProcedureName, ReplayId};
use crate::action::{ActionInput, ActionResult};
use crate::db::model::package::{ActionRequestCondition, ActionRequestEntry, ActionRequestInput};
use crate::db::model::package::{
ActionRequestCondition, ActionRequestEntry, ActionRequestInput, ActionVisibility,
AllowedStatuses,
};
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::service::{Service, ServiceActor};
@@ -123,12 +126,44 @@ impl Handler<RunAction> for ServiceActor {
&mut self,
id: Guid,
RunAction {
id: action_id,
id: ref action_id,
input,
}: RunAction,
_: &BackgroundJobQueue,
) -> Self::Response {
let container = &self.0.persistent_container;
let package_id = &self.0.id;
let action = self
.0
.ctx
.db
.peek()
.await
.into_public()
.into_package_data()
.into_idx(package_id)
.or_not_found(package_id)?
.into_actions()
.into_idx(&action_id)
.or_not_found(lazy_format!("{package_id} action {action_id}"))?
.de()?;
if !matches!(&action.visibility, ActionVisibility::Enabled) {
return Err(Error::new(
eyre!("action {action_id} is disabled"),
ErrorKind::Action,
));
}
let running = container.state.borrow().running_status.as_ref().is_some();
if match action.allowed_statuses {
AllowedStatuses::OnlyRunning => !running,
AllowedStatuses::OnlyStopped => running,
_ => false,
} {
return Err(Error::new(
eyre!("service is not in allowed status for {action_id}"),
ErrorKind::Action,
));
}
let result = container
.execute::<Option<ActionResult>>(
id,
@@ -140,7 +175,6 @@ impl Handler<RunAction> for ServiceActor {
)
.await
.with_kind(ErrorKind::Action)?;
let package_id = &self.0.id;
self.0
.ctx
.db
@@ -150,7 +184,7 @@ impl Handler<RunAction> for ServiceActor {
Ok(update_requested_actions(
requested_actions,
package_id,
&action_id,
action_id,
&input,
true,
))

View File

@@ -66,7 +66,9 @@ export async function checkDependencies<
return dep.requirement.kind !== "running" || dep.result.isRunning
}
const actionsSatisfied = (packageId: DependencyId) =>
Object.keys(find(packageId).result.requestedActions).length === 0
Object.entries(find(packageId).result.requestedActions).filter(
([_, req]) => req.active && req.request.severity === "critical",
).length === 0
const healthCheckSatisfied = (
packageId: DependencyId,
healthCheckId?: HealthCheckId,
@@ -129,7 +131,9 @@ export async function checkDependencies<
}
const throwIfActionsNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
const reqs = Object.keys(dep.result.requestedActions)
const reqs = Object.entries(dep.result.requestedActions)
.filter(([_, req]) => req.active && req.request.severity === "critical")
.map(([id, _]) => id)
if (reqs.length) {
throw new Error(
`The following action requests have not been fulfilled: ${reqs.join(", ")}`,

View File

@@ -1,8 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PasswordType } from "./PasswordType"
export type LoginParams = {
password: PasswordType | null
ephemeral: boolean
metadata: any
}
export type LoginParams = { password: PasswordType | null; ephemeral: boolean }

View File

@@ -14,7 +14,7 @@ export type PackageVersionInfo = {
icon: DataUrl
description: Description
releaseNotes: string
gitHash: GitHash
gitHash: GitHash | null
license: string
wrapperRepo: string
upstreamRepo: string

View File

@@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Session = { loggedIn: string; lastActive: string; metadata: any }
export type Session = {
loggedIn: string
lastActive: string
userAgent: string | null
}

View File

@@ -1,5 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Sessions = {
[key: string]: { loggedIn: string; lastActive: string; metadata: any }
[key: string]: {
loggedIn: string
lastActive: string
userAgent: string | null
}
}

View File

@@ -1,6 +1,14 @@
import { DataUrl, Manifest, MerkleArchiveCommitment } from "../osBindings"
import {
DataUrl,
DependencyMetadata,
Manifest,
MerkleArchiveCommitment,
PackageId,
} from "../osBindings"
import { ArrayBufferReader, MerkleArchive } from "./merkleArchive"
import mime from "mime-types"
import { DirectoryContents } from "./merkleArchive/directoryContents"
import { FileContents } from "./merkleArchive/fileContents"
const magicAndVersion = new Uint8Array([59, 59, 2])
@@ -65,4 +73,63 @@ export class S9pk {
).toString("base64")
)
}
async dependencyMetadataFor(id: PackageId) {
const entry = this.archive.contents.getPath([
"dependencies",
id,
"metadata.json",
])
if (!entry) return null
return JSON.parse(
new TextDecoder().decode(await entry.verifiedFileContents()),
) as { title: string }
}
async dependencyIconFor(id: PackageId) {
const dir = this.archive.contents.getPath(["dependencies", id])
if (!dir || !(dir.contents instanceof DirectoryContents)) return null
const iconName = Object.keys(dir.contents.contents).find(
(name) =>
name.startsWith("icon.") &&
(mime.contentType(name) || null)?.startsWith("image/"),
)
if (!iconName) return null
return (
`data:${mime.contentType(iconName)};base64,` +
Buffer.from(
await dir.contents.getPath([iconName])!.verifiedFileContents(),
).toString("base64")
)
}
async dependencyMetadata(): Promise<Record<PackageId, DependencyMetadata>> {
return Object.fromEntries(
await Promise.all(
Object.entries(this.manifest.dependencies).map(async ([id, info]) => [
id,
{
...(await this.dependencyMetadataFor(id)),
icon: await this.dependencyIconFor(id),
description: info.description,
optional: info.optional,
},
]),
),
)
}
async instructions(): Promise<string> {
const file = this.archive.contents.getPath(["instructions.md"])
if (!file || !(file.contents instanceof FileContents))
throw new Error("instructions.md not found in archive")
return new TextDecoder().decode(await file.verifiedFileContents())
}
async license(): Promise<string> {
const file = this.archive.contents.getPath(["LICENSE.md"])
if (!file || !(file.contents instanceof FileContents))
throw new Error("instructions.md not found in archive")
return new TextDecoder().decode(await file.verifiedFileContents())
}
}

View File

@@ -30,7 +30,12 @@ import { HealthCheck } from "./health/HealthCheck"
import { checkPortListening } from "./health/checkFns/checkPortListening"
import { checkWebUrl, runHealthScript } from "./health/checkFns"
import { List } from "../../base/lib/actions/input/builder/list"
import { Install, InstallFn } from "./inits/setupInstall"
import {
Install,
InstallFn,
PostInstall,
PreInstall,
} from "./inits/setupInstall"
import { SetupBackupsParams, setupBackups } from "./backup/setupBackups"
import { UninstallFn, setupUninstall } from "./inits/setupUninstall"
import { setupMain } from "./mainFn"
@@ -571,12 +576,24 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
setupDependencies: setupDependencies<Manifest>,
setupInit: setupInit<Manifest, Store>,
/**
* @description Use this function to execute arbitrary logic *once*, on initial install only.
* @description Use this function to execute arbitrary logic *once*, on initial install *before* interfaces, actions, and dependencies are updated.
* @example
* In the this example, we initialize a config file
*
* ```
const preInstall = sdk.setupPreInstall(async ({ effects }) => {
await configFile.write(effects, { name: 'World' })
})
* ```
*/
setupPreInstall: (fn: InstallFn<Manifest, Store>) => PreInstall.of(fn),
/**
* @description Use this function to execute arbitrary logic *once*, on initial install *after* interfaces, actions, and dependencies are updated.
* @example
* In the this example, we bootstrap our Store with a random, 16-char admin password.
*
* ```
const install = sdk.setupInstall(async ({ effects }) => {
const postInstall = sdk.setupPostInstall(async ({ effects }) => {
await sdk.store.setOwn(
effects,
sdk.StorePath.adminPassword,
@@ -588,10 +605,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
})
* ```
*/
setupInstall: (
fn: InstallFn<Manifest, Store>,
preFn?: InstallFn<Manifest, Store>,
) => Install.of(fn, preFn),
setupPostInstall: (fn: InstallFn<Manifest, Store>) => PostInstall.of(fn),
/**
* @description Use this function to determine how this service will be hosted and served. The function executes on service install, service update, and inputSpec save.
*

View File

@@ -5,12 +5,13 @@ import { ExposedStorePaths } from "../../../base/lib/types"
import * as T from "../../../base/lib/types"
import { StorePath } from "../util"
import { VersionGraph } from "../version/VersionGraph"
import { Install } from "./setupInstall"
import { PostInstall, PreInstall } from "./setupInstall"
import { Uninstall } from "./setupUninstall"
export function setupInit<Manifest extends T.SDKManifest, Store>(
versions: VersionGraph<string>,
install: Install<Manifest, Store>,
preInstall: PreInstall<Manifest, Store>,
postInstall: PostInstall<Manifest, Store>,
uninstall: Uninstall<Manifest, Store>,
setServiceInterfaces: UpdateServiceInterfaces<any>,
setDependencies: (options: {
@@ -34,7 +35,7 @@ export function setupInit<Manifest extends T.SDKManifest, Store>(
to: versions.currentVersion(),
})
} else {
await install.install(opts)
await postInstall.postInstall(opts)
await opts.effects.setDataVersion({
version: versions.current.options.version,
})
@@ -61,7 +62,7 @@ export function setupInit<Manifest extends T.SDKManifest, Store>(
path: "" as StorePath,
value: initStore,
})
await install.preInstall(opts)
await preInstall.preInstall(opts)
}
await setServiceInterfaces({
...opts,

View File

@@ -4,34 +4,57 @@ export type InstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
effects: T.Effects
}) => Promise<null | void | undefined>
export class Install<Manifest extends T.SDKManifest, Store> {
private constructor(
readonly fn: InstallFn<Manifest, Store>,
readonly preFn?: InstallFn<Manifest, Store>,
) {}
protected constructor(readonly fn: InstallFn<Manifest, Store>) {}
}
export class PreInstall<Manifest extends T.SDKManifest, Store> extends Install<
Manifest,
Store
> {
private constructor(fn: InstallFn<Manifest, Store>) {
super(fn)
}
static of<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
preFn?: InstallFn<Manifest, Store>,
) {
return new Install(fn, preFn)
return new PreInstall(fn)
}
async install({ effects }: Parameters<T.ExpectedExports.packageInit>[0]) {
async preInstall({ effects }: Parameters<T.ExpectedExports.packageInit>[0]) {
await this.fn({
effects,
})
}
}
async preInstall({ effects }: Parameters<T.ExpectedExports.packageInit>[0]) {
this.preFn &&
(await this.preFn({
effects,
}))
export function setupPreInstall<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
return PreInstall.of(fn)
}
export class PostInstall<Manifest extends T.SDKManifest, Store> extends Install<
Manifest,
Store
> {
private constructor(fn: InstallFn<Manifest, Store>) {
super(fn)
}
static of<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
) {
return new PostInstall(fn)
}
async postInstall({ effects }: Parameters<T.ExpectedExports.packageInit>[0]) {
await this.fn({
effects,
})
}
}
export function setupInstall<Manifest extends T.SDKManifest, Store>(
export function setupPostInstall<Manifest extends T.SDKManifest, Store>(
fn: InstallFn<Manifest, Store>,
preFn?: InstallFn<Manifest, Store>,
) {
return Install.of(fn, preFn)
return PostInstall.of(fn)
}

View File

@@ -1,12 +1,12 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-beta.20",
"version": "0.4.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@start9labs/start-sdk",
"version": "0.3.6-beta.20",
"version": "0.4.0-beta.2",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-beta.20",
"version": "0.4.0-beta.2",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts",

234
web/package-lock.json generated
View File

@@ -25,18 +25,18 @@
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.2.2",
"@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.30.0",
"@taiga-ui/addon-commerce": "4.30.0",
"@taiga-ui/addon-mobile": "4.30.0",
"@taiga-ui/addon-table": "4.30.0",
"@taiga-ui/cdk": "4.30.0",
"@taiga-ui/core": "4.30.0",
"@taiga-ui/event-plugins": "4.5.0",
"@taiga-ui/experimental": "4.30.0",
"@taiga-ui/icons": "4.30.0",
"@taiga-ui/kit": "4.30.0",
"@taiga-ui/layout": "4.30.0",
"@taiga-ui/legacy": "4.30.0",
"@taiga-ui/addon-charts": "4.32.0",
"@taiga-ui/addon-commerce": "4.32.0",
"@taiga-ui/addon-mobile": "4.32.0",
"@taiga-ui/addon-table": "4.32.0",
"@taiga-ui/cdk": "4.32.0",
"@taiga-ui/core": "4.32.0",
"@taiga-ui/event-plugins": "4.5.1",
"@taiga-ui/experimental": "4.32.0",
"@taiga-ui/icons": "4.32.0",
"@taiga-ui/kit": "4.32.0",
"@taiga-ui/layout": "4.32.0",
"@taiga-ui/legacy": "4.32.0",
"@taiga-ui/polymorpheus": "4.9.0",
"@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2",
@@ -3480,9 +3480,9 @@
}
},
"node_modules/@ng-web-apis/common": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-4.11.1.tgz",
"integrity": "sha512-fXbcMrd/+L+9j9knbgXbDwYe30H4Wt0hQzvqyhpXTVrc0jYwlk3MJTYrnazKz5HvP9318caEv5n4qt3HMf5uPQ==",
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-4.12.0.tgz",
"integrity": "sha512-OG4ChsEWQ0IbGJ+WrJAiOY5X4jF8f5YUCss961taPeiyhvwtUo4zAuX3UvtV/iJSt8XZ41jaOYFTyMIBGubv4Q==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -3495,9 +3495,9 @@
}
},
"node_modules/@ng-web-apis/intersection-observer": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-4.11.1.tgz",
"integrity": "sha512-KjODVVx20yG/U5bnPvp5voihL5DSVFuYwZVY9DNRvaFIcQPMy1tL1t9/oJOdxj7zUSFDL8+Z0RoJbsvArezuSg==",
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-4.12.0.tgz",
"integrity": "sha512-7MW0y6BrjLKCTUGb5YfsEPYpn17wPmGj8J+2A980ntGNveo9+DILz3KpFHkpS6G6bJGtnD36exU2YvfTUKiyXA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -3505,13 +3505,13 @@
},
"peerDependencies": {
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": ">=4.11.1"
"@ng-web-apis/common": ">=4.12.0"
}
},
"node_modules/@ng-web-apis/mutation-observer": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-4.11.1.tgz",
"integrity": "sha512-YFnkGFE0gd03q4HBJL+WPl3YZRZNq7dVV8yD5uqr0ZCDgmOAMBilrp42FuHBPaYkF73Rs2EpKsKHWz1jASqBbQ==",
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-4.12.0.tgz",
"integrity": "sha512-Yyz0jpQoGgWzQEhjRH8xC9Umxr2W0hYktuYDNxrmnr6GnBejcfkE+wCon7FhCt6h7BrqR3+Z4cg7TvnyUVHU6Q==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -3519,13 +3519,13 @@
},
"peerDependencies": {
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": ">=4.11.1"
"@ng-web-apis/common": ">=4.12.0"
}
},
"node_modules/@ng-web-apis/platform": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@ng-web-apis/platform/-/platform-4.11.1.tgz",
"integrity": "sha512-BrhkUIEEAD7wcwR65LSXHYOD6L3IvAb4aV94S8tzxUNeGUPwikX5glQJBT1UwkHWXQjANPKTCNyK1LO+cMPgkw==",
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/platform/-/platform-4.12.0.tgz",
"integrity": "sha512-OuwV9OERPvQD+QxS2q84pWg60GlL9O66zl0VBLDR8ARMslBfCg1m70LlbLfQI4mlt4QZzTliUVps1JaGyAEKYA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -3533,9 +3533,9 @@
}
},
"node_modules/@ng-web-apis/resize-observer": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-4.11.1.tgz",
"integrity": "sha512-q8eJ6sovnMhfqIULN1yyhqT35Y2a60vB42p9CUBWPeeVammU+QHE/imPCMCJgnti9cdPZfnyI/+TeYNIUg7mzg==",
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-4.12.0.tgz",
"integrity": "sha512-ekLcZnqap9OBcoTtOD0/tcOy/STFrxSu3WR0yU9yEM0n7S1mOsKOnXIY4PMAxxWV4LMs91P00wzFHfNNnuOS/g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -3543,13 +3543,13 @@
},
"peerDependencies": {
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": ">=4.11.1"
"@ng-web-apis/common": ">=4.12.0"
}
},
"node_modules/@ng-web-apis/screen-orientation": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@ng-web-apis/screen-orientation/-/screen-orientation-4.11.1.tgz",
"integrity": "sha512-HS/kWTgVjXVDqMLcJbl5uty+1sV10m9PeDag74tzktIDAB06diFQJQGfcQaA0o0IBisT3fOysf9gHV5sXxSOFw==",
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/screen-orientation/-/screen-orientation-4.12.0.tgz",
"integrity": "sha512-oqZqc9TTpEBNNinVSFjEl/plhoRxh07zMtdG6VMnuY6Lng2l1jfC7vKru2rjEskR+0sYgN8Y8Ttov03i8I9GHg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -3557,7 +3557,7 @@
},
"peerDependencies": {
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": ">=4.11.1",
"@ng-web-apis/common": ">=4.12.0",
"rxjs": ">=7.0.0"
}
},
@@ -4418,9 +4418,9 @@
"link": true
},
"node_modules/@taiga-ui/addon-charts": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.30.0.tgz",
"integrity": "sha512-QrM2Oh4hUcg/I0K3KWFkc/dbTCYZn2n5GU2FSpZaK6I7pwjfRoMjBU7vswPLVVdmgeWTJxxoQlbfYnbUbkMAJw==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.32.0.tgz",
"integrity": "sha512-VhGkBxwfra5eijSvZdXhMKOWEnFMESo5TX3OfsahIXWJXivwguvIc63rIhHYq2uC+t5sj1kINveO4yLqOeAm/Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4428,16 +4428,16 @@
"peerDependencies": {
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/core": "^4.30.0",
"@ng-web-apis/common": "^4.12.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/core": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0"
}
},
"node_modules/@taiga-ui/addon-commerce": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.30.0.tgz",
"integrity": "sha512-6diktxvxMpWjbEHXThS0pTrURdUiF/47jf2jdBFkMwX3BbbekisM1qkwxY24V7q8fN0IIxfO8CVEjTeLRrCw5g==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.32.0.tgz",
"integrity": "sha512-AC3VU/RVTNapS8ltSAemZPeDb2CopJEj298rI3Vl4qER1oVl0zunmWVy5ncwK1F1zWKU2/QNDjjo8yKYWeU/Nw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4449,19 +4449,19 @@
"@maskito/angular": "^3.5.0",
"@maskito/core": "^3.5.0",
"@maskito/kit": "^3.5.0",
"@ng-web-apis/common": "^4.11.1",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/core": "^4.30.0",
"@taiga-ui/i18n": "^4.30.0",
"@taiga-ui/kit": "^4.30.0",
"@ng-web-apis/common": "^4.12.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/core": "^4.32.0",
"@taiga-ui/i18n": "^4.32.0",
"@taiga-ui/kit": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/addon-mobile": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.30.0.tgz",
"integrity": "sha512-8cYyU0UDLUd74v+Zjs4m9S4AsSWchUojAexDLvaAHzfi0x+tdtA+ZN0h49v8AmOWHK0v69z4FMjyyc52p/jiDw==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.32.0.tgz",
"integrity": "sha512-pUoHWyILPj6KIAhna1JDzz48c2nCjqYb1tb7AL3LQ3qfNwAbg9fvjBIfrgWMhW0LaDeh5+FfrS7oiO/ERcHTLg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4470,19 +4470,19 @@
"@angular/cdk": ">=16.0.0",
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/core": "^4.30.0",
"@taiga-ui/kit": "^4.30.0",
"@taiga-ui/layout": "^4.30.0",
"@ng-web-apis/common": "^4.12.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/core": "^4.32.0",
"@taiga-ui/kit": "^4.32.0",
"@taiga-ui/layout": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/addon-table": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.30.0.tgz",
"integrity": "sha512-OdCEwlrMs42Z2pINK1wvNk7OZmAlkj+mbgHTyMGdrUdA49dFZfYXNpVUCwVOqHAm2PDOeVN4ybZ8FSbzYefJyw==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.32.0.tgz",
"integrity": "sha512-8oXeqLO1wGH8RYHTYWhjCvrKWptPN1we04NRahmFY4AxSJ3u7MqaR4420RRNO4zZG9kGyktLXPjqGocMoymL8Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4490,19 +4490,19 @@
"peerDependencies": {
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@ng-web-apis/intersection-observer": "^4.11.1",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/core": "^4.30.0",
"@taiga-ui/i18n": "^4.30.0",
"@taiga-ui/kit": "^4.30.0",
"@ng-web-apis/intersection-observer": "^4.12.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/core": "^4.32.0",
"@taiga-ui/i18n": "^4.32.0",
"@taiga-ui/kit": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/cdk": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.30.0.tgz",
"integrity": "sha512-ndfnLOnL6vriItm5lq8/0slzj03CatkGVYG8zAT3fx00Vuam5Wf8Sh6h2ObqCFAljT7WJxHqMF9A1cBfLPI/iQ==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.32.0.tgz",
"integrity": "sha512-qvYe79uh6Tw2LJSEGLJYUlAidbZi6JgcuMRqWAB1JhyIGpgnaqar5v+oJJg28zJZZ81PCj59VkFNLL0dNVXRUg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.8.1"
@@ -4520,20 +4520,20 @@
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@angular/forms": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@ng-web-apis/mutation-observer": "^4.11.1",
"@ng-web-apis/platform": "^4.11.1",
"@ng-web-apis/resize-observer": "^4.11.1",
"@ng-web-apis/screen-orientation": "^4.11.1",
"@taiga-ui/event-plugins": "^4.4.1",
"@ng-web-apis/common": "^4.12.0",
"@ng-web-apis/mutation-observer": "^4.12.0",
"@ng-web-apis/platform": "^4.12.0",
"@ng-web-apis/resize-observer": "^4.12.0",
"@ng-web-apis/screen-orientation": "^4.12.0",
"@taiga-ui/event-plugins": "^4.5.1",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/core": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.30.0.tgz",
"integrity": "sha512-IeZ6QBpSuv7k4bQx2BSDr8N3dDiMDwgnnwkkKqtJ0yJayZ/ZlCMq3nUQA0kg3VjH2spJeUbdqkDqpEuzrWJGkA==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.32.0.tgz",
"integrity": "sha512-e1z7YhhjePMRLTk+s83OclN45wMixCwZWMxM9WuXIyd2KXMPhJvrrgBDjoK66GuFtjZ4qaSF/H2FTIJmJ/6MiQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4545,19 +4545,19 @@
"@angular/forms": ">=16.0.0",
"@angular/platform-browser": ">=16.0.0",
"@angular/router": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@ng-web-apis/mutation-observer": "^4.11.1",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/event-plugins": "^4.4.1",
"@taiga-ui/i18n": "^4.30.0",
"@ng-web-apis/common": "^4.12.0",
"@ng-web-apis/mutation-observer": "^4.12.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/event-plugins": "^4.5.1",
"@taiga-ui/i18n": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/event-plugins": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.5.0.tgz",
"integrity": "sha512-bMW36eqr4Q+EnUM8ZNjx1Sw8POIAcyALY74xVPq9UHoQ3NqnRkeEDnZdfPhq9IYxtC3sO2BttNjWYcvBAkU2+A==",
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.5.1.tgz",
"integrity": "sha512-p5TAs6ZAJAlyl64OUdvnVnfCvDteJtLl9cjXarMzPRY7sX2d+SC97qnTZF8ACPcx4FgFaiHI6VH5G6UzYHMZ8g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.3.0"
@@ -4569,9 +4569,9 @@
}
},
"node_modules/@taiga-ui/experimental": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.30.0.tgz",
"integrity": "sha512-0GWkBinW+tqQIFkWQbTqMBTkKGZhju3RslKRCYbjal/hfcIuSAsEPZqLqIQqVqJNz6AhaIpT0UQ+I7QXzx1/yw==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.32.0.tgz",
"integrity": "sha512-sCOasTF9UlgPOW4vXSeM5M1tgGrjgofa+Qq7hejYW3BXs/4mnmdm5yiYzWfMVZd4jgTSeV5kobkIJ9Fkp2zt6g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4579,18 +4579,18 @@
"peerDependencies": {
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@taiga-ui/addon-commerce": "^4.30.0",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/core": "^4.30.0",
"@taiga-ui/kit": "^4.30.0",
"@taiga-ui/addon-commerce": "^4.32.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/core": "^4.32.0",
"@taiga-ui/kit": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/i18n": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.30.0.tgz",
"integrity": "sha512-OvtUqSRQE988XfiH1MS7Wd3Eg6dE1mkP2sqYRLw0HyE5Oc9hgHMwdPstSaoMN9aeJRVZnKXGsYmX4iaQ3x7drw==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.32.0.tgz",
"integrity": "sha512-PAQv9RxSgvf3RBUps9bXX2erCk9oiSt9ApM3SAIa/OuzET0TJsW6yZ4EQrtLw03bMX3wyA8PnEYva9wzoYAqxA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -4598,23 +4598,23 @@
},
"peerDependencies": {
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@ng-web-apis/common": "^4.12.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/icons": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.30.0.tgz",
"integrity": "sha512-EAbvw1ii4UVDgt9+5t7NQkV0WBqkVm5SGixH0ux8Vb4qhhLJJwp5xvXOCGt5QPzviT7nFGqXD6EqB23aYcuusg==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.32.0.tgz",
"integrity": "sha512-X2ZSiqeMKigULgX91fBZkFJRUbwzeW934yLEGhq7C1JMcC2+ppLmL/NbkD2kpKZ4OeHnGsItxKauoXu44rXeLA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.3.0"
}
},
"node_modules/@taiga-ui/kit": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.30.0.tgz",
"integrity": "sha512-tCHZbsiq1u19ariarFuP9iwnNSxJGicQnYvJYy2+QojL65KsC9p8VgZv36rpggpuPEUXRXwmhyz2Qi6fwFcbLg==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.32.0.tgz",
"integrity": "sha512-J8XoqeQHBNbAAuTz0kVACujDOb3zuh4Vps83lYl+msFIaUmkjC37muXF3eRlImH3m4DpT8yI8+ffh/T3+jky7w==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4628,21 +4628,21 @@
"@maskito/core": "^3.5.0",
"@maskito/kit": "^3.5.0",
"@maskito/phone": "^3.5.0",
"@ng-web-apis/common": "^4.11.1",
"@ng-web-apis/intersection-observer": "^4.11.1",
"@ng-web-apis/mutation-observer": "^4.11.1",
"@ng-web-apis/resize-observer": "^4.11.1",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/core": "^4.30.0",
"@taiga-ui/i18n": "^4.30.0",
"@ng-web-apis/common": "^4.12.0",
"@ng-web-apis/intersection-observer": "^4.12.0",
"@ng-web-apis/mutation-observer": "^4.12.0",
"@ng-web-apis/resize-observer": "^4.12.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/core": "^4.32.0",
"@taiga-ui/i18n": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/layout": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.30.0.tgz",
"integrity": "sha512-DyIqpmXcv/OP4byt7L1f1iBKPysf3L+sj/dBpkeYvAUUnJnXnJsXav0j57d43VkXPn9lpGqz0gEBtzVDt7xxTw==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.32.0.tgz",
"integrity": "sha512-ECaoJ3CbL+eoqL1MleaHvD9/FQ5OCaUMkjOdXId2Jg2MNbuDhtS9hqVZvSWLXRWz3XgC3aADYnPwrNvIsy5Mng==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4650,17 +4650,17 @@
"peerDependencies": {
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@taiga-ui/cdk": "^4.30.0",
"@taiga-ui/core": "^4.30.0",
"@taiga-ui/kit": "^4.30.0",
"@taiga-ui/cdk": "^4.32.0",
"@taiga-ui/core": "^4.32.0",
"@taiga-ui/kit": "^4.32.0",
"@taiga-ui/polymorpheus": "^4.9.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/legacy": {
"version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.30.0.tgz",
"integrity": "sha512-ebFJMddzlsq3TUAWxopn5Qju4REkC4bHzoYYx5OEzPq1VW1zmCvNC+X6usMnluhc9aS50UI8ZB7Xd3N4Zdgtfg==",
"version": "4.32.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.32.0.tgz",
"integrity": "sha512-wEsywt6hK2NNpHddqVrL0MTd1QFzmhMdPPgtraNOieQmzrSW2jpA37KJO11cVleuRdDsk98rFtzQ3stlNNFy5Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"

View File

@@ -47,18 +47,18 @@
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.2.2",
"@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.30.0",
"@taiga-ui/addon-commerce": "4.30.0",
"@taiga-ui/addon-mobile": "4.30.0",
"@taiga-ui/addon-table": "4.30.0",
"@taiga-ui/cdk": "4.30.0",
"@taiga-ui/core": "4.30.0",
"@taiga-ui/event-plugins": "4.5.0",
"@taiga-ui/experimental": "4.30.0",
"@taiga-ui/icons": "4.30.0",
"@taiga-ui/kit": "4.30.0",
"@taiga-ui/layout": "4.30.0",
"@taiga-ui/legacy": "4.30.0",
"@taiga-ui/addon-charts": "4.32.0",
"@taiga-ui/addon-commerce": "4.32.0",
"@taiga-ui/addon-mobile": "4.32.0",
"@taiga-ui/addon-table": "4.32.0",
"@taiga-ui/cdk": "4.32.0",
"@taiga-ui/core": "4.32.0",
"@taiga-ui/event-plugins": "4.5.1",
"@taiga-ui/experimental": "4.32.0",
"@taiga-ui/icons": "4.32.0",
"@taiga-ui/kit": "4.32.0",
"@taiga-ui/layout": "4.32.0",
"@taiga-ui/legacy": "4.32.0",
"@taiga-ui/polymorpheus": "4.9.0",
"@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2",

View File

@@ -8,7 +8,7 @@
header {
@include scrollbar-hidden();
// TODO: Theme
// @TODO Theme
background: #2b2b2f;
overflow: hidden;
width: 100%;

View File

@@ -16,21 +16,3 @@
</span>
</div>
</div>
<!-- @TODO Alex -->
<!-- <ion-item
class="service-card"
[routerLink]="['/marketplace', pkg.id]"
[queryParams]="{ flavor: pkg.flavor, version: pkg.version }"
>
<ion-thumbnail slot="start">
<img alt="" [src]="pkg.icon | trustUrl" />
</ion-thumbnail>
<ion-label>
<h2 class="montserrat">
<strong>{{ pkg.title }}</strong>
</h2>
<h3>{{ pkg.description.short }}</h3>
<ng-content></ng-content>
</ion-label>
</ion-item> -->

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core'
import { TuiDialogService } from '@taiga-ui/core'
import { RELEASE_NOTES } from '../../../modals/release-notes.component'
import { MarketplacePkg } from '../../../types'
import { MarketplacePkgBase } from '../../../types'
@Component({
selector: 'marketplace-about',
@@ -18,7 +18,7 @@ export class AboutComponent {
private readonly dialogs = inject(TuiDialogService)
@Input({ required: true })
pkg!: MarketplacePkg
pkg!: MarketplacePkgBase
async onPast() {
this.dialogs

View File

@@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiIcon, TuiLabel, TuiTitle } from '@taiga-ui/core'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiLineClamp } from '@taiga-ui/kit'
@Component({
@@ -43,7 +43,7 @@ import { TuiLineClamp } from '@taiga-ui/kit'
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiLineClamp, TuiLabel, TuiIcon, TuiTitle],
imports: [CommonModule, TuiLineClamp, TuiIcon, TuiTitle],
})
export class MarketplaceAdditionalItemComponent {
@Input({ required: true })

View File

@@ -4,7 +4,7 @@
<div class="detail-container">
<!-- release date -->
<marketplace-additional-item
*ngIf="pkg.s9pk.publishedAt as published"
*ngIf="pkg.s9pk?.publishedAt as published"
[data]="(published | date: 'medium')!"
label="Released"
icon=""
@@ -76,98 +76,3 @@
</div>
</div>
</div>
<!-- <ion-item-divider>Additional Info</ion-item-divider>
<ion-grid *ngIf="pkg">
<ion-row>
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item
*ngIf="pkg.gitHash as gitHash; else noHash"
button
detail="false"
(click)="copy(gitHash)"
>
<ion-label>
<h2>Git Hash</h2>
<p>{{ gitHash }}</p>
</ion-label>
<ion-icon slot="end" name="copy-outline"></ion-icon>
</ion-item>
<ng-template #noHash>
<ion-item>
<ion-label>
<h2>Git Hash</h2>
<p>Unknown</p>
</ion-label>
</ion-item>
</ng-template>
<ion-item button detail="false" (click)="presentAlertVersions()">
<ion-label>
<h2>Other Versions</h2>
<p>Click to view other versions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
<ion-item button detail="false" (click)="presentModalMd('license')">
<ion-label>
<h2>License</h2>
<p>{{ pkg.license }}</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
<ion-item
button
detail="false"
(click)="presentModalMd('instructions')"
>
<ion-label>
<h2>Instructions</h2>
<p>Click to view instructions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item
[href]="pkg.upstreamRepo"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Source Repository</h2>
<p>{{ pkg.upstreamRepo }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item
[href]="pkg.wrapperRepo"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Wrapper Repository</h2>
<p>{{ pkg.wrapperRepo }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item
[href]="pkg.supportSite"
[disabled]="!pkg.supportSite"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Support Site</h2>
<p>{{ pkg.supportSite || 'Not provided' }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
</ion-row>
</ion-grid> -->

View File

@@ -7,8 +7,7 @@ import {
} from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { CopyService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { MarketplacePkg } from '../../../types'
import { MarketplacePkgBase } from '../../../types'
@Component({
selector: 'marketplace-additional',
@@ -18,14 +17,13 @@ import { MarketplacePkg } from '../../../types'
})
export class AdditionalComponent {
@Input({ required: true })
pkg!: MarketplacePkg
pkg!: MarketplacePkgBase
@Output()
readonly static = new EventEmitter<string>()
readonly static = new EventEmitter<'License' | 'Instructions'>()
constructor(
readonly copyService: CopyService,
private readonly dialogs: TuiDialogService,
private readonly route: ActivatedRoute,
) {}

View File

@@ -6,7 +6,7 @@ import {
Input,
Output,
} from '@angular/core'
import { MarketplacePkg } from '../../../types'
import { MarketplacePkgBase } from '../../../types'
import { MarketplaceDepItemComponent } from './dependency-item.component'
@Component({
@@ -55,7 +55,7 @@ import { MarketplaceDepItemComponent } from './dependency-item.component'
})
export class MarketplaceDependenciesComponent {
@Input({ required: true })
pkg!: MarketplacePkg
pkg!: MarketplacePkgBase
@Output() open = new EventEmitter<string>()
}

View File

@@ -3,9 +3,8 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterModule } from '@angular/router'
import { ExverPipesModule } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
import { MarketplacePkg } from '../../../types'
import { MarketplacePkgBase } from '../../../types'
@Component({
selector: 'marketplace-dep-item',
@@ -97,12 +96,11 @@ import { MarketplacePkg } from '../../../types'
TuiAvatar,
ExverPipesModule,
TuiLineClamp,
TuiLet,
],
})
export class MarketplaceDepItemComponent {
@Input({ required: true })
pkg!: MarketplacePkg
pkg!: MarketplacePkgBase
@Input({ required: true })
dep!: KeyValue<string, T.DependencyMetadata>

View File

@@ -1,3 +1,4 @@
import { OptionalProperty } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
export type GetPackageReq = {
@@ -31,11 +32,17 @@ export type StoreData = {
packages: MarketplacePkg[]
}
export type MarketplacePkg = T.PackageVersionInfo &
Omit<GetPackageRes, 'best'> & {
id: T.PackageId
version: string
flavor: string | null
}
export type MarketplacePkgBase = OptionalProperty<
T.PackageVersionInfo,
's9pk'
> & {
id: T.PackageId
version: string
flavor: string | null
}
export type MarketplacePkg = MarketplacePkgBase &
GetPackageRes &
T.PackageVersionInfo
export type StoreDataWithUrl = StoreData & { url: string }

View File

@@ -117,7 +117,7 @@ export class MockApiService extends ApiService {
})),
) as Observable<T>
} else if (guid === 'progress-guid') {
// @TODO mock progress
// @TODO Matt mock progress
return interval(1000).pipe(
map(() => ({
overall: true,

View File

@@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiProgress } from '@taiga-ui/kit'
import { LogsWindowComponent } from './logs-window.component'
@@ -31,7 +30,7 @@ import { LogsWindowComponent } from './logs-window.component'
padding: 1rem;
margin: 1.5rem;
text-align: center;
/* TODO: Theme */
// @TODO Theme
background: #e0e0e0;
color: #333;
--tui-background-neutral-1: rgba(0, 0, 0, 0.1);
@@ -46,11 +45,11 @@ import { LogsWindowComponent } from './logs-window.component'
text-align: left;
overflow: hidden;
border-radius: 2rem;
/* TODO: Theme */
// @TODO Theme
background: #181818;
}
`,
imports: [CommonModule, LogsWindowComponent, TuiLet, TuiProgress],
imports: [CommonModule, LogsWindowComponent, TuiProgress],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InitializingComponent {

View File

@@ -1,7 +1,7 @@
import { AfterViewInit, Directive, ElementRef, Inject } from '@angular/core'
import { DOCUMENT } from '@angular/common'
// TODO: Refactor to use `MutationObserver` so it works with dynamic content
// @TODO Alex: Refactor to use `MutationObserver` so it works with dynamic content
@Directive({
selector: '[safeLinks]',
standalone: true,

View File

@@ -2,7 +2,7 @@ import { ErrorHandler, inject, Injectable } from '@angular/core'
import { TuiAlertService } from '@taiga-ui/core'
import { HttpError } from '../classes/http-error'
// TODO: Enable this as ErrorHandler
// @TODO Alex: Enable this as ErrorHandler
@Injectable({
providedIn: 'root',
})

View File

@@ -1,5 +1,13 @@
import { StaticClassProvider } from '@angular/core'
import { bufferTime, defer, map, Observable, scan, switchMap } from 'rxjs'
import {
bufferTime,
defer,
filter,
map,
Observable,
scan,
switchMap,
} from 'rxjs'
import { FollowLogsReq, FollowLogsRes, Log } from '../types/api'
import { Constructor } from '../types/constructor'
import { convertAnsi } from '../util/convert-ansi'
@@ -22,7 +30,8 @@ export function provideSetupLogsService(
export class SetupLogsService extends Observable<readonly string[]> {
private readonly log$ = defer(() => this.api.followServerLogs({})).pipe(
switchMap(({ guid }) => this.api.openWebsocket$(guid)),
bufferTime(1000),
bufferTime(500),
filter(logs => !!logs.length),
map(convertAnsi),
scan((logs: readonly string[], log) => [...logs, log], []),
)

View File

@@ -1,22 +1,29 @@
// @TODO get types from sdk
type Progress = null | boolean | { done: number; total: number | null }
type NamedProgress = { name: string; progress: Progress }
type FullProgress = { overall: Progress; phases: Array<NamedProgress> }
import { T } from '@start9labs/start-sdk'
export function formatProgress({ phases, overall }: FullProgress): {
export function formatProgress({ phases, overall }: T.FullProgress): {
total: number
message: string
} {
return {
total: getDecimal(overall),
message: phases
.filter(p => p.progress !== true && p.progress !== null)
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
.filter(
(
p,
): p is {
name: string
progress: {
done: number
total: number | null
}
} => p.progress !== true && p.progress !== null,
)
.map(p => `<b>${p.name}</b>${getPhaseBytes(p.progress)}`)
.join(', '),
}
}
function getDecimal(progress: Progress): number {
function getDecimal(progress: T.Progress): number {
if (progress === true) {
return 1
} else if (!progress || !progress.total) {
@@ -26,7 +33,7 @@ function getDecimal(progress: Progress): number {
}
}
function getPhaseBytes(progress: Progress): string {
function getPhaseBytes(progress: T.Progress): string {
return progress === true || !progress
? ''
: `: (${progress.done}/${progress.total})`

View File

@@ -55,3 +55,6 @@ export function toUrl(text: string | null | undefined): string {
}
export type WithId<T> = T & { id: string }
export type OptionalProperty<T, K extends keyof T> = Omit<T, K> &
Partial<Pick<T, K>>

View File

@@ -169,7 +169,7 @@ $wide-modal: 900px;
--portal-header-height: 56px;
// @TODO rename when make style lib
// @TODO Alex rename when make style lib
--tw-color-black: 0 0 0;
--tw-color-white: 255 255 255;
--tw-color-slate-50: 248 250 252;

View File

@@ -139,7 +139,7 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
background: rgb(34 34 34 / 80%);
}
// TODO: Move to Taiga UI
// @TODO Alex: Move to Taiga UI
a[tuiIconButton]:not([href]) {
pointer-events: none;
opacity: var(--tui-disabled-opacity);

View File

@@ -9,6 +9,7 @@ import { debounceTime, endWith, map, merge, Subject } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
// @TODO Alex
@Component({
standalone: true,
selector: 'refresh-alert',

View File

@@ -6,16 +6,7 @@ import {
provideSetupLogsService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
catchError,
defer,
EMPTY,
from,
map,
startWith,
switchMap,
tap,
} from 'rxjs'
import { catchError, defer, from, map, startWith, switchMap, tap } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { StateService } from 'src/app/services/state.service'
@@ -36,18 +27,25 @@ export default class InitializingPage {
defer(() => from(this.api.initFollowProgress())).pipe(
switchMap(({ guid, progress }) =>
this.api
.openWebsocket$<T.FullProgress>(guid, {})
.openWebsocket$<T.FullProgress>(guid, {
closeObserver: {
next: () => {
this.state.syncState()
},
},
})
.pipe(startWith(progress)),
),
map(formatProgress),
tap<{ total: number; message: string }>(({ total }) => {
tap(({ total }) => {
if (total === 1) {
this.state.syncState()
}
}),
catchError(e => {
catchError((e, caught$) => {
console.error(e)
return EMPTY
this.state.syncState()
return caught$
}),
),
{ initialValue: { total: 0, message: '' } },

View File

@@ -42,7 +42,6 @@ export class LoginPage {
}
await this.api.login({
password: this.password,
metadata: { platforms: [] }, // @TODO do we really need platforms now?
ephemeral: window.location.host === 'localhost',
})

View File

@@ -17,7 +17,7 @@ export abstract class Control<
return this.control.spec
}
// TODO: Properly handle already set immutable value
// @TODO Alex: Properly handle already set immutable value
get readOnly(): boolean {
return (
!!this.value && !!this.control.control?.pristine && this.control.immutable

View File

@@ -91,7 +91,7 @@ import { HeaderStatusComponent } from './status.component'
}
&:has([data-status='success']) {
--status: var(--tui-status-positive);
--status: transparent;
}
}

View File

@@ -88,6 +88,7 @@ import { InterfaceComponent } from './interface.component'
text-align: right;
grid-area: 1 / 2 / 3 / 3;
place-content: center;
white-space: nowrap;
}
.mobile {

View File

@@ -15,6 +15,7 @@ import { MappedServiceInterface } from './interface.utils'
`,
styles: `
:host {
max-width: 56rem;
display: flex;
flex-direction: column;
gap: 1rem;

View File

@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { TuiBadge } from '@taiga-ui/kit'
@Component({
standalone: true,
selector: 'interface-status',
template: `
<tui-badge
size="l"
[iconStart]="public() ? '@tui.globe' : '@tui.lock'"
[style.vertical-align.rem]="-0.125"
[style.margin]="'0 0.25rem -0.25rem'"
[appearance]="public() ? 'positive' : 'negative'"
>
{{ public() ? 'Public' : 'Private' }}
</tui-badge>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiBadge],
})
export class InterfaceStatusComponent {
readonly public = input(false)
}

View File

@@ -80,7 +80,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
:host {
display: none;
backdrop-filter: blur(1rem);
// TODO: Theme
// TODO Theme
--tui-background-elevation-1: #333;
--tui-background-base: #fff;
--tui-border-normal: var(--tui-background-neutral-1);

View File

@@ -24,7 +24,7 @@ import { HeaderComponent } from './components/header/header.component'
height: 100%;
display: flex;
flex-direction: column;
// TODO: Theme
// @TODO Theme
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
}

View File

@@ -102,7 +102,7 @@ export class BackupsUpcomingComponent {
readonly targets = toSignal(from(this.api.getBackupTargets({})))
readonly current = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
// @TODO remove "as any" once this feature is real
// @TODO 041 remove "as any" once this feature is real
.watch$('serverInfo', 'statusInfo', 'currentBackup' as any, 'job')
.pipe(map(job => job || {})),
)

View File

@@ -7,7 +7,7 @@ import {
Input,
} from '@angular/core'
import { Router } from '@angular/router'
import { MarketplacePkg } from '@start9labs/marketplace'
import { MarketplacePkgBase } from '@start9labs/marketplace'
import {
Exver,
ErrorService,
@@ -103,7 +103,7 @@ export class MarketplaceControlsComponent {
private readonly marketplaceService = inject(MarketplaceService)
@Input({ required: true })
pkg!: MarketplacePkg
pkg!: MarketplacePkgBase
@Input()
localPkg!: PackageDataEntry | null

View File

@@ -22,10 +22,9 @@ import {
TuiButton,
TuiDialogContext,
TuiDialogService,
TuiIcon,
TuiLoader,
} from '@taiga-ui/core'
import { TuiRadioList, TuiStringifyContentPipe } from '@taiga-ui/kit'
import { TuiRadioList } from '@taiga-ui/kit'
import {
BehaviorSubject,
combineLatest,
@@ -53,7 +52,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
@if (!(pkg.dependencyMetadata | empty)) {
<marketplace-dependencies [pkg]="pkg" (open)="open($event)" />
}
<marketplace-additional [pkg]="pkg">
<marketplace-additional [pkg]="pkg" (static)="onStatic($event)">
@if (versions$ | async; as versions) {
<marketplace-additional-item
(click)="versions.length ? selectVersion(pkg, version) : 0"
@@ -172,10 +171,8 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
AboutModule,
SharedPipesModule,
FormsModule,
TuiStringifyContentPipe,
TuiRadioList,
TuiLoader,
TuiIcon,
FlavorsComponent,
],
})
@@ -228,6 +225,10 @@ export class MarketplacePreviewComponent {
this.router.navigate([], { queryParams: { id } })
}
onStatic(type: 'License' | 'Instructions') {
// @TODO Alex need to display License or Instructions. This requires an API request, check out next/minor
}
selectVersion(
{ version }: MarketplacePkg,
template: TemplateRef<TuiDialogContext>,

View File

@@ -1,6 +1,6 @@
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { inject, Injectable } from '@angular/core'
import { MarketplacePkg } from '@start9labs/marketplace'
import { MarketplacePkg, MarketplacePkgBase } from '@start9labs/marketplace'
import { TuiDialogService } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
@@ -60,7 +60,7 @@ export class MarketplaceAlertsService {
})
}
async alertInstall({ alerts }: MarketplacePkg): Promise<boolean> {
async alertInstall({ alerts }: MarketplacePkgBase): Promise<boolean> {
const content = alerts.install
return (

View File

@@ -1,19 +1,27 @@
import { inject, Injectable } from '@angular/core'
import {
catchError,
defer,
filter,
ignoreElements,
Observable,
repeat,
retry,
shareReplay,
startWith,
switchMap,
take,
tap,
} from 'rxjs'
import { ServerMetrics } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
@Injectable({
providedIn: 'root',
})
export class MetricsService extends Observable<ServerMetrics> {
private readonly connection = inject(ConnectionService)
private readonly api = inject(ApiService)
private readonly metrics$ = defer(() =>
@@ -22,8 +30,10 @@ export class MetricsService extends Observable<ServerMetrics> {
switchMap(({ guid, metrics }) =>
this.api.openWebsocket$<ServerMetrics>(guid).pipe(startWith(metrics)),
),
// @TODO Alex how to handle failure and reconnection here? Simple retry() will not work. Seems like we need a general solution for reconnecting websockets: patchDB, logs, metrics, progress, and any future. Reconnection should depend on server state, then we need to get a new guid, then reconnect. Similar to how patchDB websocket currently behaves on disconnect/reconnect.
retry(),
catchError(() =>
this.connection.pipe(filter(Boolean), take(1), ignoreElements()),
),
repeat(),
shareReplay(1),
)

View File

@@ -56,7 +56,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'
:host {
height: 100%;
min-height: 7.5rem;
min-height: 9rem;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -16,11 +16,6 @@ import { TimeService } from 'src/app/services/time.service'
selector: 'metrics-time',
template: `
@if (now(); as time) {
@if (!time.synced) {
<tui-notification appearance="warning">
<ng-container *ngTemplateOutlet="hint" />
</tui-notification>
}
<div tuiCell>
<div tuiTitle [style.text-align]="'center'">
<div tuiSubtitle class="g-secondary">
@@ -36,6 +31,11 @@ import { TimeService } from 'src/app/services/time.service'
/>
}
</div>
@if (!time.synced) {
<tui-notification size="s" appearance="warning">
<ng-container *ngTemplateOutlet="hint" />
</tui-notification>
}
} @else {
Loading...
}
@@ -61,10 +61,12 @@ import { TimeService } from 'src/app/services/time.service'
styles: `
:host {
height: 100%;
min-height: var(--tui-height-l);
display: flex;
flex-direction: column;
justify-content: center;
gap: 1rem;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
[tuiCell],
@@ -72,6 +74,10 @@ import { TimeService } from 'src/app/services/time.service'
[tuiSubtitle] {
margin: 0;
justify-content: center;
&::after {
display: none;
}
}
}
@@ -79,6 +85,10 @@ import { TimeService } from 'src/app/services/time.service'
display: none;
}
tui-notification {
width: fit-content;
}
:host-context(tui-root._mobile) {
tui-notification {
display: none;

View File

@@ -30,6 +30,7 @@ import { TimeService } from 'src/app/services/time.service'
styles: `
:host {
height: 100%;
min-height: var(--tui-height-l);
display: flex;
text-align: center;
justify-content: center;

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiTitle } from '@taiga-ui/core'
interface ActionItem {
readonly name: string
@@ -12,7 +12,6 @@ interface ActionItem {
@Component({
selector: '[action]',
template: `
<tui-icon [icon]="action.icon || '@tui.circle-play'" />
<div tuiTitle>
<strong>{{ action.name }}</strong>
<div tuiSubtitle>{{ action.description }}</div>
@@ -23,7 +22,7 @@ interface ActionItem {
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiIcon, TuiTitle],
imports: [TuiTitle],
host: {
'[disabled]': '!!disabled',
},

View File

@@ -1,27 +1,27 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { DependencyInfo } from 'src/app/routes/portal/routes/services/types/dependency-info'
import { map } from 'rxjs'
import { ControlsService } from 'src/app/services/controls.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
selector: 'service-actions',
selector: 'service-controls',
template: `
@if (['running', 'starting', 'restarting'].includes(status)) {
<button
tuiButton
appearance="secondary-destructive"
iconStart="@tui.square"
(click)="actions.stop(manifest)"
(click)="controls.stop(manifest())"
>
Stop
</button>
@@ -31,7 +31,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
<button
tuiButton
iconStart="@tui.rotate-cw"
(click)="actions.restart(manifest)"
(click)="controls.restart(manifest())"
>
Restart
</button>
@@ -41,7 +41,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
<button
tuiButton
iconStart="@tui.play"
(click)="actions.start(manifest, hasUnmet(dependencies))"
(click)="controls.start(manifest(), !!hasUnmet)"
>
Start
</button>
@@ -78,24 +78,26 @@ import { getManifest } from 'src/app/utils/get-package-data'
standalone: true,
imports: [TuiButton],
})
export class ServiceActionsComponent {
export class ServiceControlsComponent {
private readonly errors = inject(DepErrorService)
@Input({ required: true })
pkg!: PackageDataEntry
@Input({ required: true })
status!: PrimaryStatus
// TODO
dependencies: readonly DependencyInfo[] = []
readonly manifest = computed(() => getManifest(this.pkg))
readonly actions = inject(ControlsService)
readonly controls = inject(ControlsService)
get manifest(): T.Manifest {
return getManifest(this.pkg)
}
@tuiPure
hasUnmet(dependencies: readonly DependencyInfo[]): boolean {
return dependencies.some(dep => !!dep.errorText)
}
readonly hasUnmet = computed(() =>
this.errors.getPkgDepErrors$(this.manifest().id).pipe(
map(errors =>
Object.keys(this.pkg.currentDependencies)
.map(id => errors[id])
.some(Boolean),
),
),
)
}

View File

@@ -5,11 +5,11 @@ import {
Input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { MappedInterface } from '../types/mapped-interface'
@Component({
selector: 'tr[serviceInterface]',
@@ -113,7 +113,10 @@ export class ServiceInterfaceComponent {
private readonly config = inject(ConfigService)
@Input({ required: true })
info!: MappedInterface
info!: T.ServiceInterface & {
public: boolean
routerLink: string
}
@Input({ required: true })
pkg!: PackageDataEntry

View File

@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiLoader } from '@taiga-ui/core'
import { getProgressText } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { InstallingInfo } from 'src/app/services/patch-db/data-model'
import {
PrimaryRendering,
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
@Component({
selector: 'service-status',
@@ -17,7 +18,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
<tui-loader size="s" [inheritColor]="true" />
Installing
<span class="loading-dots"></span>
{{ installingInfo.progress.overall | installingProgressString }}
{{ getText(installingInfo.progress.overall) }}
</h3>
} @else {
<h3 [class]="class">
@@ -84,7 +85,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
host: { class: 'g-card' },
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InstallingProgressDisplayPipe, TuiLoader],
imports: [TuiLoader],
})
export class ServiceStatusComponent {
@Input({ required: true })
@@ -120,4 +121,8 @@ export class ServiceStatusComponent {
get rendering() {
return PrimaryRendering[this.status]
}
getText(progress: T.Progress): string {
return getProgressText(progress)
}
}

View File

@@ -21,14 +21,7 @@ import { ServicesService } from './services.service'
<th tuiTh [requiredSort]="true" [sorter]="name">Name</th>
<th tuiTh>Version</th>
<th tuiTh [requiredSort]="true" [sorter]="uptime">Uptime</th>
<th
tuiTh
[requiredSort]="true"
[sorter]="status"
[style.width.rem]="13"
>
Status
</th>
<th tuiTh [requiredSort]="true" [sorter]="status">Status</th>
<th [style.width.rem]="8" [style.text-indent.rem]="1.5">Controls</th>
</tr>
</thead>

View File

@@ -1,14 +1,9 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
import { getProgressText } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
@Component({
standalone: true,
@@ -31,6 +26,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
align-items: center;
gap: 0.5rem;
height: 3rem;
white-space: nowrap;
}
:host-context(tui-root._mobile) {
@@ -46,11 +42,8 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon, TuiLoader],
providers: [InstallingProgressDisplayPipe],
})
export class StatusComponent {
private readonly pipe = inject(InstallingProgressDisplayPipe)
@Input()
pkg!: PackageDataEntry
@@ -72,7 +65,7 @@ export class StatusComponent {
get status(): string {
if (this.pkg.stateInfo.installingInfo) {
return `Installing...${this.pipe.transform(this.pkg.stateInfo.installingInfo.progress.overall)}`
return `Installing...${getProgressText(this.pkg.stateInfo.installingInfo.progress.overall)}`
}
switch (this.getStatus(this.pkg).primary) {

View File

@@ -1,22 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core'
import { T } from '@start9labs/start-sdk'
// TODO drop these pipes
@Pipe({
standalone: true,
name: 'installingProgressString',
})
export class InstallingProgressDisplayPipe implements PipeTransform {
transform(progress: T.Progress): string {
if (progress === true) return 'finalizing'
if (progress === false || progress === null || !progress.total)
return 'unknown %'
const percentage = Math.round((100 * progress.done) / progress.total)
return percentage < 99 ? String(percentage) + '%' : 'finalizing'
}
}
@Pipe({
standalone: true,
name: 'installingProgress',
@@ -28,3 +12,12 @@ export class InstallingProgressPipe implements PipeTransform {
return Math.floor((100 * progress.done) / progress.total)
}
}
export function getProgressText(progress: T.Progress): string {
if (progress === true) return 'finalizing'
if (!progress || !progress.total) return 'unknown %'
const percentage = Math.round((100 * progress.done) / progress.total)
return percentage < 99 ? `${percentage}%` : 'finalizing'
}

View File

@@ -39,7 +39,7 @@ import {
section {
display: flex;
flex-direction: column;
max-width: 32rem;
max-width: 36rem;
padding: 0.5rem 1rem;
}
`,

View File

@@ -50,7 +50,7 @@ const OTHER = 'Other Custom Actions'
`,
styles: `
section {
max-width: 54rem;
max-width: 42rem;
display: flex;
flex-direction: column;
margin-bottom: 2rem;
@@ -74,18 +74,20 @@ export default class ServiceActionsRoute {
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.keys(pkg.actions).reduce<
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
>(
(acc, id) => {
const action = { id, ...pkg.actions[id] }
const group = pkg.actions[id].group || OTHER
const current = acc[group] || []
actions: Object.entries(pkg.actions)
.filter(([_, val]) => val.visibility !== 'hidden')
.reduce<
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
>(
(acc, [id]) => {
const action = { id, ...pkg.actions[id] }
const group = pkg.actions[id].group || OTHER
const current = acc[group] || []
return { ...acc, [group]: current.concat(action) }
},
{ [OTHER]: [] },
),
return { ...acc, [group]: current.concat(action) }
},
{ [OTHER]: [] },
),
})),
),
)
@@ -110,7 +112,7 @@ const REBUILD = {
icon: '@tui.wrench',
name: 'Rebuild Service',
description:
'Rebuilds the service container. It is harmless and only takes a few seconds to complete, but it should only be necessary if a StartOS bug is preventing dependencies, interfaces, or actions from synchronizing.',
'Rebuilds the service container. Only necessary in there is a bug in StartOS',
}
const UNINSTALL = {

View File

@@ -15,6 +15,7 @@ import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { InterfaceStatusComponent } from 'src/app/routes/portal/components/interfaces/status.component'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -24,7 +25,7 @@ import { TitleDirective } from 'src/app/services/title.service'
<ng-container *title>
<a routerLink="../.." tuiIconButton iconStart="@tui.arrow-left">Back</a>
{{ interface()?.name }}
<ng-container *ngTemplateOutlet="badge" />
<interface-status [public]="!!interface()?.public" />
</ng-container>
<tui-breadcrumbs size="l" [style.margin-block-end.rem]="1">
<a *tuiItem tuiLink appearance="action-grayscale" routerLink="../..">
@@ -32,7 +33,7 @@ import { TitleDirective } from 'src/app/services/title.service'
</a>
<span *tuiItem class="g-primary">
{{ interface()?.name }}
<ng-container *ngTemplateOutlet="badge" />
<interface-status [public]="!!interface()?.public" />
</span>
</tui-breadcrumbs>
@if (interface(); as serviceInterface) {
@@ -41,16 +42,6 @@ import { TitleDirective } from 'src/app/services/title.service'
[serviceInterface]="serviceInterface"
/>
}
<ng-template #badge>
<tui-badge
[iconStart]="interface()?.public ? '@tui.globe' : '@tui.lock'"
[style.vertical-align.rem]="-0.125"
[style.margin]="'0 0.25rem -0.25rem'"
[appearance]="interface()?.public ? 'positive' : 'negative'"
>
{{ interface()?.public ? 'Public' : 'Private' }}
</tui-badge>
</ng-template>
`,
styles: `
:host-context(tui-root._mobile) tui-breadcrumbs {
@@ -70,6 +61,7 @@ import { TitleDirective } from 'src/app/services/title.service'
TuiLink,
TuiBadge,
NgTemplateOutlet,
InterfaceStatusComponent,
],
})
export default class ServiceInterfaceRoute {

View File

@@ -19,7 +19,7 @@ import {
} from 'src/app/services/patch-db/data-model'
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { ServiceActionRequestsComponent } from '../components/action-requests.component'
import { ServiceActionsComponent } from '../components/actions.component'
import { ServiceControlsComponent } from '../components/controls.component'
import { ServiceDependenciesComponent } from '../components/dependencies.component'
import { ServiceErrorComponent } from '../components/error.component'
import { ServiceHealthChecksComponent } from '../components/health-checks.component'
@@ -38,7 +38,7 @@ import { ServiceStatusComponent } from '../components/status.component'
<p class="g-secondary" [appUptime]="started"></p>
}
@if (installed() && connected()) {
<service-actions [pkg]="pkg()" [status]="status()" />
<service-controls [pkg]="pkg()" [status]="status()" />
}
</service-status>
@@ -90,7 +90,7 @@ import { ServiceStatusComponent } from '../components/status.component'
CommonModule,
ServiceProgressComponent,
ServiceStatusComponent,
ServiceActionsComponent,
ServiceControlsComponent,
ServiceInterfacesComponent,
ServiceHealthChecksComponent,
ServiceDependenciesComponent,

View File

@@ -1,9 +0,0 @@
export interface DependencyInfo {
id: string
title: string | null
icon: string | null
version: string
errorText: string
actionText: string
action: () => any
}

View File

@@ -1,16 +0,0 @@
import { T } from '@start9labs/start-sdk'
export type MappedInterface = T.ServiceInterface & {
public: boolean
// TODO implement addresses
addresses: any
routerLink: string
}
export type MappedAddress = {
name: string
url: string
isDomain: boolean
isOnion: boolean
acme: string | null
}

View File

@@ -1,11 +1,11 @@
import { CommonModule } from '@angular/common'
import { Component, inject, Input } from '@angular/core'
import { Router, RouterLink } from '@angular/router'
import {
AboutModule,
AdditionalModule,
MarketplaceDependenciesComponent,
MarketplacePackageHeroComponent,
MarketplacePkgBase,
} from '@start9labs/marketplace'
import {
ErrorService,
@@ -13,66 +13,39 @@ import {
LoadingService,
SharedPipesModule,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiProgressBar } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { combineLatest, filter, firstValueFrom, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { MarketplaceControlsComponent } from '../marketplace/components/controls.component'
import { filter, first, map } from 'rxjs'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { SideloadService } from './sideload.service'
import { MarketplacePkgSideload } from './sideload.utils'
@Component({
selector: 'sideload-package',
template: `
<div class="outer-container">
<ng-content />
@if (progress$ | async; as progress) {
@for (phase of progress.phases; track $index) {
<p>
{{ phase.name }}
@if (phase.progress | installingProgress; as progress) {
: {{ progress }}%
}
</p>
<progress
tuiProgressBar
size="xs"
[style.color]="
phase.progress === true
? 'var(--tui-text-positive)'
: 'var(--tui-text-action)'
"
[attr.value]="(phase.progress | installingProgress) / 100 || null"
></progress>
}
} @else {
<marketplace-package-hero
*tuiLet="button$ | async as button"
[pkg]="package"
>
<div class="inner-container">
@if (button !== null && button !== 'Install') {
<a tuiButton [routerLink]="'/portal/services/' + package.id">
View installed
</a>
}
@if (button) {
<button tuiButton (click)="upload()">{{ button }}</button>
}
</div>
</marketplace-package-hero>
<!-- @TODO Matt do we want this here? How do we turn s9pk into MarketplacePkg? -->
<!-- <marketplace-about [pkg]="package" />-->
<!-- @if (!(package.dependencyMetadata | empty)) {-->
<!-- <marketplace-dependencies [pkg]="package" (open)="open($event)" />-->
<!-- }-->
<!-- <marketplace-additional [pkg]="package" />-->
}
<marketplace-package-hero [pkg]="pkg">
<marketplace-controls
slot="controls"
class="controls-wrapper"
[pkg]="pkg"
[localPkg]="local$ | async"
[localFlavor]="!!(flavor$ | async)"
/>
</marketplace-package-hero>
<div class="package-details">
<div class="package-details-main">
<marketplace-about [pkg]="pkg" />
@if (!(pkg.dependencyMetadata | empty)) {
<marketplace-dependencies [pkg]="pkg" />
}
</div>
<div class="package-details-additional">
<marketplace-additional [pkg]="pkg" (static)="onStatic($event)" />
</div>
</div>
</div>
`,
styles: [
@@ -83,95 +56,97 @@ import { SideloadService } from './sideload.service'
width: 100%;
@media (min-width: 1024px) {
max-width: 80%;
margin: auto;
padding: 2.5rem 4rem 2rem 4rem;
}
}
.inner-container {
.package-details {
-moz-column-gap: 2rem;
column-gap: 2rem;
&-main {
grid-column: span 12 / span 12;
}
&-additional {
grid-column: span 12 / span 12;
}
@media (min-width: 1536px) {
grid-template-columns: repeat(12, minmax(0, 1fr));
&-main {
grid-column: span 8 / span 8;
}
&-additional {
grid-column: span 4 / span 4;
margin-top: 0px;
}
}
}
.controls-wrapper {
display: flex;
justify-content: flex-start;
margin: -0.5rem 0 1.5rem -1px;
gap: 0.5rem;
height: 4.5rem;
}
`,
],
standalone: true,
imports: [
CommonModule,
RouterLink,
SharedPipesModule,
AboutModule,
AdditionalModule,
TuiButton,
TuiLet,
MarketplacePackageHeroComponent,
MarketplaceDependenciesComponent,
InstallingProgressPipe,
TuiProgressBar,
MarketplaceControlsComponent,
],
})
export class SideloadPackageComponent {
private readonly loader = inject(LoadingService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly router = inject(Router)
private readonly exver = inject(Exver)
private readonly sideloadService = inject(SideloadService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly progress$ = this.sideloadService.progress$
readonly button$ = combineLatest([
inject(ClientStorageService).showDevTools$,
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData')
.pipe(
map(local =>
local[this.package.id]
? this.exver.compareExver(
getManifest(local[this.package.id]).version,
this.package.version,
)
: null,
),
),
]).pipe(
map(([devtools, version]) => {
switch (version) {
case null:
return 'Install'
case 1:
return 'Update'
case -1:
return devtools ? 'Downgrade' : ''
default:
return ''
}
}),
)
// @Input({ required: true })
// pkg!: MarketplacePkgSideload
// @Alex why do I need to initialize pkg below? I would prefer to do the above, but it's not working
@Input({ required: true })
package!: T.Manifest & { icon: string }
pkg: MarketplacePkgSideload = {} as MarketplacePkgSideload
@Input({ required: true })
file!: File
readonly local$ = this.patch.watch$('packageData', this.pkg.id).pipe(
filter(Boolean),
map(pkg =>
this.exver.getFlavor(getManifest(pkg).version) === this.pkg.flavor
? pkg
: null,
),
first(),
)
readonly flavor$ = this.local$.pipe(map(pkg => !pkg))
onStatic(type: 'License' | 'Instructions') {
// @TODO Matt display License or Instructions
}
async upload() {
const loader = this.loader.open('Starting upload').subscribe()
try {
const { upload, progress } = await this.api.sideloadPackage()
this.sideloadService.followProgress(progress)
const { upload } = await this.api.sideloadPackage()
this.api.uploadPackage(upload, this.file).catch(console.error)
await firstValueFrom(this.progress$.pipe(filter(Boolean)))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
open(id: string) {
this.router.navigate(['/marketplace'], { queryParams: { id } })
}
}

View File

@@ -1,12 +1,11 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { T } from '@start9labs/start-sdk'
import { MarketplacePkgBase } from '@start9labs/marketplace'
import { tuiIsString } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import {
@@ -17,16 +16,16 @@ import {
import { ConfigService } from 'src/app/services/config.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SideloadPackageComponent } from './package.component'
import { parseS9pk } from './sideload.utils'
import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
@Component({
template: `
<ng-container *title>Sideload</ng-container>
@if (file && package()) {
<sideload-package [package]="package()!" [file]="file!">
@if (file && package(); as pkg) {
<sideload-package [pkg]="pkg" [file]="file!">
<button
tuiIconButton
appearance="secondary"
appearance="neutral"
iconStart="@tui.x"
[style.border-radius.%]="100"
[style.justify-self]="'end'"
@@ -55,7 +54,10 @@ import { parseS9pk } from './sideload.utils'
<tui-avatar appearance="secondary" src="@tui.cloud-upload" />
<p>Upload .s9pk package file</p>
@if (isTor) {
<p class="g-positive">Tip: switch to LAN for faster uploads</p>
<p class="g-warning">
Warning: package upload will be slow over Tor. Switch to local
for a better experience.
</p>
}
<button tuiButton>Upload</button>
</div>
@@ -69,7 +71,7 @@ import { parseS9pk } from './sideload.utils'
`
label {
height: 100%;
max-width: 40rem;
max-width: 42rem;
margin: 0 auto;
}
@@ -91,11 +93,10 @@ import { parseS9pk } from './sideload.utils'
],
})
export default class SideloadComponent {
private readonly cdr = inject(ChangeDetectorRef)
readonly isTor = inject(ConfigService).isTor()
file: File | null = null
readonly package = signal<(T.Manifest & { icon: string }) | null>(null)
readonly package = signal<MarketplacePkgSideload | null>(null)
readonly error = signal('')
clear() {
@@ -105,12 +106,11 @@ export default class SideloadComponent {
}
async onFile(file: File | null) {
const parsed = file ? await parseS9pk(file) : ''
this.file = file
const parsed = file ? await validateS9pk(file) : ''
this.package.set(tuiIsString(parsed) ? null : parsed)
this.error.set(tuiIsString(parsed) ? parsed : '')
// @TODO Alex figure out why it is needed even though we use signals
this.cdr.markForCheck()
}
}

View File

@@ -1,56 +0,0 @@
import { inject, Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
catchError,
EMPTY,
endWith,
shareReplay,
Subject,
switchMap,
tap,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Injectable({
providedIn: 'root',
})
export class SideloadService {
private readonly api = inject(ApiService)
private readonly guid$ = new Subject<string>()
private readonly errorService = inject(ErrorService)
private readonly router = inject(Router)
readonly progress$ = this.guid$.pipe(
switchMap(guid =>
this.api
.openWebsocket$<T.FullProgress>(guid, {
closeObserver: {
next: event => {
if (event.code !== 1000) {
this.errorService.handleError(event.reason)
}
},
},
})
.pipe(
tap(p => {
if (p.overall === true) {
this.router.navigate([''], { replaceUrl: true })
}
}),
endWith(null),
),
),
catchError(e => {
this.errorService.handleError('Websocket connection broken. Try again.')
return EMPTY
}),
shareReplay(1),
)
followProgress(guid: string) {
this.guid$.next(guid)
}
}

View File

@@ -1,31 +1,22 @@
import { S9pk, T } from '@start9labs/start-sdk'
import cbor from 'cbor'
import { MarketplacePkgBase } from '@start9labs/marketplace'
import { S9pk, ExtendedVersion } from '@start9labs/start-sdk'
const MAGIC = new Uint8Array([59, 59])
const VERSION_1 = new Uint8Array([1])
const VERSION_2 = new Uint8Array([2])
interface Positions {
[key: string]: [bigint, bigint] // [position, length]
}
export async function parseS9pk(
export async function validateS9pk(
file: File,
): Promise<(T.Manifest & { icon: string }) | string> {
): Promise<MarketplacePkgSideload | string> {
const magic = new Uint8Array(await blobToBuffer(file.slice(0, 2)))
const version = new Uint8Array(await blobToBuffer(file.slice(2, 3)))
if (compare(magic, MAGIC)) {
try {
if (compare(version, VERSION_1)) {
return await parseS9pkV1(file)
return 'Version 1 s9pk detected. This package format is deprecated. You can sideload a V1 s9pk via start-cli if necessary.'
} else if (compare(version, VERSION_2)) {
const s9pk = await S9pk.deserialize(file, null)
return {
...s9pk.manifest,
icon: await s9pk.icon(),
}
return await parseS9pk(file)
} else {
console.error(version)
@@ -43,92 +34,21 @@ export async function parseS9pk(
return 'Invalid package file'
}
async function parseS9pkV1(file: File) {
const positions: Positions = {}
// magic=2bytes, version=1bytes, pubkey=32bytes, signature=64bytes, toc_length=4bytes = 103byte is starting point
let start = 103
let end = start + 1 // 104
const tocLength = new DataView(
await blobToBuffer(file.slice(99, 103) ?? new Blob()),
).getUint32(0, false)
await getPositions(start, end, file, positions, tocLength as any)
const data = await blobToBuffer(
file.slice(
Number(positions['manifest'][0]),
Number(positions['manifest'][0]) + Number(positions['manifest'][1]),
),
)
async function parseS9pk(file: File): Promise<MarketplacePkgSideload> {
const s9pk = await S9pk.deserialize(file, null)
return {
...(await cbor.decode(data, true)),
icon: await blobToDataURL(
file.slice(
Number(positions['icon'][0]),
Number(positions['icon'][0]) + Number(positions['icon'][1]),
'',
),
),
...s9pk.manifest,
dependencyMetadata: await s9pk.dependencyMetadata(),
gitHash: '',
icon: await s9pk.icon(),
sourceVersion: s9pk.manifest.canMigrateFrom,
flavor: ExtendedVersion.parse(s9pk.manifest.version).flavor,
license: await s9pk.license(),
instructions: await s9pk.instructions(),
}
}
async function getPositions(
initialStart: number,
initialEnd: number,
file: Blob,
positions: Positions,
tocLength: number,
) {
let start = initialStart
let end = initialEnd
const titleLength = new Uint8Array(
await blobToBuffer(file.slice(start, end)),
)[0]
const tocTitle = await file.slice(end, end + titleLength).text()
start = end + titleLength
end = start + 8
const chapterPosition = new DataView(
await blobToBuffer(file.slice(start, end)),
).getBigUint64(0, false)
start = end
end = start + 8
const chapterLength = new DataView(
await blobToBuffer(file.slice(start, end)),
).getBigUint64(0, false)
positions[tocTitle] = [chapterPosition, chapterLength]
start = end
end = start + 1
if (end <= tocLength + (initialStart - 1)) {
await getPositions(start, end, file, positions, tocLength)
}
}
async function readBlobAsDataURL(
f: Blob | File,
): Promise<string | ArrayBuffer | null> {
const reader = new FileReader()
return new Promise((resolve, reject) => {
reader.onloadend = () => {
resolve(reader.result)
}
reader.readAsDataURL(f)
reader.onerror = _ => reject(new Error('error reading blob'))
})
}
async function blobToDataURL(data: Blob | File): Promise<string> {
const res = await readBlobAsDataURL(data)
if (res instanceof ArrayBuffer) {
throw new Error('readBlobAsDataURL response should not be an array buffer')
}
if (res == null) {
throw new Error('readBlobAsDataURL response should not be null')
}
if (typeof res === 'string') return res
throw new Error('no possible blob to data url resolution found')
}
async function blobToBuffer(data: Blob | File): Promise<ArrayBuffer> {
const res = await readBlobToArrayBuffer(data)
if (res instanceof String) {
@@ -158,3 +78,8 @@ async function readBlobToArrayBuffer(
function compare(a: Uint8Array, b: Uint8Array) {
return a.length === b.length && a.every((value, index) => value === b[index])
}
export type MarketplacePkgSideload = MarketplacePkgBase & {
license: string
instructions: string
}

View File

@@ -93,7 +93,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
`,
styles: `
:host {
max-width: 40rem;
max-width: 36rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -140,7 +140,7 @@ export default class SystemDomainsComponent {
this.formDialog.open(FormComponent, options)
}
// @TODO figure out how to get types here
// @TODO 041 figure out how to get types here
private getNetworkStrategy(strategy: any) {
return strategy.selection === 'local'
? { ipStrategy: strategy.value.ipStrategy }
@@ -162,7 +162,7 @@ export default class SystemDomainsComponent {
loader.unsubscribe()
}
}
// @TODO figure out how to get types here
// @TODO 041 figure out how to get types here
private async claimDomain({ strategy }: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const networkStrategy = this.getNetworkStrategy(strategy)
@@ -177,7 +177,7 @@ export default class SystemDomainsComponent {
loader.unsubscribe()
}
}
// @TODO figure out how to get types here
// @TODO 041 figure out how to get types here
private async save({ provider, strategy, hostname }: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const name = provider.selection

View File

@@ -97,7 +97,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
`,
styles: `
:host {
max-width: 40rem;
max-width: 36rem;
}
form header,

View File

@@ -8,6 +8,7 @@ import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { InterfaceStatusComponent } from 'src/app/routes/portal/components/interfaces/status.component'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -34,10 +35,14 @@ const iface: T.ServiceInterface = {
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
StartOS UI
<interface-status [public]="!!ui()?.public" />
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>{{ iface.name }}</h3>
<h3>
{{ iface.name }}
<interface-status [public]="!!ui()?.public" />
</h3>
<p tuiSubtitle>{{ iface.description }}</p>
</hgroup>
</header>
@@ -54,6 +59,7 @@ const iface: T.ServiceInterface = {
TitleDirective,
TuiHeader,
TuiTitle,
InterfaceStatusComponent,
],
})
export default class StartOsUiComponent {

View File

@@ -62,7 +62,7 @@ export default class SystemProxiesComponent {
this.formDialog.open(FormComponent, options)
}
// @TODO fix type to be WireguardSpec
// @TODO 041 fix type to be WireguardSpec
private async save({ name, config }: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()

View File

@@ -1,38 +1,45 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PlatformType } from 'src/app/services/api/api.types'
@Pipe({
name: 'platformInfo',
standalone: true,
})
export class PlatformInfoPipe implements PipeTransform {
transform(platforms: readonly PlatformType[]): {
transform(userAgent: string | null): {
name: string
icon: string
} {
const info = {
name: '',
icon: '@tui.smartphone',
if (!userAgent) {
return {
name: 'CLI',
icon: '@tui.terminal',
}
}
if (platforms.includes('cli')) {
info.name = 'CLI'
info.icon = '@tui.terminal'
} else if (platforms.includes('desktop')) {
info.name = 'Desktop/Laptop'
info.icon = '@tui.monitor'
} else if (platforms.includes('android')) {
info.name = 'Android Device'
} else if (platforms.includes('iphone')) {
info.name = 'iPhone'
} else if (platforms.includes('ipad')) {
info.name = 'iPad'
} else if (platforms.includes('ios')) {
info.name = 'iOS Device'
} else {
info.name = 'Unknown Device'
if (/Android/i.test(userAgent)) {
return {
name: 'Android Device',
icon: '@tui.smartphone',
}
}
return info
if (/iPhone/i.test(userAgent)) {
return {
name: 'iPhone',
icon: '@tui.smartphone',
}
}
if (/iPad/i.test(userAgent)) {
return {
name: 'iPad',
icon: '@tui.smartphone',
}
}
return {
name: 'Desktop/Laptop',
icon: '@tui.monitor',
}
}
}

View File

@@ -1,5 +1,4 @@
import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'

View File

@@ -33,10 +33,10 @@ import { PlatformInfoPipe } from './platform-info.pipe'
<span tuiFade class="agent">{{ session.userAgent }}</span>
</label>
</td>
@if (session.metadata.platforms | platformInfo; as info) {
@if (session.userAgent | platformInfo; as platform) {
<td class="platform">
<tui-icon [icon]="info.icon" />
{{ info.name }}
<tui-icon [icon]="platform.icon" />
{{ platform.name }}
</td>
}
<td class="date">{{ session.lastActive | date: 'medium' }}</td>

View File

@@ -103,7 +103,7 @@ import { wifiSpec } from './wifi.const'
`,
styles: `
:host {
max-width: 40rem;
max-width: 36rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -935,26 +935,17 @@ export namespace Mock {
loggedIn: '2021-07-14T20:49:17.774Z',
lastActive: '2021-07-14T20:49:17.774Z',
userAgent: 'AppleWebKit/{WebKit Rev} (KHTML, like Gecko)',
metadata: {
platforms: ['iphone', 'mobileweb', 'mobile', 'ios'],
},
},
klndsfjhbwsajkdnaksj: {
loggedIn: '2021-07-14T20:49:17.774Z',
lastActive: '2019-07-14T20:49:17.774Z',
userAgent: 'AppleWebKit/{WebKit Rev} (KHTML, like Gecko)',
metadata: {
platforms: ['cli'],
},
},
b7b1a9cef4284f00af9e9dda6e676177: {
loggedIn: '2021-07-14T20:49:17.774Z',
lastActive: '2021-06-14T20:49:17.774Z',
userAgent:
'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
metadata: {
platforms: ['desktop'],
},
},
},
}
@@ -1500,7 +1491,6 @@ export namespace Mock {
},
{
spec: ISB.InputSpec.of({
/* TODO: Convert range for this value ([0, 2])*/
union: ISB.Value.union(
{
name: 'Preference',
@@ -1560,19 +1550,18 @@ export namespace Mock {
},
disabled: ['option2'],
})),
'favorite-number':
/* TODO: Convert range for this value ((-100,100])*/ ISB.Value.number(
{
name: 'Favorite Number',
description: 'Your favorite number of all time',
warning:
'Once you set this number, it can never be changed without severe consequences.',
required: false,
default: 7,
integer: false,
units: 'BTC',
},
),
'favorite-number': ISB.Value.number({
name: 'Favorite Number',
description: 'Your favorite number of all time',
warning:
'Once you set this number, it can never be changed without severe consequences.',
required: false,
default: 7,
integer: false,
units: 'BTC',
min: -100,
max: 100,
}),
rpcsettings: ISB.Value.object(
{
name: 'RPC Settings',
@@ -1906,7 +1895,7 @@ export namespace Mock {
name: 'View Properties',
description: 'view important information about Bitcoin',
warning: null,
visibility: 'enabled',
visibility: 'hidden',
allowedStatuses: 'any',
hasInput: false,
group: null,

View File

@@ -31,7 +31,6 @@ export namespace RR {
export type LoginReq = {
password: string
metadata: SessionMetadata
ephemeral?: boolean
} // auth.login - unauthed
export type loginRes = null
@@ -421,30 +420,8 @@ export type Session = {
loggedIn: string
lastActive: string
userAgent: string
metadata: SessionMetadata
}
export type SessionMetadata = {
platforms: PlatformType[]
}
export type PlatformType =
| 'cli'
| 'ios'
| 'ipad'
| 'iphone'
| 'android'
| 'phablet'
| 'tablet'
| 'cordova'
| 'capacitor'
| 'electron'
| 'pwa'
| 'mobile'
| 'mobileweb'
| 'desktop'
| 'hybrid'
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
export interface DiskBackupTarget {
@@ -604,7 +581,7 @@ export type DependencyErrorTransitive = {
type: 'transitive'
}
// **** @TODO 041 ****
// @TODO 041
// export namespace RR041 {
// // ** domains **

View File

@@ -252,7 +252,7 @@ export const mockPatchData: DataModel = {
name: 'View Properties',
description: 'view important information about Bitcoin',
warning: null,
visibility: 'enabled',
visibility: 'hidden',
allowedStatuses: 'any',
hasInput: false,
group: null,

View File

@@ -357,11 +357,11 @@ export function listUnique(spec: IST.ValueSpecList): ValidatorFn {
const objSpec = spec.spec
let display1: string
let display2: string
let uniqueMessage = isObject(objSpec)
let uniqueMessage = isListObject(objSpec)
? uniqueByMessageWrapper(objSpec.uniqueBy, objSpec)
: ''
if (isObject(objSpec) && objSpec.displayAs) {
if (isListObject(objSpec) && objSpec.displayAs) {
display1 = `"${(Mustache as any).render(
objSpec.displayAs,
list[idx],
@@ -390,7 +390,6 @@ function listItemEquals(
val1: any,
val2: any,
): boolean {
// TODO: fix types
switch (spec.spec.type) {
case 'text':
return val1 == val2
@@ -402,45 +401,6 @@ function listItemEquals(
}
}
function itemEquals(spec: IST.ValueSpec, val1: any, val2: any): boolean {
switch (spec.type) {
case 'text':
case 'textarea':
case 'number':
case 'toggle':
case 'select':
return val1 == val2
case 'object':
// TODO: 'unique-by' does not exist on ValueSpecObject, fix types
return objEquals(
(spec as any)['unique-by'],
spec as IST.ValueSpecObject,
val1,
val2,
)
case 'union':
// TODO: 'unique-by' does not exist onIST.ValueSpecUnion, fix types
return unionEquals(
(spec as any)['unique-by'],
spec as IST.ValueSpecUnion,
val1,
val2,
)
case 'list':
if (val1.length !== val2.length) {
return false
}
for (let idx = 0; idx < val1.length; idx++) {
if (listItemEquals(spec, val1[idx], val2[idx])) {
return false
}
}
return true
default:
return false
}
}
function listObjEquals(
uniqueBy: IST.UniqueBy,
spec: IST.ListValueSpecObject,
@@ -450,17 +410,17 @@ function listObjEquals(
if (!uniqueBy) {
return false
} else if (typeof uniqueBy === 'string') {
return itemEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
return uniqueByEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (listObjEquals(subSpec, spec, val1, val2)) {
for (let unique of uniqueBy.any) {
if (listObjEquals(unique, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!listObjEquals(subSpec, spec, val1, val2)) {
for (let unique of uniqueBy.all) {
if (!listObjEquals(unique, spec, val1, val2)) {
return false
}
}
@@ -469,66 +429,29 @@ function listObjEquals(
return false
}
function objEquals(
uniqueBy: IST.UniqueBy,
spec: IST.ValueSpecObject,
val1: any,
val2: any,
): boolean {
if (!uniqueBy) {
return false
} else if (typeof uniqueBy === 'string') {
// TODO: fix types
return itemEquals((spec as any)[uniqueBy], val1[uniqueBy], val2[uniqueBy])
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (objEquals(subSpec, spec, val1, val2)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!objEquals(subSpec, spec, val1, val2)) {
function uniqueByEquals(spec: IST.ValueSpec, val1: any, val2: any): boolean {
switch (spec.type) {
case 'text':
case 'textarea':
case 'number':
case 'toggle':
case 'select':
case 'color':
case 'datetime':
return val1 == val2
case 'list':
if (val1.length !== val2.length) {
return false
}
}
return true
}
return false
}
function unionEquals(
uniqueBy: IST.UniqueBy,
spec: IST.ValueSpecUnion,
val1: any,
val2: any,
): boolean {
const variantSpec = spec.variants[val1.selection].spec
if (!uniqueBy) {
return false
} else if (typeof uniqueBy === 'string') {
if (uniqueBy === 'selection') {
return val1.selection === val2.selection
} else {
return itemEquals(variantSpec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
}
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (unionEquals(subSpec, spec, val1, val2)) {
return true
for (let idx = 0; idx < val1.length; idx++) {
if (listItemEquals(spec, val1[idx], val2[idx])) {
return false
}
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!unionEquals(subSpec, spec, val1, val2)) {
return false
}
}
return true
return true
default:
return false
}
return false
}
function uniqueByMessageWrapper(
@@ -573,7 +496,7 @@ function uniqueByMessage(
: '(' + ret + ')'
}
function isObject(
function isListObject(
spec: IST.ListValueSpecOf<any>,
): spec is IST.ListValueSpecObject {
// only lists of objects have uniqueBy

View File

@@ -48,7 +48,6 @@ export class MarketplaceService {
this.registryUrl$.pipe(
switchMap(url => this.fetchRegistry$(url)),
filter(Boolean),
// @TODO is updateStoreName needed?
map(registry => {
registry.info.categories = {
all: {
@@ -217,7 +216,7 @@ export class MarketplaceService {
map(packages => {
return Object.entries(packages).flatMap(([id, pkgInfo]) =>
Object.keys(pkgInfo.best).map(version =>
this.convertToMarketplacePkg(
this.convertRegistryPkgToMarketplacePkg(
id,
version,
this.exver.getFlavor(version),
@@ -239,12 +238,12 @@ export class MarketplaceService {
this.api.getRegistryPackage(url, id, version ? `=${version}` : null),
).pipe(
map(pkgInfo =>
this.convertToMarketplacePkg(id, version, flavor, pkgInfo),
this.convertRegistryPkgToMarketplacePkg(id, version, flavor, pkgInfo),
),
)
}
private convertToMarketplacePkg(
private convertRegistryPkgToMarketplacePkg(
id: string,
version: string | null | undefined,
flavor: string | null,

View File

@@ -40,7 +40,7 @@ hr {
top left,
top right;
// TODO: Theme
// @TODO Theme
background-color: color-mix(
in hsl,
var(--tui-background-base) 90%,

View File

@@ -22,7 +22,6 @@
"allowSyntheticDefaultImports": true,
"paths": {
/* These paths are relative to each app base folder */
/* @TODO Alex the marketplace path is different on 0351. verify */
"@start9labs/marketplace": ["../marketplace/index"],
"@start9labs/shared": ["../shared/src/public-api"],
"path": ["../../node_modules/path-browserify"]