mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 20:43:41 +00:00
Compare commits
5 Commits
refactor/p
...
v0.4.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ef39adad | ||
|
|
466b9217b5 | ||
|
|
c9a7f519b9 | ||
|
|
96ae532879 | ||
|
|
eda08d5b0f |
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
2
container-runtime/package-lock.json
generated
2
container-runtime/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
2
core/.gitignore
vendored
@@ -8,4 +8,4 @@ secrets.db
|
||||
.env
|
||||
.editorconfig
|
||||
proptest-regressions/**/*
|
||||
/startos/bindings/*
|
||||
/bindings/*
|
||||
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("."),
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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})")
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
53
core/src/version/v0_4_0_alpha_17.rs
Normal file
53
core/src/version/v0_4_0_alpha_17.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
9
sdk/base/lib/osBindings/AddPackageSignerParams.ts
Normal file
9
sdk/base/lib/osBindings/AddPackageSignerParams.ts
Normal 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
|
||||
}
|
||||
8
sdk/base/lib/osBindings/GetServiceManifestParams.ts
Normal file
8
sdk/base/lib/osBindings/GetServiceManifestParams.ts
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
152
sdk/package/lib/util/GetServiceManifest.ts
Normal file
152
sdk/package/lib/util/GetServiceManifest.ts
Normal 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)),
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
88
sdk/package/lib/util/Volume.ts
Normal file
88
sdk/package/lib/util/Volume.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
4
sdk/package/package-lock.json
generated
4
sdk/package/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
1377
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 l’empreinte',
|
||||
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 l’AC',
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import { InterfaceComponent } from './interface.component'
|
||||
iconStart="@tui.trash"
|
||||
appearance="action-destructive"
|
||||
(click)="remove(domain)"
|
||||
[disabled]="!privateDomains()"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user