Compare commits

..

5 Commits

Author SHA1 Message Date
Aiden McClelland
e8ef39adad misc fixes for alpha.16 (#3091)
* port misc fixes from feature/nvidia

* switch back to official tor proxy on 9050

* refactor OpenUI

* fix typo

* fixes, plus getServiceManifest

* fix EffectCreator, bump to beta.47

* fixes
2026-01-10 12:58:17 -07:00
Remco Ros
466b9217b5 fix: allow (multiple) equal signs in env filehelper values (#3090) 2026-01-06 18:32:03 +00:00
Matt Hill
c9a7f519b9 Misc (#3087)
* help ios downlaod .crt and add begin add masked for addresses

* only require and show CA for public domain if addSsl

* fix type and revert i18n const

* feat: add address masking and adjust design (#3088)

* feat: add address masking and adjust design

* update lockfile

* chore: move eye button to actions

* chore: refresh notifications and handle action error

* static width for health check name

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* hide certificate authorities tab

* alpha.17

* add waiting health check status

* remove "on" from waiting message

* reject on abort in `.watch`

* id migration: nostr -> nostr-rs-relay

* health check waiting state

* use interface type for launch button

* better wording for masked

* cleaner

* sdk improvements

* fix type error

* fix notification badge issue

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
2025-12-31 11:30:57 -07:00
Aiden McClelland
96ae532879 Refactor/project structure (#3085)
* refactor project structure

* environment-based default registry

* fix tests

* update build container

* use docker platform for iso build emulation

* simplify compat

* Fix docker platform spec in run-compat.sh

* handle riscv compat

* fix bug with dep error exists attr

* undo removal of sorting

* use qemu for iso stage

---------

Co-authored-by: Mariusz Kogen <k0gen@pm.me>
Co-authored-by: Matt Hill <mattnine@protonmail.com>
2025-12-22 13:39:38 -07:00
Alex Inkin
eda08d5b0f chore: update taiga (#3086)
* chore: update taiga

* chore: fix UI menu
2025-12-22 13:33:02 -07:00
127 changed files with 2132 additions and 1453 deletions

View File

@@ -278,7 +278,7 @@ ts-bindings: core/bindings/index.ts
core/bindings/index.ts: $(call ls-files, core) $(ENVIRONMENT_FILE)
rm -rf core/bindings
./core/build/build-ts.sh
ls core/bindings/*.ts | sed 's/core\/startos\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' | tee core/bindings/index.ts
ls core/bindings/*.ts | sed 's/core\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' | tee core/bindings/index.ts
npm --prefix sdk exec -- prettier --config ./sdk/base/package.json -w ./core/bindings/*.ts
touch core/bindings/index.ts

View File

@@ -63,7 +63,7 @@ mount --bind /proc /media/startos/next/proc
mount --bind /boot /media/startos/next/boot
mount --bind /media/startos/root /media/startos/next/media/startos/root
if mountpoint /sys/firmware/efi/efivars 2> /dev/null; then
if mountpoint /sys/firmware/efi/efivars 2>&1 > /dev/null; then
mount --bind /sys/firmware/efi/efivars /media/startos/next/sys/firmware/efi/efivars
fi
@@ -75,7 +75,7 @@ else
CHROOT_RES=$?
fi
if mountpoint /media/startos/next/sys/firmware/efi/efivars 2> /dev/null; then
if mountpoint /media/startos/next/sys/firmware/efi/efivars 2>&1 > /dev/null; then
umount /media/startos/next/sys/firmware/efi/efivars
fi

View File

@@ -35,16 +35,20 @@ if [ "$UNDO" = 1 ]; then
exit $err
fi
# DNAT: rewrite destination for incoming packets (external traffic)
iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
iptables -t nat -A ${NAME}_PREROUTING -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
# DNAT: rewrite destination for locally-originated packets (hairpin from host itself)
iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
iptables -t nat -A ${NAME}_PREROUTING -s "$dip/$dprefix" -d "$sip" -p tcp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
iptables -t nat -A ${NAME}_PREROUTING -s "$dip/$dprefix" -d "$sip" -p udp --dport "$sport" -j DNAT --to-destination "$dip:$dport"
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
# MASQUERADE: rewrite source for all forwarded traffic to the destination
# This ensures responses are routed back through the host regardless of source IP
iptables -t nat -A ${NAME}_POSTROUTING -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -d "$dip" -p udp --dport "$dport" -j MASQUERADE
# Allow new connections to be forwarded to the destination
iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT
iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT

View File

@@ -50,12 +50,12 @@ mount --bind /proc /media/startos/next/proc
mount --bind /boot /media/startos/next/boot
mount --bind /media/startos/root /media/startos/next/media/startos/root
if mountpoint /boot/efi 2> /dev/null; then
if mountpoint /boot/efi 2>&1 > /dev/null; then
mkdir -p /media/startos/next/boot/efi
mount --bind /boot/efi /media/startos/next/boot/efi
fi
if mountpoint /sys/firmware/efi/efivars 2> /dev/null; then
if mountpoint /sys/firmware/efi/efivars 2>&1 > /dev/null; then
mount --bind /sys/firmware/efi/efivars /media/startos/next/sys/firmware/efi/efivars
fi

View File

@@ -5,25 +5,24 @@ if [ -z "$VERSION" ]; then
exit 2
fi
if [ -z "$RUN_ID" ]; then
>&2 echo '$RUN_ID required'
exit 2
fi
set -e
if [ "$SKIP_DL" != "1" ]; then
rm -rf ~/Downloads/v$VERSION
mkdir ~/Downloads/v$VERSION
cd ~/Downloads/v$VERSION
if [ "$SKIP_CLEAN" != "1" ]; then
rm -rf ~/Downloads/v$VERSION
mkdir ~/Downloads/v$VERSION
cd ~/Downloads/v$VERSION
fi
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree raspberrypi; do
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.squashfs -D $(pwd); do sleep 1; done
done
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.iso -D $(pwd); do sleep 1; done
done
while ! gh run download -R Start9Labs/start-os $RUN_ID -n raspberrypi.img -D $(pwd); do sleep 1; done
if [ -n "$RUN_ID" ]; then
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree raspberrypi; do
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.squashfs -D $(pwd); do sleep 1; done
done
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.iso -D $(pwd); do sleep 1; done
done
while ! gh run download -R Start9Labs/start-os $RUN_ID -n raspberrypi.img -D $(pwd); do sleep 1; done
fi
if [ -n "$ST_RUN_ID" ]; then
for arch in aarch64 riscv64 x86_64; do

View File

@@ -38,7 +38,7 @@
},
"../sdk/dist": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-beta.45",
"version": "0.4.0-beta.47",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",

View File

@@ -178,6 +178,13 @@ export function makeEffects(context: EffectContext): Effects {
T.Effects["getInstalledPackages"]
>
},
getServiceManifest(
...[options]: Parameters<T.Effects["getServiceManifest"]>
) {
return rpcRound("get-service-manifest", options) as ReturnType<
T.Effects["getServiceManifest"]
>
},
subcontainer: {
createFs(options: { imageId: string; name: string }) {
return rpcRound("subcontainer.create-fs", options) as ReturnType<

View File

@@ -10,7 +10,6 @@ import { SDKManifest } from "@start9labs/start-sdk/base/lib/types"
import { SubContainerRc } from "@start9labs/start-sdk/package/lib/util/SubContainer"
const EMBASSY_HEALTH_INTERVAL = 15 * 1000
const EMBASSY_PROPERTIES_LOOP = 30 * 1000
/**
* We wanted something to represent what the main loop is doing, and
* in this case it used to run the properties, health, and the docker/ js main.

View File

@@ -50,6 +50,7 @@ import {
transformOldConfigToNew,
} from "./transformConfigSpec"
import { partialDiff } from "@start9labs/start-sdk/base/lib/util"
import { Volume } from "@start9labs/start-sdk/package/lib/util/Volume"
type Optional<A> = A | undefined | null
function todo(): never {
@@ -61,14 +62,14 @@ export const EMBASSY_JS_LOCATION = "/usr/lib/startos/package/embassy.js"
const configFile = FileHelper.json(
{
volumeId: "embassy",
base: new Volume("embassy"),
subpath: "config.json",
},
matches.any,
)
const dependsOnFile = FileHelper.json(
{
volumeId: "embassy",
base: new Volume("embassy"),
subpath: "dependsOn.json",
},
dictionary([string, array(string)]),
@@ -330,6 +331,10 @@ export class SystemForEmbassy implements System {
) {
this.version.upstream.prerelease = ["alpha"]
}
if (this.manifest.id === "nostr") {
this.manifest.id = "nostr-rs-relay"
}
}
async init(

View File

@@ -15,4 +15,7 @@ case $ARCH in
DOCKER_PLATFORM=linux/arm64;;
esac
docker run --rm $USE_TTY --platform=$DOCKER_PLATFORM -eARCH --privileged -v "$(pwd):/root/start-os" start9/build-env /root/start-os/container-runtime/update-image.sh
docker run --rm $USE_TTY --platform=$DOCKER_PLATFORM -eARCH --privileged -v "$(pwd):/root/start-os" start9/build-env /root/start-os/container-runtime/update-image.sh
if [ "$(ls -nd "rootfs.${ARCH}.squashfs" | awk '{ print $3 }')" != "$UID" ]; then
docker run --rm $USE_TTY -v "$(pwd):/root/start-os" start9/build-env chown -R $UID:$UID /root/start-os/container-runtime
fi

2
core/.gitignore vendored
View File

@@ -8,4 +8,4 @@ secrets.db
.env
.editorconfig
proptest-regressions/**/*
/startos/bindings/*
/bindings/*

2
core/Cargo.lock generated
View File

@@ -7664,7 +7664,7 @@ dependencies = [
[[package]]
name = "start-os"
version = "0.4.0-alpha.16"
version = "0.4.0-alpha.17"
dependencies = [
"aes 0.7.5",
"arti-client",

View File

@@ -15,7 +15,7 @@ license = "MIT"
name = "start-os"
readme = "README.md"
repository = "https://github.com/Start9Labs/start-os"
version = "0.4.0-alpha.16" # VERSION_BUMP
version = "0.4.0-alpha.17" # VERSION_BUMP
[lib]
name = "startos"

View File

@@ -184,7 +184,11 @@ async fn cli_login<C: SessionAuthContext>(
where
CliContext: CallRemote<C>,
{
let password = rpassword::prompt_password("Password: ")?;
let password = if let Ok(password) = std::env::var("PASSWORD") {
password
} else {
rpassword::prompt_password("Password: ")?
};
ctx.call_remote::<C>(
&parent_method.into_iter().chain(method).join("."),

View File

@@ -56,8 +56,7 @@ pub async fn restart(ctx: RpcContext, ControlParams { id }: ControlParams) -> Re
.as_idx_mut(&id)
.or_not_found(&id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| Ok(s.restart()))
.restart()
})
.await
.result?;

View File

@@ -94,7 +94,23 @@ impl Public {
..Default::default()
},
gateways: OrdMap::new(),
acme: BTreeMap::new(),
acme: {
let mut acme: BTreeMap<AcmeProvider, AcmeSettings> = Default::default();
acme.insert(
"letsencrypt".parse()?,
AcmeSettings {
contact: Vec::new(),
},
);
#[cfg(feature = "dev")]
acme.insert(
"letsencrypt-staging".parse()?,
AcmeSettings {
contact: Vec::new(),
},
);
acme
},
dns: Default::default(),
},
status_info: ServerStatus {

View File

@@ -703,22 +703,22 @@ async fn watch_ip(
.into_iter()
.map(IpNet::try_from)
.try_collect()?;
let tables = ip4_proxy.route_data().await?.into_iter().filter_map(|d|d.table).collect::<Vec<_>>();
if !tables.is_empty() {
let rules = String::from_utf8(Command::new("ip").arg("rule").arg("list").invoke(ErrorKind::Network).await?)?;
for table in tables {
for subnet in subnets.iter().filter(|s| s.addr().is_ipv4()) {
let subnet_string = subnet.trunc().to_string();
let rule = ["from", &subnet_string, "lookup", &table.to_string()];
if !rules.contains(&rule.join(" ")) {
if rules.contains(&rule[..2].join(" ")) {
Command::new("ip").arg("rule").arg("del").args(&rule[..2]).invoke(ErrorKind::Network).await?;
}
Command::new("ip").arg("rule").arg("add").args(rule).invoke(ErrorKind::Network).await?;
}
}
}
}
// let tables = ip4_proxy.route_data().await?.into_iter().filter_map(|d|d.table).collect::<Vec<_>>();
// if !tables.is_empty() {
// let rules = String::from_utf8(Command::new("ip").arg("rule").arg("list").invoke(ErrorKind::Network).await?)?;
// for table in tables {
// for subnet in subnets.iter().filter(|s| s.addr().is_ipv4()) {
// let subnet_string = subnet.trunc().to_string();
// let rule = ["from", &subnet_string, "lookup", &table.to_string()];
// if !rules.contains(&rule.join(" ")) {
// if rules.contains(&rule[..2].join(" ")) {
// Command::new("ip").arg("rule").arg("del").args(&rule[..2]).invoke(ErrorKind::Network).await?;
// }
// Command::new("ip").arg("rule").arg("add").args(rule).invoke(ErrorKind::Network).await?;
// }
// }
// }
// }
let wan_ip = if !subnets.is_empty()
&& !matches!(
device_type,

View File

@@ -15,7 +15,7 @@ use crate::util::future::NonDetachingJoinHandle;
pub const DEFAULT_SOCKS_LISTEN: SocketAddr = SocketAddr::V4(SocketAddrV4::new(
Ipv4Addr::new(HOST_IP[0], HOST_IP[1], HOST_IP[2], HOST_IP[3]),
9050,
1080,
));
pub struct SocksController {

View File

@@ -472,7 +472,7 @@ fn cert_send(cert: &X509, hostname: &Hostname) -> Result<Response, Error> {
)
.to_lowercase(),
)
.header(http::header::CONTENT_TYPE, "application/x-x509-ca-cert")
.header(http::header::CONTENT_TYPE, "application/octet-stream")
.header(http::header::CONTENT_LENGTH, pem.len())
.header(
http::header::CONTENT_DISPOSITION,

View File

@@ -43,7 +43,6 @@ const STARTING_HEALTH_TIMEOUT: u64 = 120; // 2min
const TOR_CONTROL: SocketAddr =
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 1, 1), 9051));
const TOR_SOCKS: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 1, 1), 9050));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OnionAddress(OnionAddressV3);
@@ -402,10 +401,15 @@ fn event_handler(_event: AsyncEvent<'static>) -> BoxFuture<'static, Result<(), C
#[derive(Clone)]
pub struct TorController(Arc<TorControl>);
impl TorController {
const TOR_SOCKS: &[SocketAddr] = &[
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 9050)),
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 3, 1), 9050)),
];
pub fn new() -> Result<Self, Error> {
Ok(TorController(Arc::new(TorControl::new(
TOR_CONTROL,
TOR_SOCKS,
Self::TOR_SOCKS,
))))
}
@@ -508,7 +512,7 @@ impl TorController {
}
Ok(Box::new(tcp_stream))
} else {
let mut stream = TcpStream::connect(TOR_SOCKS)
let mut stream = TcpStream::connect(Self::TOR_SOCKS[0])
.await
.with_kind(ErrorKind::Tor)?;
if let Err(e) = socket2::SockRef::from(&stream).set_keepalive(true) {
@@ -595,7 +599,7 @@ enum TorCommand {
#[instrument(skip_all)]
async fn torctl(
tor_control: SocketAddr,
tor_socks: SocketAddr,
tor_socks: &[SocketAddr],
recv: &mut mpsc::UnboundedReceiver<TorCommand>,
services: &mut Watch<
BTreeMap<
@@ -641,10 +645,21 @@ async fn torctl(
tokio::fs::remove_dir_all("/var/lib/tor").await?;
wipe_state.store(false, std::sync::atomic::Ordering::SeqCst);
}
write_file_atomic(
"/etc/tor/torrc",
format!("SocksPort {TOR_SOCKS}\nControlPort {TOR_CONTROL}\nCookieAuthentication 1\n"),
)
write_file_atomic("/etc/tor/torrc", {
use std::fmt::Write;
let mut conf = String::new();
for tor_socks in tor_socks {
writeln!(&mut conf, "SocksPort {tor_socks}").unwrap();
}
writeln!(
&mut conf,
"ControlPort {tor_control}\nCookieAuthentication 1"
)
.unwrap();
conf
})
.await?;
tokio::fs::create_dir_all("/var/lib/tor").await?;
Command::new("chown")
@@ -976,7 +991,10 @@ struct TorControl {
>,
}
impl TorControl {
pub fn new(tor_control: SocketAddr, tor_socks: SocketAddr) -> Self {
pub fn new(
tor_control: SocketAddr,
tor_socks: impl AsRef<[SocketAddr]> + Send + 'static,
) -> Self {
let (send, mut recv) = mpsc::unbounded_channel();
let services = Watch::new(BTreeMap::new());
let mut thread_services = services.clone();
@@ -987,7 +1005,7 @@ impl TorControl {
loop {
if let Err(e) = torctl(
tor_control,
tor_socks,
tor_socks.as_ref(),
&mut recv,
&mut thread_services,
&wipe_state,

View File

@@ -241,7 +241,7 @@ pub async fn mark_seen_before(
ctx.db
.mutate(|db| {
let n = db.as_private_mut().as_notifications_mut();
for id in n.keys()?.range(..before) {
for id in n.keys()?.range(..=before) {
n.as_idx_mut(&id)
.or_not_found(lazy_format!("Notification #{id}"))?
.as_seen_mut()

View File

@@ -176,7 +176,7 @@ impl S9pk<TmpSource<PackSource>> {
impl TryFrom<ManifestV1> for Manifest {
type Error = Error;
fn try_from(value: ManifestV1) -> Result<Self, Self::Error> {
fn try_from(mut value: ManifestV1) -> Result<Self, Self::Error> {
let default_url = value.upstream_repo.clone();
let mut version = ExtendedVersion::from(
exver::emver::Version::from_str(&value.version)
@@ -190,6 +190,9 @@ impl TryFrom<ManifestV1> for Manifest {
} else if &*value.id == "lightning-terminal" || &*value.id == "robosats" {
version = version.map_upstream(|v| v.with_prerelease(["alpha".into()]));
}
if &*value.id == "nostr" {
value.id = "nostr-rs-relay".parse()?;
}
Ok(Self {
id: value.id,
title: format!("{} (Legacy)", value.title).into(),

View File

@@ -113,7 +113,7 @@ impl Manifest {
if let Some(emulate_as) = &config.emulate_missing_as {
expected.check_file(
Path::new("images")
.join(arch)
.join(emulate_as)
.join(image_id)
.with_extension("squashfs"),
)?;

View File

@@ -36,6 +36,7 @@ struct ServiceCallbackMap {
>,
get_status: BTreeMap<PackageId, Vec<CallbackHandler>>,
get_container_ip: BTreeMap<PackageId, Vec<CallbackHandler>>,
get_service_manifest: BTreeMap<PackageId, Vec<CallbackHandler>>,
}
impl ServiceCallbacks {
@@ -68,6 +69,10 @@ impl ServiceCallbacks {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
this.get_service_manifest.retain(|_, v| {
v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0);
!v.is_empty()
});
})
}
@@ -250,6 +255,25 @@ impl ServiceCallbacks {
.filter(|cb| !cb.0.is_empty())
})
}
pub(super) fn add_get_service_manifest(&self, package_id: PackageId, handler: CallbackHandler) {
self.mutate(|this| {
this.get_service_manifest
.entry(package_id)
.or_default()
.push(handler)
})
}
#[must_use]
pub fn get_service_manifest(&self, package_id: &PackageId) -> Option<CallbackHandlers> {
self.mutate(|this| {
this.get_service_manifest
.remove(package_id)
.map(CallbackHandlers)
.filter(|cb| !cb.0.is_empty())
})
}
}
pub struct CallbackHandler {

View File

@@ -36,8 +36,7 @@ pub async fn restart(context: EffectContext) -> Result<(), Error> {
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| Ok(s.restart()))
.restart()
})
.await
.result?;

View File

@@ -14,7 +14,10 @@ use crate::disk::mount::filesystem::bind::{Bind, FileType};
use crate::disk::mount::filesystem::idmapped::{IdMap, IdMapped};
use crate::disk::mount::filesystem::{FileSystem, MountType};
use crate::disk::mount::util::{is_mountpoint, unmount};
use crate::s9pk::manifest::Manifest;
use crate::service::effects::callbacks::CallbackHandler;
use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::{FromStrParser, VersionString};
use crate::volume::data_dir;
@@ -367,3 +370,45 @@ pub async fn check_dependencies(
}
Ok(results)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct GetServiceManifestParams {
pub package_id: PackageId,
#[ts(optional)]
#[arg(skip)]
pub callback: Option<CallbackId>,
}
pub async fn get_service_manifest(
context: EffectContext,
GetServiceManifestParams {
package_id,
callback,
}: GetServiceManifestParams,
) -> Result<Manifest, Error> {
let context = context.deref()?;
if let Some(callback) = callback {
let callback = callback.register(&context.seed.persistent_container);
context
.seed
.ctx
.callbacks
.add_get_service_manifest(package_id.clone(), CallbackHandler::new(&context, callback));
}
let db = context.seed.ctx.db.peek().await;
let manifest = db
.as_public()
.as_package_data()
.as_idx(&package_id)
.or_not_found(&package_id)?
.as_state_info()
.as_manifest(ManifestPreference::New)
.de()?;
Ok(manifest)
}

View File

@@ -88,6 +88,10 @@ pub fn handler<C: Context>() -> ParentHandler<C> {
"get-installed-packages",
from_fn_async(dependency::get_installed_packages).no_cli(),
)
.subcommand(
"get-service-manifest",
from_fn_async(dependency::get_service_manifest).no_cli(),
)
// health
.subcommand("set-health", from_fn_async(health::set_health).no_cli())
// subcontainer

View File

@@ -575,6 +575,17 @@ impl Service {
.await
.result?;
// Trigger manifest callbacks after successful installation
let manifest = service.seed.persistent_container.s9pk.as_manifest();
if let Some(callbacks) = ctx.callbacks.get_service_manifest(&manifest.id) {
let manifest_value =
serde_json::to_value(manifest).with_kind(ErrorKind::Serialization)?;
callbacks
.call(imbl::vector![manifest_value.into()])
.await
.log_err();
}
Ok(service)
}

View File

@@ -1,5 +1,7 @@
use std::path::Path;
use imbl::vector;
use crate::context::RpcContext;
use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState};
use crate::prelude::*;
@@ -65,6 +67,11 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(),
));
}
};
// Trigger manifest callbacks with null to indicate uninstall
if let Some(callbacks) = ctx.callbacks.get_service_manifest(&manifest.id) {
callbacks.call(vector![Value::Null]).await.log_err();
}
if !soft {
let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id);
if tokio::fs::metadata(&path).await.is_ok() {

View File

@@ -24,6 +24,7 @@ impl FromStr for NamedHealthCheckResult {
"success" => NamedHealthCheckResultKind::Success { message },
"disabled" => NamedHealthCheckResultKind::Disabled { message },
"starting" => NamedHealthCheckResultKind::Starting { message },
"waiting" => NamedHealthCheckResultKind::Waiting { message },
"loading" => NamedHealthCheckResultKind::Loading {
message: message.unwrap_or_default(),
},
@@ -61,6 +62,7 @@ pub enum NamedHealthCheckResultKind {
Success { message: Option<String> },
Disabled { message: Option<String> },
Starting { message: Option<String> },
Waiting { message: Option<String> },
Loading { message: String },
Failure { message: String },
}
@@ -89,6 +91,13 @@ impl std::fmt::Display for NamedHealthCheckResult {
write!(f, "{name}: Starting")
}
}
NamedHealthCheckResultKind::Waiting { message } => {
if let Some(message) = message {
write!(f, "{name}: Waiting ({message})")
} else {
write!(f, "{name}: Waiting")
}
}
NamedHealthCheckResultKind::Loading { message } => {
write!(f, "{name}: Loading ({message})")
}

View File

@@ -53,6 +53,11 @@ impl Model<StatusInfo> {
self.as_started_mut().ser(&None)?;
Ok(())
}
pub fn restart(&mut self) -> Result<(), Error> {
self.as_desired_mut().map_mutate(|s| Ok(s.restart()))?;
self.as_health_mut().ser(&Default::default())?;
Ok(())
}
pub fn init(&mut self) -> Result<(), Error> {
self.as_started_mut().ser(&None)?;
self.as_desired_mut().map_mutate(|s| {

View File

@@ -56,8 +56,9 @@ mod v0_4_0_alpha_13;
mod v0_4_0_alpha_14;
mod v0_4_0_alpha_15;
mod v0_4_0_alpha_16;
mod v0_4_0_alpha_17;
pub type Current = v0_4_0_alpha_16::Version; // VERSION_BUMP
pub type Current = v0_4_0_alpha_17::Version; // VERSION_BUMP
impl Current {
#[instrument(skip(self, db))]
@@ -175,7 +176,8 @@ enum Version {
V0_4_0_alpha_13(Wrapper<v0_4_0_alpha_13::Version>),
V0_4_0_alpha_14(Wrapper<v0_4_0_alpha_14::Version>),
V0_4_0_alpha_15(Wrapper<v0_4_0_alpha_15::Version>),
V0_4_0_alpha_16(Wrapper<v0_4_0_alpha_16::Version>), // VERSION_BUMP
V0_4_0_alpha_16(Wrapper<v0_4_0_alpha_16::Version>),
V0_4_0_alpha_17(Wrapper<v0_4_0_alpha_17::Version>), // VERSION_BUMP
Other(exver::Version),
}
@@ -234,7 +236,8 @@ impl Version {
Self::V0_4_0_alpha_13(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_14(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_17(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::Other(v) => {
return Err(Error::new(
eyre!("unknown version {v}"),
@@ -285,7 +288,8 @@ impl Version {
Version::V0_4_0_alpha_13(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_14(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_17(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::Other(x) => x.clone(),
}
}

View File

@@ -286,6 +286,18 @@ impl VersionT for Version {
ErrorKind::Filesystem,
));
}
if tokio::fs::metadata("/media/startos/data/package-data/volumes/nostr")
.await
.is_ok()
{
tokio::fs::rename(
"/media/startos/data/package-data/volumes/nostr",
"/media/startos/data/package-data/volumes/nostr-rs-relay",
)
.await?;
}
// Should be the name of the package
let mut paths = tokio::fs::read_dir(path).await?;
while let Some(path) = paths.next_entry().await? {

View File

@@ -0,0 +1,53 @@
use exver::{PreReleaseSegment, VersionRange};
use super::v0_3_5::V0_3_0_COMPAT;
use super::{VersionT, v0_4_0_alpha_16};
use crate::db::model::public::AcmeSettings;
use crate::net::acme::AcmeProvider;
use crate::prelude::*;
lazy_static::lazy_static! {
static ref V0_4_0_alpha_17: exver::Version = exver::Version::new(
[0, 4, 0],
[PreReleaseSegment::String("alpha".into()), 17.into()]
);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_4_0_alpha_16::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_4_0_alpha_17.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
#[instrument(skip_all)]
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
let acme = db["public"]["serverInfo"]["network"]["acme"]
.as_object_mut()
.or_not_found("public.serverInfo.network.acme")?;
let letsencrypt =
InternedString::intern::<&str>("letsencrypt".parse::<AcmeProvider>()?.as_ref());
if !acme.contains_key(&letsencrypt) {
acme.insert(
letsencrypt,
to_value(&AcmeSettings {
contact: Vec::new(),
})?,
);
}
Ok(Value::Null)
}
fn down(self, _db: &mut Value) -> Result<(), Error> {
Ok(())
}
}

View File

@@ -15,6 +15,7 @@ import {
CreateTaskParams,
MountParams,
StatusInfo,
Manifest,
} from "./osBindings"
import {
PackageId,
@@ -83,6 +84,11 @@ export type Effects = {
mount(options: MountParams): Promise<string>
/** Returns a list of the ids of all installed packages */
getInstalledPackages(): Promise<string[]>
/** Returns the manifest of a service */
getServiceManifest(options: {
packageId: PackageId
callback?: () => void
}): Promise<Manifest>
// health
/** sets the result of a health check */

View File

@@ -224,8 +224,6 @@ export type ListValueSpecObject = {
uniqueBy: UniqueBy
displayAs: string | null
}
// TODO Aiden do we really want this expressivity? Why not the below. Also what's with the "readonly" portion?
// export type UniqueBy = null | string | { any: string[] } | { all: string[] }
export type UniqueBy =
| null

View File

@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Guid } from "./Guid"
import type { PackageId } from "./PackageId"
export type AddPackageSignerParams = {
id: PackageId
signer: Guid
versions: string | null
}

View File

@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CallbackId } from "./CallbackId"
import type { PackageId } from "./PackageId"
export type GetServiceManifestParams = {
packageId: PackageId
callback?: CallbackId
}

View File

@@ -4,6 +4,7 @@ export type NamedHealthCheckResult = { name: string } & (
| { result: "success"; message: string | null }
| { result: "disabled"; message: string | null }
| { result: "starting"; message: string | null }
| { result: "waiting"; message: string | null }
| { result: "loading"; message: string }
| { result: "failure"; message: string }
)

View File

@@ -4,7 +4,7 @@ import type { PackageVersionInfo } from "./PackageVersionInfo"
import type { Version } from "./Version"
export type PackageInfo = {
authorized: Array<Guid>
authorized: { [key: Guid]: string }
versions: { [key: Version]: PackageVersionInfo }
categories: string[]
}

View File

@@ -2,4 +2,4 @@
import type { Guid } from "./Guid"
import type { PackageId } from "./PackageId"
export type PackageSignerParams = { id: PackageId; signer: Guid }
export type RemovePackageSignerParams = { id: PackageId; signer: Guid }

View File

@@ -5,6 +5,7 @@ export type SetHealth = { id: HealthCheckId; name: string } & (
| { result: "success"; message: string | null }
| { result: "disabled"; message: string | null }
| { result: "starting"; message: string | null }
| { result: "waiting"; message: string | null }
| { result: "loading"; message: string }
| { result: "failure"; message: string }
)

View File

@@ -14,6 +14,7 @@ export { AddAdminParams } from "./AddAdminParams"
export { AddAssetParams } from "./AddAssetParams"
export { AddCategoryParams } from "./AddCategoryParams"
export { AddPackageParams } from "./AddPackageParams"
export { AddPackageSignerParams } from "./AddPackageSignerParams"
export { AddPackageToCategoryParams } from "./AddPackageToCategoryParams"
export { AddressInfo } from "./AddressInfo"
export { AddSslOptions } from "./AddSslOptions"
@@ -90,6 +91,7 @@ export { GetPackageParams } from "./GetPackageParams"
export { GetPackageResponseFull } from "./GetPackageResponseFull"
export { GetPackageResponse } from "./GetPackageResponse"
export { GetServiceInterfaceParams } from "./GetServiceInterfaceParams"
export { GetServiceManifestParams } from "./GetServiceManifestParams"
export { GetServicePortForwardParams } from "./GetServicePortForwardParams"
export { GetSslCertificateParams } from "./GetSslCertificateParams"
export { GetSslKeyParams } from "./GetSslKeyParams"
@@ -154,7 +156,6 @@ export { PackageId } from "./PackageId"
export { PackageIndex } from "./PackageIndex"
export { PackageInfoShort } from "./PackageInfoShort"
export { PackageInfo } from "./PackageInfo"
export { PackageSignerParams } from "./PackageSignerParams"
export { PackageState } from "./PackageState"
export { PackageVersionInfo } from "./PackageVersionInfo"
export { PasswordType } from "./PasswordType"
@@ -172,6 +173,7 @@ export { RemoveAssetParams } from "./RemoveAssetParams"
export { RemoveCategoryParams } from "./RemoveCategoryParams"
export { RemovePackageFromCategoryParams } from "./RemovePackageFromCategoryParams"
export { RemovePackageParams } from "./RemovePackageParams"
export { RemovePackageSignerParams } from "./RemovePackageSignerParams"
export { RemoveTunnelParams } from "./RemoveTunnelParams"
export { RemoveVersionParams } from "./RemoveVersionParams"
export { ReplayId } from "./ReplayId"

View File

@@ -13,6 +13,7 @@ import {
RunActionParams,
SetDataVersionParams,
SetMainStatus,
GetServiceManifestParams,
} from ".././osBindings"
import { CreateSubcontainerFsParams } from ".././osBindings"
import { DestroySubcontainerFsParams } from ".././osBindings"
@@ -64,7 +65,6 @@ describe("startosTypeValidation ", () => {
destroyFs: {} as DestroySubcontainerFsParams,
},
clearBindings: {} as ClearBindingsParams,
getInstalledPackages: undefined,
bind: {} as BindParams,
getHostInfo: {} as WithCallback<GetHostInfoParams>,
restart: undefined,
@@ -76,6 +76,8 @@ describe("startosTypeValidation ", () => {
getSslKey: {} as GetSslKeyParams,
getServiceInterface: {} as WithCallback<GetServiceInterfaceParams>,
setDependencies: {} as SetDependenciesParams,
getInstalledPackages: undefined,
getServiceManifest: {} as WithCallback<GetServiceManifestParams>,
getSystemSmtp: {} as WithCallback<GetSystemSmtpParams>,
getContainerIp: {} as WithCallback<GetContainerIpParams>,
getOsIp: undefined,

View File

@@ -153,10 +153,21 @@ export type SDKManifest = {
// this is hacky but idk a more elegant way
type ArchOptions = {
0: ["x86_64", "aarch64"]
1: ["aarch64", "x86_64"]
2: ["x86_64"]
3: ["aarch64"]
0: ["x86_64", "aarch64", "riscv64"]
1: ["aarch64", "x86_64", "riscv64"]
2: ["x86_64", "riscv64", "aarch64"]
3: ["aarch64", "riscv64", "x86_64"]
4: ["riscv64", "x86_64", "aarch64"]
5: ["riscv64", "aarch64", "x86_64"]
6: ["x86_64", "aarch64"]
7: ["aarch64", "x86_64"]
8: ["x86_64", "riscv64"]
9: ["aarch64", "riscv64"]
10: ["riscv64", "aarch64"]
11: ["riscv64", "x86_64"]
12: ["x86_64"]
13: ["aarch64"]
14: ["riscv64"]
}
export type SDKImageInputSpec = {
[A in keyof ArchOptions]: {

View File

@@ -39,6 +39,7 @@ export class GetSystemSmtp {
})
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
@@ -46,7 +47,7 @@ export class GetSystemSmtp {
*/
watch(
abort?: AbortSignal,
): AsyncGenerator<T.SmtpValue | null, void, unknown> {
): AsyncGenerator<T.SmtpValue | null, never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -5,6 +5,7 @@ import { Effects } from "../Effects"
import { DropGenerator, DropPromise } from "./Drop"
import { IpAddress, IPV6_LINK_LOCAL } from "./ip"
import { deepEqual } from "./deepEqual"
import { once } from "./once"
export type UrlString = string
export type HostId = string
@@ -255,6 +256,21 @@ export const filledAddress = (
function filledAddressFromHostnames<F extends Filter>(
hostnames: HostnameInfo[],
): Filled<F> & AddressInfo {
const getNonLocal = once(() =>
filledAddressFromHostnames<typeof nonLocalFilter & F>(
filterRec(hostnames, nonLocalFilter, false),
),
)
const getPublic = once(() =>
filledAddressFromHostnames<typeof publicFilter & F>(
filterRec(hostnames, publicFilter, false),
),
)
const getOnion = once(() =>
filledAddressFromHostnames<typeof onionFilter & F>(
filterRec(hostnames, onionFilter, false),
),
)
return {
...addressInfo,
hostnames,
@@ -273,19 +289,13 @@ export const filledAddress = (
)
},
get nonLocal(): Filled<typeof nonLocalFilter & F> {
return filledAddressFromHostnames<typeof nonLocalFilter & F>(
filterRec(hostnames, nonLocalFilter, false),
)
return getNonLocal()
},
get public(): Filled<typeof publicFilter & F> {
return filledAddressFromHostnames<typeof publicFilter & F>(
filterRec(hostnames, publicFilter, false),
)
return getPublic()
},
get onion(): Filled<typeof onionFilter & F> {
return filledAddressFromHostnames<typeof onionFilter & F>(
filterRec(hostnames, onionFilter, false),
)
return getOnion()
},
}
}
@@ -393,12 +403,13 @@ export class GetServiceInterface<Mapped = ServiceInterfaceFilled | null> {
}
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
* Watches the requested service interface. Returns an async iterator that yields whenever the value changes
*/
watch(abort?: AbortSignal): AsyncGenerator<Mapped, void, unknown> {
watch(abort?: AbortSignal): AsyncGenerator<Mapped, never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -2,11 +2,7 @@ import { Effects } from "../Effects"
import { PackageId } from "../osBindings"
import { deepEqual } from "./deepEqual"
import { DropGenerator, DropPromise } from "./Drop"
import {
ServiceInterfaceFilled,
filledAddress,
getHostname,
} from "./getServiceInterface"
import { ServiceInterfaceFilled, filledAddress } from "./getServiceInterface"
const makeManyInterfaceFilled = async ({
effects,
@@ -106,12 +102,13 @@ export class GetServiceInterfaces<Mapped = ServiceInterfaceFilled[]> {
}
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
* Watches the service interfaces for the package. Returns an async iterator that yields whenever the value changes
*/
watch(abort?: AbortSignal): AsyncGenerator<Mapped, void, unknown> {
watch(abort?: AbortSignal): AsyncGenerator<Mapped, never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -4,7 +4,11 @@ export { getDefaultString } from "./getDefaultString"
export * from "./ip"
/// Not being used, but known to be browser compatible
export { GetServiceInterface, getServiceInterface } from "./getServiceInterface"
export {
GetServiceInterface,
getServiceInterface,
filledAddress,
} from "./getServiceInterface"
export { getServiceInterfaces } from "./getServiceInterfaces"
export { once } from "./once"
export { asError } from "./asError"

View File

@@ -46,7 +46,7 @@ import {
CheckDependencies,
checkDependencies,
} from "../../base/lib/dependencies/dependencies"
import { GetSslCertificate } from "./util"
import { GetSslCertificate, getServiceManifest } from "./util"
import { getDataVersion, setDataVersion } from "./version"
import { MaybeFn } from "../../base/lib/actions/setupActions"
import { GetInput } from "../../base/lib/actions/setupActions"
@@ -65,8 +65,9 @@ import {
ServiceInterfaceFilled,
} from "../../base/lib/util/getServiceInterface"
import { getOwnServiceInterfaces } from "../../base/lib/util/getServiceInterfaces"
import { Volumes, createVolumes } from "./util/Volume"
export const OSVersion = testTypeVersion("0.4.0-alpha.16")
export const OSVersion = testTypeVersion("0.4.0-alpha.17")
// prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> =
@@ -106,6 +107,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
| "getContainerIp"
| "getDataVersion"
| "setDataVersion"
| "getServiceManifest"
// prettier-ignore
type StartSdkEffectWrapper = {
@@ -132,6 +134,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
return {
manifest: this.manifest,
volumes: createVolumes(this.manifest),
...startSdkEffectWrapper,
setDataVersion,
getDataVersion,
@@ -430,20 +433,21 @@ export class StartSdk<Manifest extends T.SDKManifest> {
query: Record<string, string>
/** (optional) overrides the protocol prefix provided by the bind function.
*
* @example `ftp://`
* @example `{ ssl: 'ftps', noSsl: 'ftp' }`
*/
schemeOverride: { ssl: Scheme; noSsl: Scheme } | null
/** TODO Aiden how would someone include a password in the URL? Whether or not to mask the URLs on the screen, for example, when they contain a password */
/** mask the url (recommended if it contains credentials such as an API key or password) */
masked: boolean
},
) => new ServiceInterfaceBuilder({ ...options, effects }),
getSystemSmtp: <E extends Effects>(effects: E) =>
new GetSystemSmtp(effects),
getSslCerificate: <E extends Effects>(
getSslCertificate: <E extends Effects>(
effects: E,
hostnames: string[],
algorithm?: T.Algorithm,
) => new GetSslCertificate(effects, hostnames, algorithm),
getServiceManifest,
healthCheck: {
checkPortListening,
checkWebUrl,

View File

@@ -14,10 +14,7 @@ export { CommandController } from "./CommandController"
import { EXIT_SUCCESS, HealthDaemon } from "./HealthDaemon"
import { Daemon } from "./Daemon"
import { CommandController } from "./CommandController"
import { HealthCheck } from "../health/HealthCheck"
import { Oneshot } from "./Oneshot"
import { Manifest } from "../test/output.sdk"
import { asError } from "../util"
export const cpExec = promisify(CP.exec)
export const cpExecFile = promisify(CP.execFile)
@@ -432,7 +429,7 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
async build() {
for (const daemon of this.healthDaemons) {
await daemon.init()
await daemon.updateStatus()
}
return this
}

View File

@@ -3,10 +3,6 @@ import { defaultTrigger } from "../trigger/defaultTrigger"
import { Ready } from "./Daemons"
import { Daemon } from "./Daemon"
import { SetHealth, Effects, SDKManifest } from "../../../base/lib/types"
import { DEFAULT_SIGTERM_TIMEOUT } from "."
import { asError } from "../../../base/lib/util/asError"
import { Oneshot } from "./Oneshot"
import { SubContainer } from "../util/SubContainer"
const oncePromise = <T>() => {
let resolve: (value: T) => void
@@ -26,7 +22,7 @@ export const EXIT_SUCCESS = "EXIT_SUCCESS" as const
*
*/
export class HealthDaemon<Manifest extends SDKManifest> {
private _health: HealthCheckResult = { result: "starting", message: null }
private _health: HealthCheckResult = { result: "waiting", message: null }
private healthWatchers: Array<() => unknown> = []
private running = false
private started?: number
@@ -82,20 +78,18 @@ export class HealthDaemon<Manifest extends SDKManifest> {
if (newStatus) {
console.debug(`Launching ${this.id}...`)
this.setupHealthCheck()
;(await this.daemon)?.start()
this.daemon?.start()
this.started = performance.now()
} else {
console.debug(`Stopping ${this.id}...`)
;(await this.daemon)?.term()
this.turnOffHealthCheck()
this.setHealth({ result: "starting", message: null })
this.daemon?.term()
await this.turnOffHealthCheck()
}
}
private healthCheckCleanup: (() => null) | null = null
private turnOffHealthCheck() {
this.healthCheckCleanup?.()
private healthCheckCleanup: (() => Promise<null>) | null = null
private async turnOffHealthCheck() {
await this.healthCheckCleanup?.()
this.resolvedReady = false
this.readyPromise = new Promise(
@@ -107,8 +101,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
)
}
private async setupHealthCheck() {
const daemon = await this.daemon
daemon?.onExit((success) => {
this.daemon?.onExit((success) => {
if (success && this.ready === "EXIT_SUCCESS") {
this.setHealth({ result: "success", message: null })
} else if (!success) {
@@ -116,7 +109,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
result: "failure",
message: `${this.id} daemon crashed`,
})
} else if (!daemon.isOneshot()) {
} else if (!this.daemon?.isOneshot()) {
this.setHealth({
result: "failure",
message: `${this.id} daemon exited`,
@@ -132,6 +125,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
const { promise: status, resolve: setStatus } = oncePromise<{
done: true
}>()
const { promise: exited, resolve: setExited } = oncePromise<null>()
new Promise(async () => {
if (this.ready === "EXIT_SUCCESS") return
for (
@@ -150,10 +144,12 @@ export class HealthDaemon<Manifest extends SDKManifest> {
await this.setHealth(response)
}
setExited(null)
}).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`))
this.healthCheckCleanup = () => {
this.healthCheckCleanup = async () => {
setStatus({ done: true })
await exited
this.healthCheckCleanup = null
return null
}
@@ -201,6 +197,7 @@ export class HealthDaemon<Manifest extends SDKManifest> {
const healths = this.dependencies.map((d) => ({
health: d.running && d._health,
id: d.id,
display: typeof d.ready === "object" ? d.ready.display : null,
}))
const waitingOn = healths.filter(
(h) => !h.health || h.health.result !== "success",
@@ -209,18 +206,15 @@ export class HealthDaemon<Manifest extends SDKManifest> {
console.debug(
`daemon ${this.id} waiting on ${waitingOn.map((w) => w.id)}`,
)
this.changeRunning(!waitingOn.length)
}
async init() {
if (this.ready !== "EXIT_SUCCESS" && this.ready.display) {
this.effects.setHealth({
id: this.id,
message: null,
name: this.ready.display,
result: "starting",
})
if (waitingOn.length) {
const waitingOnNames = waitingOn.flatMap((w) =>
w.display ? [w.display] : [],
)
const message = waitingOnNames.length ? waitingOnNames.join(", ") : null
await this.setHealth({ result: "waiting", message })
} else {
await this.setHealth({ result: "starting", message: null })
}
await this.updateStatus()
await this.changeRunning(!waitingOn.length)
}
}

View File

@@ -0,0 +1,152 @@
import { Effects } from "../../../base/lib/Effects"
import { Manifest, PackageId } from "../../../base/lib/osBindings"
import { DropGenerator, DropPromise } from "../../../base/lib/util/Drop"
import { deepEqual } from "../../../base/lib/util/deepEqual"
export class GetServiceManifest<Mapped = Manifest> {
constructor(
readonly effects: Effects,
readonly packageId: PackageId,
readonly map: (manifest: Manifest | null) => Mapped,
readonly eq: (a: Mapped, b: Mapped) => boolean,
) {}
/**
* Returns the manifest of a service. Reruns the context from which it has been called if the underlying value changes
*/
async const() {
let abort = new AbortController()
const watch = this.watch(abort.signal)
const res = await watch.next()
if (this.effects.constRetry) {
watch.next().then(() => {
abort.abort()
this.effects.constRetry && this.effects.constRetry()
})
}
return res.value
}
/**
* Returns the manifest of a service. Does nothing if it changes
*/
async once() {
const manifest = await this.effects.getServiceManifest({
packageId: this.packageId,
})
return this.map(manifest)
}
private async *watchGen(abort?: AbortSignal) {
let prev = null as { value: Mapped } | null
const resolveCell = { resolve: () => {} }
this.effects.onLeaveContext(() => {
resolveCell.resolve()
})
abort?.addEventListener("abort", () => resolveCell.resolve())
while (this.effects.isInContext && !abort?.aborted) {
let callback: () => void = () => {}
const waitForNext = new Promise<void>((resolve) => {
callback = resolve
resolveCell.resolve = resolve
})
const next = this.map(
await this.effects.getServiceManifest({
packageId: this.packageId,
callback: () => callback(),
}),
)
if (!prev || !this.eq(prev.value, next)) {
prev = { value: next }
yield next
}
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
* Watches the manifest of a service. Returns an async iterator that yields whenever the value changes
*/
watch(abort?: AbortSignal): AsyncGenerator<Mapped, never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())
}
/**
* Watches the manifest of a service. Takes a custom callback function to run whenever it changes
*/
onChange(
callback: (
value: Mapped | null,
error?: Error,
) => { cancel: boolean } | Promise<{ cancel: boolean }>,
) {
;(async () => {
const ctrl = new AbortController()
for await (const value of this.watch(ctrl.signal)) {
try {
const res = await callback(value)
if (res.cancel) {
ctrl.abort()
break
}
} catch (e) {
console.error(
"callback function threw an error @ GetServiceManifest.onChange",
e,
)
}
}
})()
.catch((e) => callback(null, e))
.catch((e) =>
console.error(
"callback function threw an error @ GetServiceManifest.onChange",
e,
),
)
}
/**
* Watches the manifest of a service. Returns when the predicate is true
*/
waitFor(pred: (value: Mapped) => boolean): Promise<Mapped> {
const ctrl = new AbortController()
return DropPromise.of(
Promise.resolve().then(async () => {
for await (const next of this.watchGen(ctrl.signal)) {
if (pred(next)) {
return next
}
}
throw new Error("context left before predicate passed")
}),
() => ctrl.abort(),
)
}
}
export function getServiceManifest(
effects: Effects,
packageId: PackageId,
): GetServiceManifest<Manifest>
export function getServiceManifest<Mapped>(
effects: Effects,
packageId: PackageId,
map: (manifest: Manifest | null) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceManifest<Mapped>
export function getServiceManifest<Mapped>(
effects: Effects,
packageId: PackageId,
map?: (manifest: Manifest | null) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceManifest<Mapped> {
return new GetServiceManifest(
effects,
packageId,
map ?? ((a) => a as Mapped),
eq ?? ((a, b) => deepEqual(a, b)),
)
}

View File

@@ -50,6 +50,7 @@ export class GetSslCertificate {
})
await waitForNext
}
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
/**
@@ -57,7 +58,7 @@ export class GetSslCertificate {
*/
watch(
abort?: AbortSignal,
): AsyncGenerator<[string, string, string], void, unknown> {
): AsyncGenerator<[string, string, string], never, unknown> {
const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())

View File

@@ -7,6 +7,7 @@ import { once } from "../../../base/lib/util/once"
import { Drop } from "../../../base/lib/util/Drop"
import { Mounts } from "../mainFn/Mounts"
import { BackupEffects } from "../backup/Backups"
import { PathBase } from "./Volume"
export const execFile = promisify(cp.execFile)
const False = () => false
@@ -71,10 +72,18 @@ async function bind(
export interface SubContainer<
Manifest extends T.SDKManifest,
Effects extends T.Effects = T.Effects,
> extends Drop {
> extends Drop,
PathBase {
readonly imageId: keyof Manifest["images"] & T.ImageId
readonly rootfs: string
readonly guid: T.Guid
/**
* Get the absolute path to a file or directory within this subcontainer's rootfs
* @param path Path relative to the rootfs
*/
subpath(path: string): string
mount(
mounts: Effects extends BackupEffects
? Mounts<
@@ -137,6 +146,22 @@ export interface SubContainer<
options?: CommandOptions & StdioOptions,
): Promise<cp.ChildProcess>
/**
* @description Write a file to the subcontainer's filesystem
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
writeFile(
path: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void>
rc(): SubContainerRc<Manifest, Effects>
isOwned(): this is SubContainerOwned<Manifest, Effects>
@@ -291,6 +316,12 @@ export class SubContainerOwned<
}
}
subpath(path: string): string {
return path.startsWith("/")
? `${this.rootfs}${path}`
: `${this.rootfs}/${path}`
}
async mount(
mounts: Effects extends BackupEffects
? Mounts<
@@ -618,6 +649,27 @@ export class SubContainerOwned<
)
}
/**
* @description Write a file to the subcontainer's filesystem
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
async writeFile(
path: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void> {
const fullPath = this.subpath(path)
const dir = fullPath.replace(/\/[^/]*\/?$/, "")
await fs.mkdir(dir, { recursive: true })
return fs.writeFile(fullPath, data, options)
}
rc(): SubContainerRc<Manifest, Effects> {
return new SubContainerRc(this)
}
@@ -643,6 +695,9 @@ export class SubContainerRc<
get guid() {
return this.subcontainer.guid
}
subpath(path: string): string {
return this.subcontainer.subpath(path)
}
private destroyed = false
private destroying: Promise<null> | null = null
public constructor(
@@ -800,6 +855,24 @@ export class SubContainerRc<
return this.subcontainer.spawn(command, options)
}
/**
* @description Write a file to the subcontainer's filesystem
* @param path Path relative to the subcontainer rootfs (e.g. "/etc/config.json")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
async writeFile(
path: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void> {
return this.subcontainer.writeFile(path, data, options)
}
rc(): SubContainerRc<Manifest, Effects> {
return this.subcontainer.rc()
}

View File

@@ -0,0 +1,88 @@
import * as fs from "node:fs/promises"
import * as T from "../../../base/lib/types"
/**
* Common interface for objects that have a subpath method (Volume, SubContainer, etc.)
*/
export interface PathBase {
subpath(path: string): string
}
/**
* @description Represents a volume in the StartOS filesystem.
* Provides utilities for reading and writing files within the volume.
*/
export class Volume<Id extends string = string> implements PathBase {
/**
* The absolute path to this volume's root directory
*/
readonly path: string
constructor(readonly id: Id) {
this.path = `/media/startos/volumes/${id}`
}
/**
* Get the absolute path to a file or directory within this volume
* @param subpath Path relative to the volume root
*/
subpath(subpath: string): string {
return subpath.startsWith("/")
? `${this.path}${subpath}`
: `${this.path}/${subpath}`
}
/**
* @description Read a file from this volume
* @param subpath Path relative to the volume root (e.g. "config.json" or "/data/file.txt")
* @param options Optional read options (same as node:fs/promises readFile)
*/
async readFile(
subpath: string,
options?: Parameters<typeof fs.readFile>[1],
): Promise<Buffer | string> {
const fullPath = this.subpath(subpath)
return fs.readFile(fullPath, options)
}
/**
* @description Write a file to this volume
* @param subpath Path relative to the volume root (e.g. "config.json" or "/data/file.txt")
* @param data The data to write
* @param options Optional write options (same as node:fs/promises writeFile)
*/
async writeFile(
subpath: string,
data:
| string
| NodeJS.ArrayBufferView
| Iterable<string | NodeJS.ArrayBufferView>
| AsyncIterable<string | NodeJS.ArrayBufferView>,
options?: Parameters<typeof fs.writeFile>[2],
): Promise<void> {
const fullPath = this.subpath(subpath)
const dir = fullPath.replace(/\/[^/]*\/?$/, "")
await fs.mkdir(dir, { recursive: true })
return fs.writeFile(fullPath, data, options)
}
}
/**
* Type-safe volumes object that provides Volume instances for each volume defined in the manifest
*/
export type Volumes<Manifest extends T.SDKManifest> = {
[K in Manifest["volumes"][number]]: Volume<K>
}
/**
* Creates a type-safe volumes object from a manifest
*/
export function createVolumes<Manifest extends T.SDKManifest>(
manifest: Manifest,
): Volumes<Manifest> {
const volumes = {} as Volumes<Manifest>
for (const volumeId of manifest.volumes) {
;(volumes as any)[volumeId] = new Volume(volumeId)
}
return volumes
}

View File

@@ -6,6 +6,7 @@ import * as T from "../../../base/lib/types"
import * as fs from "node:fs/promises"
import { asError, deepEqual } from "../../../base/lib/util"
import { DropGenerator, DropPromise } from "../../../base/lib/util/Drop"
import { PathBase } from "./Volume"
const previousPath = /(.+?)\/([^/]*)$/
@@ -88,11 +89,12 @@ export type Transformers<Raw = unknown, Transformed = unknown> = {
onWrite: (value: Transformed) => Raw
}
type ToPath = string | { volumeId: T.VolumeId; subpath: string }
type ToPath = string | { base: PathBase; subpath: string }
function toPath(path: ToPath): string {
return typeof path === "string"
? path
: `/media/startos/volumes/${path.volumeId}/${path.subpath}`
if (typeof path === "string") {
return path
}
return path.base.subpath(path.subpath)
}
type Validator<T, U> = matches.Validator<T, U> | matches.Validator<unknown, U>
@@ -103,7 +105,7 @@ type ReadType<A> = {
watch: (
effects: T.Effects,
abort?: AbortSignal,
) => AsyncGenerator<A | null, null, unknown>
) => AsyncGenerator<A | null, never, unknown>
onChange: (
effects: T.Effects,
callback: (
@@ -270,7 +272,7 @@ export class FileHelper<A> {
await onCreated(this.path).catch((e) => console.error(asError(e)))
}
}
return null
return new Promise<never>((_, rej) => rej(new Error("aborted")))
}
private readOnChange<B>(
@@ -621,7 +623,10 @@ export class FileHelper<A> {
.split("\n")
.map((line) => line.trim())
.filter((line) => !line.startsWith("#") && line.includes("="))
.map((line) => line.split("=", 2)),
.map((line) => {
const pos = line.indexOf("=")
return [line.slice(0, pos), line.slice(pos + 1)]
}),
),
(data) => shape.unsafeCast(data),
transformers,

View File

@@ -1,4 +1,6 @@
export * from "../../../base/lib/util"
export { GetSslCertificate } from "./GetSslCertificate"
export { GetServiceManifest, getServiceManifest } from "./GetServiceManifest"
export { Drop } from "../../../base/lib/util/Drop"
export { Volume, Volumes } from "./Volume"

View File

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

View File

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

1377
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.4.0-alpha.16",
"version": "0.4.0-alpha.17",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",
@@ -49,18 +49,18 @@
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.3.0",
"@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.65.0",
"@taiga-ui/addon-commerce": "4.65.0",
"@taiga-ui/addon-mobile": "4.65.0",
"@taiga-ui/addon-table": "4.65.0",
"@taiga-ui/cdk": "4.65.0",
"@taiga-ui/core": "4.65.0",
"@taiga-ui/addon-charts": "4.66.0",
"@taiga-ui/addon-commerce": "4.66.0",
"@taiga-ui/addon-mobile": "4.66.0",
"@taiga-ui/addon-table": "4.66.0",
"@taiga-ui/cdk": "4.66.0",
"@taiga-ui/core": "4.66.0",
"@taiga-ui/dompurify": "4.1.11",
"@taiga-ui/event-plugins": "4.7.0",
"@taiga-ui/experimental": "4.65.0",
"@taiga-ui/icons": "4.65.0",
"@taiga-ui/kit": "4.65.0",
"@taiga-ui/layout": "4.65.0",
"@taiga-ui/experimental": "4.66.0",
"@taiga-ui/icons": "4.66.0",
"@taiga-ui/kit": "4.66.0",
"@taiga-ui/layout": "4.66.0",
"@taiga-ui/polymorpheus": "4.9.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",

View File

@@ -4,7 +4,8 @@
"https://registry.start9.com/": "Start9 Registry",
"https://community-registry.start9.com/": "Community Registry",
"https://beta-registry.start9.com/": "Start9 Beta Registry",
"https://community-beta-registry.start9.com/": "Community Beta Registry"
"https://community-beta-registry.start9.com/": "Community Beta Registry",
"https://alpha-registry-x.start9.com/": "Start9 Alpha Registry"
},
"startosRegistry": "https://beta-registry.start9.com/",
"snakeHighScore": 0

View File

@@ -5,7 +5,6 @@ import {
i18nPipe,
SharedPipesModule,
} from '@start9labs/shared'
import { TuiLet } from '@taiga-ui/cdk'
import {
TuiAppearance,
TuiButton,
@@ -29,7 +28,6 @@ import { MenuComponent } from './menu.component'
TuiButton,
CategoriesModule,
StoreIconComponentModule,
TuiLet,
TuiAppearance,
TuiIcon,
TuiSkeleton,

View File

@@ -2,18 +2,11 @@ import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
import { TuiLet } from '@taiga-ui/cdk'
import { ItemComponent } from './item.component'
@NgModule({
declarations: [ItemComponent],
exports: [ItemComponent],
imports: [
CommonModule,
RouterModule,
SharedPipesModule,
TickerComponent,
TuiLet,
],
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent],
})
export class ItemModule {}

View File

@@ -146,7 +146,9 @@ export default class SuccessPage implements AfterViewInit {
.getElementById('cert')
?.setAttribute(
'href',
`data:application/x-x509-ca-cert;base64,${encodeURIComponent(this.cert!)}`,
URL.createObjectURL(
new Blob([this.cert!], { type: 'application/octet-stream' }),
),
)
const html = this.documentation?.nativeElement.innerHTML || ''

View File

@@ -1,7 +1,7 @@
import { AsyncPipe } from '@angular/common'
import { Component, ElementRef, inject, input } from '@angular/core'
import { Component, ElementRef, inject } from '@angular/core'
import {
INTERSECTION_ROOT,
WA_INTERSECTION_ROOT,
WaIntersectionObserver,
} from '@ng-web-apis/intersection-observer'
import { WaMutationObserver } from '@ng-web-apis/mutation-observer'
@@ -36,12 +36,7 @@ import { SetupLogsService } from '../../services/setup-logs.service'
NgDompurifyPipe,
TuiScrollbar,
],
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
providers: [{ provide: WA_INTERSECTION_ROOT, useExisting: ElementRef }],
})
export class LogsWindowComponent {
readonly logs$ = inject(SetupLogsService)

View File

@@ -40,7 +40,9 @@ import { i18nKey } from '../i18n/i18n.providers'
class="button"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
</tui-textfield>
<footer class="g-buttons">

View File

@@ -1,25 +1,22 @@
import { Directive, inject, DOCUMENT } from '@angular/core'
import { Directive, DOCUMENT, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import {
MutationObserverService,
provideMutationObserverInit,
WaMutationObserverService,
} from '@ng-web-apis/mutation-observer'
import { tuiInjectElement } from '@taiga-ui/cdk'
@Directive({
selector: '[safeLinks]',
providers: [
MutationObserverService,
provideMutationObserverInit({
childList: true,
subtree: true,
}),
WaMutationObserverService,
provideMutationObserverInit({ childList: true, subtree: true }),
],
})
export class SafeLinksDirective {
private readonly doc = inject(DOCUMENT)
private readonly el = tuiInjectElement()
private readonly sub = inject(MutationObserverService)
private readonly sub = inject(WaMutationObserverService)
.pipe(takeUntilDestroyed())
.subscribe(() => {
Array.from(this.doc.links)

View File

@@ -90,6 +90,9 @@ export default {
90: 'Root-CA ist vertrauenswürdig!',
91: 'Installierte Dienste',
92: 'Diagnosen für den Tor-Daemon auf diesem Server',
93: 'Fingerabdruck kopieren',
94: 'Warten',
95: 'Warten auf',
96: 'Öffentliche Domain hinzufügen',
97: 'Wird entfernt',
100: 'Nicht gespeicherte Änderungen',
@@ -578,7 +581,7 @@ export default {
611: 'Keine Service-Schnittstellen',
612: 'Grund',
613: 'Private Gateways für die StartOS-Benutzeroberfläche können nicht deaktiviert werden',
614: 'CA-Fingerabdruck',
614: 'Root-CA',
615: 'DHCP-Server',
616: 'DHCP-Server können nicht bearbeitet werden',
617: 'Statisch',
@@ -592,4 +595,5 @@ export default {
625: 'Eine andere Version auswählen',
626: 'Hochladen',
627: 'UI öffnen',
628: 'In Zwischenablage kopiert',
} satisfies i18n

View File

@@ -89,6 +89,9 @@ export const ENGLISH = {
'Root CA Trusted!': 90,
'Installed services': 91, // as in, software services installed on this computer
'Diagnostics for the Tor daemon on this server': 92,
'Copy fingerprint': 93, // as in the fingerprint of a root certificate authority
'Waiting': 94,
'Waiting on': 95, // as in "awaiting"
'Add public domain': 96,
'Removing': 97,
'Unsaved changes': 100,
@@ -577,7 +580,7 @@ export const ENGLISH = {
'No service interfaces': 611, // as in, there are no available interfaces (API, UI, etc) for this software application
'Reason': 612, // as in, an explanation for something
'Cannot disable private gateways for StartOS UI': 613,
'CA fingerprint': 614, // as in, the unique, fixed-length digital identifier generated from a certificate's data using a cryptographic hash function
'Root CA': 614, // as in, the unique, fixed-length digital identifier generated from a certificate's data using a cryptographic hash function
'DHCP Servers': 615,
'Cannot edit DHCP servers': 616,
'Static': 617, // as in, unchanging
@@ -591,4 +594,5 @@ export const ENGLISH = {
'Select another version': 625,
'Upload': 626, // as in, upload a file
'Open UI': 627, // as in, upload a file
'Copied to clipboard': 628,
} as const

View File

@@ -90,6 +90,9 @@ export default {
90: '¡CA raíz confiable!',
91: 'Servicios instalados',
92: 'Diagnósticos para el demonio Tor en este servidor',
93: 'Copiar huella digital',
94: 'Esperando',
95: 'En espera de',
96: 'Agregar dominio público',
97: 'Eliminando',
100: 'Cambios no guardados',
@@ -578,7 +581,7 @@ export default {
611: 'Sin interfaces de servicio',
612: 'Razón',
613: 'No se pueden deshabilitar las puertas de enlace privadas para la interfaz de usuario de StartOS',
614: 'Huella digital de la CA',
614: 'CA raíz',
615: 'Servidores DHCP',
616: 'No se pueden editar los servidores DHCP',
617: 'Estático',
@@ -592,4 +595,5 @@ export default {
625: 'Seleccionar otra versión',
626: 'Subir',
627: 'Abrir UI',
628: 'Copiado al portapapeles',
} satisfies i18n

View File

@@ -90,6 +90,9 @@ export default {
90: 'Certificat racine approuvé !',
91: 'Services installés',
92: 'Diagnostics pour le service Tor sur ce serveur',
93: 'Copier lempreinte',
94: 'En attente',
95: 'En attente de',
96: 'Ajouter un domaine public',
97: 'Suppression',
100: 'Modifications non enregistrées',
@@ -578,7 +581,7 @@ export default {
611: 'Aucune interface de service',
612: 'Raison',
613: "Impossible de désactiver les passerelles privées pour l'interface utilisateur StartOS",
614: 'Empreinte de lAC',
614: 'CA racine',
615: 'Serveurs DHCP',
616: 'Impossible de modifier les serveurs DHCP',
617: 'Statique',
@@ -592,4 +595,5 @@ export default {
625: 'Sélectionner une autre version',
626: 'Téléverser',
627: 'Ouvrir UI',
628: 'Copié dans le presse-papiers',
} satisfies i18n

View File

@@ -90,6 +90,9 @@ export default {
90: 'Główny certyfikat CA zaufany!',
91: 'Zainstalowane usługi',
92: 'Diagnostyka demona Tor na tym serwerze',
93: 'Kopiuj odcisk palca',
94: 'Oczekiwanie',
95: 'Oczekiwanie na',
96: 'Dodaj domenę publiczną',
97: 'Usuwanie',
100: 'Niezapisane zmiany',
@@ -578,7 +581,7 @@ export default {
611: 'Brak interfejsów usług',
612: 'Powód',
613: 'Nie można wyłączyć prywatnych bram dla interfejsu użytkownika StartOS',
614: 'Odcisk palca CA',
614: 'głównego CA',
615: 'Serwery DHCP',
616: 'Nie można edytować serwerów DHCP',
617: 'Statyczny',
@@ -592,4 +595,5 @@ export default {
625: 'Wybierz inną wersję',
626: 'Prześlij',
627: 'Otwórz UI',
628: 'Skopiowano do schowka',
} satisfies i18n

View File

@@ -1,5 +1,5 @@
import { forwardRef, signal } from '@angular/core'
import { tuiCreateToken, tuiProvide } from '@taiga-ui/cdk'
import { forwardRef, InjectionToken, signal } from '@angular/core'
import { tuiProvide } from '@taiga-ui/cdk'
import {
TuiLanguageName,
tuiLanguageSwitcher,
@@ -11,12 +11,19 @@ import { i18nService } from './i18n.service'
export type i18nKey = keyof typeof ENGLISH
export type i18n = Record<(typeof ENGLISH)[i18nKey], string>
export const I18N = tuiCreateToken(signal<i18n | null>(null))
export const I18N_LOADER =
tuiCreateToken<(lang: TuiLanguageName) => Promise<i18n>>()
export const I18N_STORAGE = tuiCreateToken<
export const I18N = new InjectionToken('', {
factory: () => signal<i18n | null>(null),
})
export const I18N_LOADER = new InjectionToken<
(lang: TuiLanguageName) => Promise<i18n>
>('')
export const I18N_STORAGE = new InjectionToken<
(lang: TuiLanguageName) => Promise<void>
>(() => Promise.resolve())
>('', {
factory: () => () => Promise.resolve(),
})
export const I18N_PROVIDERS = [
tuiLanguageSwitcher(async (language: TuiLanguageName): Promise<unknown> => {

View File

@@ -50,7 +50,6 @@ export * from './tokens/relative-url'
export * from './util/base-64'
export * from './util/convert-ansi'
export * from './util/copy-to-clipboard'
export * from './util/format-progress'
export * from './util/get-new-entries'
export * from './util/get-pkg-id'

View File

@@ -1,16 +1,20 @@
import { inject, Injectable } from '@angular/core'
import { Clipboard } from '@angular/cdk/clipboard'
import { TuiAlertService } from '@taiga-ui/core'
import { copyToClipboard } from '../util/copy-to-clipboard'
import { i18nPipe } from '../i18n/i18n.pipe'
@Injectable({ providedIn: 'root' })
export class CopyService {
private readonly clipboard = inject(Clipboard)
private readonly i18n = inject(i18nPipe)
private readonly alerts = inject(TuiAlertService)
async copy(text: string) {
const success = await copyToClipboard(text)
const success = this.clipboard.copy(text)
const message = success ? 'Copied to clipboard' : 'Failed'
const appearance = success ? 'positive' : 'negative'
this.alerts
.open(success ? 'Copied to clipboard!' : 'Failed to copy to clipboard.')
.subscribe()
this.alerts.open(this.i18n.transform(message), { appearance }).subscribe()
}
}

View File

@@ -17,7 +17,9 @@ export class DownloadHTMLService {
const elem = this.document.createElement('a')
elem.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(html),
URL.createObjectURL(
new Blob([html], { type: 'application/octet-stream' }),
),
)
elem.setAttribute('download', filename)
elem.style.display = 'none'

View File

@@ -16,7 +16,6 @@ import {
HttpAngularOptions,
HttpOptions,
LocalHttpResponse,
Method,
} from '../types/http.types'
import { RPCResponse, RPCOptions } from '../types/rpc.types'
import { RELATIVE_URL } from '../tokens/relative-url'
@@ -42,7 +41,7 @@ export class HttpService {
const { method, headers, params, timeout } = opts
return this.httpRequest<RPCResponse<T>>({
method: Method.POST,
method: 'POST',
url: fullUrl || this.relativeUrl,
headers,
body: { method, params },
@@ -73,7 +72,7 @@ export class HttpService {
}
let req: Observable<LocalHttpResponse<T>>
if (method === Method.GET) {
if (method === 'GET') {
req = this.http.get(url, options as any) as any
} else {
req = this.http.post(url, body, options as any) as any

View File

@@ -1,9 +1,6 @@
import { HttpHeaders, HttpResponse } from '@angular/common/http'
export enum Method {
GET = 'GET',
POST = 'POST',
}
export type Method = 'GET' | 'POST'
type ParamPrimitive = string | number | boolean

View File

@@ -1,3 +1,12 @@
export type AccessType =
| 'tor'
| 'mdns'
| 'localhost'
| 'ipv4'
| 'ipv6'
| 'domain'
| 'wan-ipv4'
export type WorkspaceConfig = {
gitHash: string
useMocks: boolean
@@ -8,7 +17,7 @@ export type WorkspaceConfig = {
version: string
}
mocks: {
maskAs: 'tor' | 'local' | 'localhost' | 'ipv4' | 'ipv6' | 'clearnet'
maskAs: AccessType
maskAsHttps: boolean
skipStartupAlerts: boolean
}

View File

@@ -1,19 +0,0 @@
export async function copyToClipboard(str: string): Promise<boolean> {
if (window.isSecureContext) {
return navigator.clipboard
.writeText(str)
.then(() => true)
.catch(() => false)
}
const el = document.createElement('textarea')
el.value = str
el.setAttribute('readonly', '')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
const didCopy = document.execCommand('copy')
document.body.removeChild(el)
return didCopy
}

View File

@@ -12,8 +12,7 @@ import {
ValidatorFn,
Validators,
} from '@angular/forms'
import { DialogService, ErrorService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { ErrorService } from '@start9labs/shared'
import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk'
import {
TuiAlertService,

View File

@@ -10,11 +10,11 @@ export class AuthService {
private readonly storage = inject(WA_LOCAL_STORAGE)
private readonly effect = effect(() => {
if (this.authenticated()) {
this.storage.setItem(KEY, JSON.stringify(true))
this.storage?.setItem(KEY, JSON.stringify(true))
} else {
this.storage.removeItem(KEY)
this.storage?.removeItem(KEY)
}
})
readonly authenticated = signal(Boolean(this.storage.getItem(KEY)))
readonly authenticated = signal(Boolean(this.storage?.getItem(KEY)))
}

View File

@@ -16,6 +16,7 @@ import {
VERSION,
WorkspaceConfig,
} from '@start9labs/shared'
import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
import {
TUI_DATE_FORMAT,
TUI_DIALOGS_CLOSE,
@@ -30,7 +31,7 @@ import {
TUI_DATE_VALUE_TRANSFORMER,
} from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, of, pairwise } from 'rxjs'
import { filter, identity, of, pairwise } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import {
PATCH_CACHE,
@@ -133,4 +134,10 @@ export const APP_PROVIDERS = [
provide: VERSION,
useFactory: () => inject(ConfigService).version,
},
tuiObfuscateOptionsProvider({
recipes: {
mask: ({ length }) => '•'.repeat(length),
none: identity,
},
}),
]

View File

@@ -1,9 +1,9 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectorRef,
Component,
DestroyRef,
forwardRef,
HostBinding,
inject,
Input,
} from '@angular/core'
@@ -15,17 +15,13 @@ import {
} from '@angular/forms'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { IST } from '@start9labs/start-sdk'
import { TuiAnimated } from '@taiga-ui/cdk'
import {
TUI_ANIMATIONS_SPEED,
TuiButton,
TuiError,
tuiFadeIn,
tuiHeightCollapse,
TuiIcon,
TuiLink,
tuiParentStop,
TuiTextfield,
tuiToAnimationOptions,
} from '@taiga-ui/core'
import { TuiFieldErrorPipe, TuiTooltip } from '@taiga-ui/kit'
import { filter } from 'rxjs'
@@ -57,40 +53,40 @@ import { FormObjectComponent } from './object.component'
</div>
<tui-error [error]="order | tuiFieldError | async" />
@for (item of array.control.controls; track item) {
@if (spec.spec.type === 'object') {
<form-object
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache: $any(spec.spec).displayAs }}
<button
tuiIconButton
type="button"
class="remove"
iconStart="@tui.trash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt($index)"
></button>
</form-object>
} @else {
<form-control
class="control"
tuiTextfieldSize="m"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
(remove)="removeAt($index)"
/>
}
<div tuiAnimated class="control">
<div>
@if (spec.spec.type === 'object') {
<form-object
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache: $any(spec.spec).displayAs }}
<button
tuiIconButton
type="button"
class="remove"
iconStart="@tui.trash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt($index)"
></button>
</form-object>
} @else {
<form-control
class="array"
tuiTextfieldSize="m"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
(remove)="removeAt($index)"
/>
}
</div>
</div>
}
`,
styles: `
@@ -145,11 +141,23 @@ import { FormObjectComponent } from './object.component'
}
.control {
display: block;
margin: 0.5rem 0;
display: grid;
form-control {
display: block;
margin: 0.5rem 0;
}
> * {
overflow: hidden;
}
&.tui-enter,
&.tui-leave {
animation-name: tuiFade, tuiCollapse;
}
}
`,
animations: [tuiFadeIn, tuiHeightCollapse, tuiParentStop],
hostDirectives: [ControlDirective],
imports: [
AsyncPipe,
@@ -166,14 +174,13 @@ import { FormObjectComponent } from './object.component'
MustachePipe,
FormControlComponent,
forwardRef(() => FormObjectComponent),
TuiAnimated,
],
})
export class FormArrayComponent {
@Input({ required: true })
spec!: IST.ValueSpecList
@HostBinding('@tuiParentStop')
readonly animation = tuiToAnimationOptions(inject(TUI_ANIMATIONS_SPEED))
readonly order = ERRORS
readonly array = inject(FormArrayName)
readonly open = new Map<AbstractControl, boolean>()
@@ -181,6 +188,7 @@ export class FormArrayComponent {
private warned = false
private readonly formService = inject(FormService)
private readonly destroyRef = inject(DestroyRef)
private readonly cdr = inject(ChangeDetectorRef)
private readonly dialog = inject(DialogService)
get canAdd(): boolean {
@@ -226,5 +234,6 @@ export class FormArrayComponent {
private addItem() {
this.array.control.insert(0, this.formService.getListItem(this.spec))
this.open.set(this.array.control.at(0), true)
this.cdr.markForCheck()
}
}

View File

@@ -12,10 +12,9 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { IST } from '@start9labs/start-sdk'
import { tuiPure, TuiValueChanges } from '@taiga-ui/cdk'
import { TuiValueChanges } from '@taiga-ui/cdk'
import { TuiElasticContainer } from '@taiga-ui/kit'
import { FormService } from 'src/app/services/form.service'
import { FormControlComponent } from './control.component'
import { FormGroupComponent } from './group.component'
@@ -73,7 +72,6 @@ export class FormUnionComponent implements OnChanges {
}
// OTHER?
@tuiPure
onUnion(union: string) {
this.spec.others = this.spec.others || {}
this.spec.others[this.union] = this.form.control.controls['value']?.value
@@ -84,9 +82,7 @@ export class FormUnionComponent implements OnChanges {
[],
this.spec.others[union],
),
{
emitEvent: false,
},
{ emitEvent: false },
)
}

View File

@@ -6,7 +6,6 @@ import {
TUI_LAST_DAY,
TuiDay,
TuiMapperPipe,
tuiPure,
TuiTime,
} from '@taiga-ui/cdk'
import { TuiIcon, TuiTextfield } from '@taiga-ui/core'
@@ -44,7 +43,7 @@ import { HintPipe } from '../pipes/hint.pipe'
[invalid]="control.invalid()"
[readOnly]="readOnly"
[disabled]="!!spec.disabled"
[ngModel]="getTime(value)"
[ngModel]="value | tuiMapper: getTime"
(ngModelChange)="value = $event?.toString() || null"
(blur)="control.onTouched()"
/>
@@ -128,7 +127,6 @@ export class FormDatetimeComponent extends Control<
readonly min = TUI_FIRST_DAY
readonly max = TUI_LAST_DAY
@tuiPure
getTime(value: string | null) {
return value ? TuiTime.fromString(value) : null
}

View File

@@ -4,15 +4,14 @@ import { invert } from '@start9labs/shared'
import { IST } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiIcon, TuiTextfield } from '@taiga-ui/core'
import { TuiMultiSelect, TuiTooltip } from '@taiga-ui/kit'
import { TuiChevron, TuiMultiSelect, TuiTooltip } from '@taiga-ui/kit'
import { Control } from './control'
import { HintPipe } from '../pipes/hint.pipe'
@Component({
selector: 'form-multiselect',
template: `
<tui-textfield multi [disabledItemHandler]="disabledItemHandler">
<tui-textfield multi tuiChevron [disabledItemHandler]="disabledItemHandler">
@if (spec.name) {
<label tuiLabel>{{ spec.name }}</label>
}
@@ -43,6 +42,7 @@ import { HintPipe } from '../pipes/hint.pipe'
TuiIcon,
TuiTooltip,
HintPipe,
TuiChevron,
],
})
export class FormMultiselectComponent extends Control<

View File

@@ -5,7 +5,7 @@ import { IST } from '@start9labs/start-sdk'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiDataList, TuiIcon, TuiTextfield } from '@taiga-ui/core'
import {
TuiDataListWrapper,
TuiChevron,
TuiFluidTypography,
tuiFluidTypographyOptionsProvider,
TuiSelect,
@@ -19,6 +19,7 @@ import { HintPipe } from '../pipes/hint.pipe'
selector: 'form-select',
template: `
<tui-textfield
tuiChevron
[tuiTextfieldCleaner]="false"
[disabledItemHandler]="disabledItemHandler"
(tuiActiveZoneChange)="!$event && control.onTouched()"
@@ -76,6 +77,7 @@ import { HintPipe } from '../pipes/hint.pipe'
TuiIcon,
TuiTooltip,
HintPipe,
TuiChevron,
],
})
export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { IST, utils } from '@start9labs/start-sdk'
import { tuiInjectElement } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon, TuiTextfield } from '@taiga-ui/core'
@@ -53,7 +54,9 @@ import { HintPipe } from '../pipes/hint.pipe'
size="xs"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
<button
tuiIconButton
@@ -63,6 +66,7 @@ import { HintPipe } from '../pipes/hint.pipe'
size="xs"
title="Remove"
class="remove"
(pointerdown.prevent)="(0)"
(click)="remove()"
></button>
@if (spec | hint; as hint) {
@@ -76,7 +80,7 @@ import { HintPipe } from '../pipes/hint.pipe'
order: 1;
}
:host-context(form-array > form-control > :host) .remove {
:host-context(form-control.array > :host) .remove {
display: flex;
}
`,
@@ -87,6 +91,7 @@ import { HintPipe } from '../pipes/hint.pipe'
TuiIcon,
TuiTooltip,
HintPipe,
i18nPipe,
],
})
export class FormTextComponent extends Control<IST.ValueSpecText, string> {

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { CopyService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiButton, TuiHint, TuiTitle } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@@ -35,14 +35,27 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
</div>
<div tuiCell>
<div tuiTitle>
<strong>{{ 'CA fingerprint' | i18n }}</strong>
<strong>{{ 'Root CA' | i18n }}</strong>
<div tuiSubtitle tuiFade>{{ server.caFingerprint }}</div>
</div>
<a
tuiIconButton
download
appearance="icon"
iconStart="@tui.download"
href="/static/local-root-ca.crt"
[tuiHint]="'Download' | i18n"
tuiHintDirection="bottom"
>
{{ 'Download' | i18n }}
</a>
<button
tuiIconButton
appearance="icon"
iconStart="@tui.copy"
(click)="copyService.copy(server.caFingerprint)"
[tuiHint]="'Copy fingerprint' | i18n"
tuiHintDirection="bottom"
>
{{ 'Copy' | i18n }}
</button>
@@ -65,7 +78,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
`,
styles: '[tuiCell] { padding-inline: 0; white-space: nowrap }',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiTitle, TuiButton, TuiCell, i18nPipe, TuiFade],
imports: [TuiTitle, TuiButton, TuiCell, i18nPipe, TuiFade, TuiHint],
})
export class AboutComponent {
readonly copyService = inject(CopyService)

View File

@@ -1,15 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterLink, RouterLinkActive } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import {
TUI_ANIMATIONS_SPEED,
tuiFadeIn,
TuiHint,
TuiIcon,
tuiScaleIn,
tuiToAnimationOptions,
tuiWidthCollapse,
} from '@taiga-ui/core'
import { TuiHint, TuiIcon } from '@taiga-ui/core'
import { TuiBadgedContent, TuiBadgeNotification } from '@taiga-ui/kit'
import { getMenu } from 'src/app/utils/system-utilities'
@@ -27,12 +19,7 @@ import { getMenu } from 'src/app/utils/system-utilities'
[class.link_system]="item.routerLink === 'system'"
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"
>
<tui-badged-content
[style.--tui-radius.%]="50"
[@tuiFadeIn]="animation"
[@tuiWidthCollapse]="animation"
[@tuiScaleIn]="animation"
>
<tui-badged-content [style.--tui-radius.%]="50">
@if (item.badge(); as badge) {
<tui-badge-notification tuiSlot="top" size="s">
{{ badge }}
@@ -175,7 +162,6 @@ import { getMenu } from 'src/app/utils/system-utilities'
display: none;
}
`,
animations: [tuiFadeIn, tuiWidthCollapse, tuiScaleIn],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiBadgeNotification,
@@ -188,6 +174,5 @@ import { getMenu } from 'src/app/utils/system-utilities'
],
})
export class HeaderNavigationComponent {
readonly animation = tuiToAnimationOptions(inject(TUI_ANIMATIONS_SPEED))
readonly utils = getMenu()
}

View File

@@ -3,36 +3,39 @@ import {
Component,
inject,
input,
output,
signal,
} from '@angular/core'
import {
CopyService,
DialogService,
i18nKey,
i18nPipe,
} from '@start9labs/shared'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
tuiButtonOptionsProvider,
TuiDataList,
TuiDropdown,
TuiIcon,
TuiTextfield,
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceComponent } from '../interface.component'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
selector: 'td[actions]',
template: `
<div class="desktop">
<button tuiIconButton appearance="flat-grayscale" (click)="viewDetails()">
{{ 'Address details' | i18n }}
<tui-icon class="info" icon="@tui.info" background="@tui.info-filled" />
</button>
@if (interface.value()?.type === 'ui') {
@if (interface.address().masked) {
<button
tuiIconButton
appearance="flat-grayscale"
[iconStart]="
interface.currentlyMasked() ? '@tui.eye' : '@tui.eye-off'
"
(click)="interface.currentlyMasked.set(!interface.currentlyMasked())"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
@if (interface.address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
@@ -67,15 +70,12 @@ import { InterfaceComponent } from '../interface.component'
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[tuiAppearanceState]="open() ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown="let close">
<button tuiOption new iconStart="@tui.info" (click)="viewDetails()">
{{ 'Address details' | i18n }}
</button>
@if (interface.value()?.type === 'ui') {
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
@if (interface.address().ui) {
<a
tuiOption
new
@@ -87,6 +87,18 @@ import { InterfaceComponent } from '../interface.component'
{{ 'Open' | i18n }}
</a>
}
@if (interface.address().masked) {
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
interface.currentlyMasked.set(!interface.currentlyMasked())
"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
<button tuiOption new iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
@@ -94,7 +106,7 @@ import { InterfaceComponent } from '../interface.component'
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(href()); close()"
(click)="copyService.copy(href())"
>
{{ 'Copy URL' | i18n }}
</button>
@@ -105,24 +117,12 @@ import { InterfaceComponent } from '../interface.component'
styles: `
:host {
text-align: right;
grid-area: 1 / 2 / 4 / 3;
grid-area: 1/4/4/4;
width: fit-content;
place-content: center;
white-space: nowrap;
}
:host-context(.uncommon-hidden) .desktop {
height: 0;
visibility: hidden;
}
.info {
background: var(--tui-status-info);
&::after {
mask-size: 1.5rem;
}
}
.mobile {
display: none;
}
@@ -136,15 +136,19 @@ import { InterfaceComponent } from '../interface.component'
display: block;
}
}
:host-context(tbody.uncommon-hidden) {
.desktop {
height: 0;
visibility: hidden;
}
.mobile {
display: none;
}
}
`,
imports: [
TuiButton,
TuiDropdown,
TuiDataList,
i18nPipe,
TuiTextfield,
TuiIcon,
],
imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe, TuiTextfield],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -152,27 +156,13 @@ export class AddressActionsComponent {
readonly isMobile = inject(TUI_IS_MOBILE)
readonly dialog = inject(DialogService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
readonly interface = inject(InterfaceAddressItemComponent)
readonly open = signal(false)
readonly href = input.required<string>()
readonly bullets = input.required<string[]>()
readonly disabled = input.required<boolean>()
open = false
viewDetails() {
this.dialog
.openAlert(
`<ul>${this.bullets()
.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{
label: 'About this address' as i18nKey,
},
)
.subscribe()
}
showQR() {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {

View File

@@ -15,12 +15,13 @@ import { InterfaceAddressItemComponent } from './item.component'
<header>{{ 'Addresses' | i18n }}</header>
<tui-elastic-container>
<table [appTable]="['Type', 'Access', 'Gateway', 'URL', null]">
<th [style.width.rem]="2"></th>
@for (address of addresses()?.common; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
} @empty {
@if (addresses()) {
<tr>
<td colspan="5">
<td colspan="6">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
@@ -39,7 +40,7 @@ import { InterfaceAddressItemComponent } from './item.component'
<tbody [class.uncommon-hidden]="!uncommon">
@if (addresses()?.uncommon?.length && uncommon) {
<tr [style.background]="'var(--tui-background-neutral-1)'">
<td colspan="5"></td>
<td colspan="6"></td>
</tr>
}
@for (address of addresses()?.uncommon; track $index) {
@@ -66,6 +67,20 @@ import { InterfaceAddressItemComponent } from './item.component'
</tui-elastic-container>
`,
styles: `
:host ::ng-deep {
th:nth-child(2) {
width: 5rem;
}
th:nth-child(3) {
width: 4rem;
}
th:nth-child(4) {
width: 17rem;
}
}
.g-table:has(caption) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
@@ -76,6 +91,13 @@ import { InterfaceAddressItemComponent } from './item.component'
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:host-context(tui-root._mobile) {
[tuiButton] {
border-radius: var(--tui-radius-xs);
margin-block-end: 0.75rem;
}
}
`,
host: { class: 'g-card' },
imports: [

View File

@@ -1,13 +1,38 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { DisplayAddress } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
import { TuiBadge } from '@taiga-ui/kit'
@Component({
selector: 'tr[address]',
template: `
@if (address(); as address) {
<td [style.padding-inline-end]="0">
<div class="wrapper">
<button
tuiIconButton
appearance="flat-grayscale"
(click)="viewDetails()"
>
{{ 'Address details' | i18n }}
<tui-icon
class="info"
icon="@tui.info"
background="@tui.info-filled"
/>
</button>
</div>
</td>
<td>
<div class="wrapper">{{ address.type }}</div>
</td>
@@ -26,13 +51,18 @@ import { TuiBadge } from '@taiga-ui/kit'
}
</div>
</td>
<td [style.order]="-1">
<td [style.grid-area]="'1 / 1 / 1 / 3'">
<div class="wrapper" [title]="address.gatewayName">
{{ address.gatewayName || '-' }}
</div>
</td>
<td>
<div class="wrapper" [title]="address.url">{{ address.url }}</div>
<td [style.grid-area]="'3 / 1 / 3 / 3'">
<div
class="wrapper"
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ address.url | tuiObfuscate: recipe() }}
</div>
</td>
<td
actions
@@ -46,20 +76,30 @@ import { TuiBadge } from '@taiga-ui/kit'
styles: `
:host {
white-space: nowrap;
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
td:last-child {
padding-inline-start: 0;
}
}
.info {
background: var(--tui-status-info);
&::after {
mask-size: 1.5rem;
}
}
:host-context(.uncommon-hidden) {
.wrapper {
height: 0;
visibility: hidden;
}
td {
padding-block: 0;
td,
& {
padding-block: 0 !important;
border: hidden;
}
}
@@ -76,23 +116,50 @@ import { TuiBadge } from '@taiga-ui/kit'
:host-context(tui-root._mobile) {
td {
width: auto !important;
align-content: center;
}
td:first-child {
display: none;
grid-area: 1 / 3 / 4 / 3;
}
td:nth-child(2) {
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-primary);
padding-inline-end: 0.5rem;
}
}
`,
imports: [i18nPipe, AddressActionsComponent, TuiBadge],
imports: [
i18nPipe,
AddressActionsComponent,
TuiBadge,
TuiObfuscatePipe,
TuiButton,
TuiIcon,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
private readonly dialogs = inject(DialogService)
readonly address = input.required<DisplayAddress>()
readonly isRunning = input.required<boolean>()
readonly currentlyMasked = signal(true)
readonly recipe = computed(() =>
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
)
viewDetails() {
this.dialogs
.openAlert(
`<ul>${this.address()
.bullets.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{ label: 'About this address' as i18nKey },
)
.subscribe()
}
}

View File

@@ -12,7 +12,10 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
template: `
<div>
<section [gateways]="value()?.gateways"></section>
<section [publicDomains]="value()?.publicDomains"></section>
<section
[publicDomains]="value()?.publicDomains"
[addSsl]="value()?.addSsl || false"
></section>
<section [torDomains]="value()?.torDomains"></section>
<section [privateDomains]="value()?.privateDomains"></section>
</div>

View File

@@ -10,6 +10,8 @@ type AddressWithInfo = {
info: T.HostnameInfo
gateway?: GatewayPlus
showSsl: boolean
masked: boolean
ui: boolean
}
function cmpWithRankedPredicates<T extends AddressWithInfo>(
@@ -130,6 +132,9 @@ export class InterfaceService {
if (!hostnamesInfos.length) return addresses
const masked = serviceInterface.masked
const ui = serviceInterface.type === 'ui'
const allAddressesWithInfo: AddressWithInfo[] = hostnamesInfos.flatMap(
h => {
const { url, sslUrl } = utils.addressHostToUrl(
@@ -143,7 +148,14 @@ export class InterfaceService {
: undefined
const res = []
if (url) {
res.push({ url, info, gateway, showSsl: false })
res.push({
url,
info,
gateway,
showSsl: false,
masked,
ui,
})
}
if (sslUrl) {
res.push({
@@ -151,6 +163,8 @@ export class InterfaceService {
info,
gateway,
showSsl: !!url,
masked,
ui,
})
}
return res
@@ -192,119 +206,67 @@ export class InterfaceService {
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(ui: T.ServiceInterface, host: T.Host): string {
const hostnameInfos = this.hostnameInfo(ui, host)
const addresses = utils.filledAddress(host, ui.addressInfo)
if (!hostnameInfos.length) return ''
if (!addresses.hostnames.length) return ''
const addressInfo = ui.addressInfo
const username = addressInfo.username ? addressInfo.username + '@' : ''
const suffix = addressInfo.suffix || ''
const url = new URL(`https://${username}placeholder${suffix}`)
const use = (hostname: {
value: string
port: number | null
sslPort: number | null
}) => {
url.hostname = hostname.value
const useSsl =
hostname.port && hostname.sslPort
? this.config.isHttps()
: !!hostname.sslPort
url.protocol = useSsl
? `${addressInfo.sslScheme || 'https'}:`
: `${addressInfo.scheme || 'http'}:`
const port = useSsl ? hostname.sslPort : hostname.port
const omitPort = useSsl
? ui.addressInfo.sslScheme === 'https' && port === 443
: ui.addressInfo.scheme === 'http' && port === 80
if (!omitPort && port) url.port = String(port)
}
const useFirst = (
hostnames: (
| {
value: string
port: number | null
sslPort: number | null
}
| undefined
)[],
) => {
const first = hostnames.find(h => h)
if (first) {
use(first)
}
return !!first
const publicDomains = addresses.filter({
kind: 'domain',
visibility: 'public',
})
const tor = addresses.filter({ kind: 'onion' })
const wanIp = addresses.filter({ kind: 'ipv4', visibility: 'public' })
const bestPublic = [publicDomains, tor, wanIp].flatMap(h =>
h.format('urlstring'),
)[0]
const privateDomains = addresses.filter({
kind: 'domain',
visibility: 'private',
})
const mdns = addresses.filter({ kind: 'mdns' })
const bestPrivate = [privateDomains, mdns].flatMap(h =>
h.format('urlstring'),
)[0]
let matching
let onLan = false
switch (this.config.accessType) {
case 'ipv4':
matching = addresses.nonLocal
.filter({
kind: 'ipv4',
predicate: h => h.hostname.value === this.config.hostname,
})
.format('urlstring')[0]
onLan = true
break
case 'ipv6':
matching = addresses.nonLocal
.filter({
kind: 'ipv6',
predicate: h => h.hostname.value === this.config.hostname,
})
.format('urlstring')[0]
break
case 'localhost':
matching = addresses
.filter({ kind: 'localhost' })
.format('urlstring')[0]
onLan = true
break
case 'tor':
matching = tor.format('urlstring')[0]
break
case 'mdns':
matching = mdns.format('urlstring')[0]
onLan = true
break
}
const ipHostnames = hostnameInfos
.filter(h => h.kind === 'ip')
.map(h => h.hostname) as T.IpHostname[]
const domainHostname = ipHostnames
.filter(h => h.kind === 'domain')
.map(h => h as T.IpHostname & { kind: 'domain' })
.map(h => ({
value: h.value,
sslPort: h.sslPort,
port: h.port,
}))[0]
const wanIpHostname = hostnameInfos
.filter(h => h.kind === 'ip' && h.public && h.hostname.kind !== 'domain')
.map(h => h.hostname as Exclude<T.IpHostname, { kind: 'domain' }>)
.map(h => ({
value: h.value,
sslPort: h.sslPort,
port: h.port,
}))[0]
const onionHostname = hostnameInfos
.filter(h => h.kind === 'onion')
.map(h => h as T.HostnameInfo & { kind: 'onion' })
.map(h => ({
value: h.hostname.value,
sslPort: h.hostname.sslPort,
port: h.hostname.port,
}))[0]
const localHostname = ipHostnames
.filter(h => h.kind === 'local')
.map(h => h as T.IpHostname & { kind: 'local' })
.map(h => ({ value: h.value, sslPort: h.sslPort, port: h.port }))[0]
if (this.config.isClearnet()) {
if (
!useFirst([domainHostname, wanIpHostname, onionHostname, localHostname])
) {
return ''
}
} else if (this.config.isTor()) {
if (
!useFirst([onionHostname, domainHostname, wanIpHostname, localHostname])
) {
return ''
}
} else if (this.config.isIpv6()) {
const ipv6Hostname = ipHostnames.find(h => h.kind === 'ipv6') as {
kind: 'ipv6'
value: string
scopeId: number
port: number | null
sslPort: number | null
}
if (!useFirst([ipv6Hostname, localHostname])) {
return ''
}
} else {
// ipv4 or .local or localhost
if (!localHostname) return ''
use({
value: this.config.hostname,
port: localHostname.port,
sslPort: localHostname.sslPort,
})
}
return url.href
if (matching) return matching
if (onLan && bestPrivate) return bestPrivate
if (bestPublic) return bestPublic
return ''
}
private hostnameInfo(
@@ -316,7 +278,7 @@ export class InterfaceService {
return (
hostnameInfo?.filter(
h =>
this.config.isLocalhost() ||
this.config.accessType === 'localhost' ||
!(
h.kind === 'ip' &&
((h.hostname.kind === 'ipv6' &&
@@ -328,7 +290,7 @@ export class InterfaceService {
}
private toDisplayAddress(
{ info, url, gateway, showSsl }: AddressWithInfo,
{ info, url, gateway, showSsl, masked, ui }: AddressWithInfo,
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
@@ -509,6 +471,8 @@ export class InterfaceService {
gatewayName,
type,
bullets,
masked,
ui,
}
}
}
@@ -522,6 +486,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
common: DisplayAddress[]
uncommon: DisplayAddress[]
}
addSsl: boolean
}
export type InterfaceGateway = GatewayPlus & {
@@ -534,4 +499,6 @@ export type DisplayAddress = {
gatewayName: string | null
url: string
bullets: i18nKey[]
masked: boolean
ui: boolean
}

View File

@@ -58,6 +58,7 @@ import { InterfaceComponent } from './interface.component'
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
[disabled]="!privateDomains()"
>
{{ 'Delete' | i18n }}
</button>

View File

@@ -31,7 +31,8 @@ import { PublicDomain, PublicDomainService } from './pd.service'
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="service.add()"
(click)="service.add(addSsl())"
[disabled]="!publicDomains()"
>
{{ 'Add' | i18n }}
</button>
@@ -44,7 +45,7 @@ import { PublicDomain, PublicDomainService } from './pd.service'
} @else {
<table [appTable]="['Domain', 'Gateway', 'Certificate Authority', null]">
@for (domain of publicDomains(); track $index) {
<tr [publicDomain]="domain"></tr>
<tr [publicDomain]="domain" [addSsl]="addSsl()"></tr>
} @empty {
@for (_ of [0]; track $index) {
<tr>
@@ -79,4 +80,6 @@ export class PublicDomainsComponent {
readonly service = inject(PublicDomainService)
readonly publicDomains = input.required<readonly PublicDomain[] | undefined>()
readonly addSsl = input.required<boolean>()
}

Some files were not shown because too many files have changed in this diff Show More