Compare commits

..

6 Commits

Author SHA1 Message Date
gStart9
6ec2feb230 Fix docs URLs in start-tunnel installer output 2026-03-11 13:36:22 -06:00
Alex Inkin
be921b7865 chore: update packages (#3132)
* chore: update packages

* start tunnel messaging

* chore: standalone

* pbpaste instead

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
2026-03-09 09:53:47 -06:00
Matt Hill
a4bae73592 rpc/v1 for polling 2026-03-06 11:25:03 -07:00
Matt Hill
8b89f016ad task fix and keyboard fix (#3130)
* task fix and keyboard fix

* fixes for build scripts

* passthrough feature

* feat: inline domain health checks and improve address UX

- addPublicDomain returns DNS query + port check results (AddPublicDomainRes)
  so frontend skips separate API calls after adding a domain
- addPrivateDomain returns check_dns result for the gateway
- Support multiple ports per domain in validation modal (deduplicated)
- Run port checks concurrently via futures::future::join_all
- Add note to add-domain dialog showing other interfaces on same host
- Add addXForwardedHeaders to knownProtocols in SDK Host.ts
- Add plugin filter kind, pluginId filter, matchesAny, and docs to
  getServiceInterface.ts
- Add PassthroughInfo type and passthroughs field to NetworkInfo
- Pluralize "port forwarding rules" in i18n dictionaries

* feat: add shared host note to private domain dialog with i18n

* fix: scope public domain to single binding and return single port check

Accept internalPort in AddPublicDomainParams to target a specific
binding. Disable the domain on all other bindings. Return a single
CheckPortRes instead of Vec. Revert multi-port UI to singular port
display from 0f8a66b35.

* better shared hostname approach,  and improve look-feel of addresses tables

* fix starttls

* preserve usb as top efi boot option

* fix race condition in wan ip check

* sdk beta.56

* various bug, improve smtp

* multiple bugs, better outbound gateway UX

* remove non option from smtp for better package compat

* bump sdk

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
2026-03-06 00:30:06 -07:00
Aiden McClelland
3320391fcc feat: support preferred external ports besides 443 (#3117)
* docs: update preferred external port design in TODO

* docs: add user-controlled public/private and port forward mapping to design

* docs: overhaul interfaces page design with view/manage split and per-address controls

* docs: move address enable/disable to overflow menu, add SSL indicator, defer UI placement decisions

* chore: remove tor from startos core

Tor is being moved from a built-in OS feature to a service. This removes
the Arti-based Tor client, onion address management, hidden service
creation, and all related code from the core backend, frontend, and SDK.

- Delete core/src/net/tor/ module (~2060 lines)
- Remove OnionAddress, TorSecretKey, TorController from all consumers
- Remove HostnameInfo::Onion and HostAddress::Onion variants
- Remove onion CRUD RPC endpoints and tor subcommand
- Remove tor key handling from account and backup/restore
- Remove ~12 tor-related Cargo dependencies (arti-client, torut, etc.)
- Remove tor UI components, API methods, mock data, and routes
- Remove OnionHostname and tor patterns/regexes from SDK
- Add v0_4_0_alpha_20 database migration to strip onion data
- Bump version to 0.4.0-alpha.20

* chore: flatten HostnameInfo from enum to struct

HostnameInfo only had one variant (Ip) after removing Tor. Flatten it
into a plain struct with fields gateway, public, hostname. Remove all
kind === 'ip' type guards and narrowing across SDK, frontend, and
container runtime. Update DB migration to strip the kind field.

* chore: format RPCSpec.md markdown table

* docs: update TODO.md with DerivedAddressInfo design, remove completed tor task

* feat: implement preferred port allocation and per-address enable/disable

- Add AvailablePorts::try_alloc() with SSL tracking (BTreeMap<u16, bool>)
- Add DerivedAddressInfo on BindInfo with private_disabled/public_enabled/possible sets
- Add Bindings wrapper with Map impl for patchdb indexed access
- Flatten HostAddress from single-variant enum to struct
- Replace set-gateway-enabled RPC with set-address-enabled
- Remove hostname_info from Host; computed addresses now in BindInfo.addresses.possible
- Compute possible addresses inline in NetServiceData::update()
- Update DB migration, SDK types, frontend, and container-runtime

* feat: replace InterfaceFilter with ForwardRequirements, add WildcardListener, complete alpha.20 bump

- Replace DynInterfaceFilter with ForwardRequirements for per-IP forward
  precision with source-subnet iptables filtering for private forwards
- Add WildcardListener (binds [::]:port) to replace the per-gateway
  NetworkInterfaceListener/SelfContainedNetworkInterfaceListener/
  UpgradableListener infrastructure
- Update forward-port script with src_subnet and excluded_src env vars
- Remove unused filter types and listener infrastructure from gateway.rs
- Add availablePorts migration (IdPool -> BTreeMap<u16, bool>) to alpha.20
- Complete version bump to 0.4.0-alpha.20 in SDK and web

* outbound gateway support (#3120)

* Multiple (#3111)

* fix alerts i18n, fix status display, better, remove usb media, hide shutdown for install complete

* trigger chnage detection for localize pipe and round out implementing localize pipe for consistency even though not needed

* Fix PackageInfoShort to handle LocaleString on releaseNotes (#3112)

* Fix PackageInfoShort to handle LocaleString on releaseNotes

* fix: filter by target_version in get_matching_models and pass otherVersions from install

* chore: add exver documentation for ai agents

* frontend plus some be types

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>

* feat: replace SourceFilter with IpNet, add policy routing, remove MASQUERADE

* build ts types and fix i18n

* fix license display in marketplace

* wip refactor

* chore: update ts bindings for preferred port design

* feat: refactor NetService to watch DB and reconcile network state

- NetService sync task now uses PatchDB DbWatch instead of being called
  directly after DB mutations
- Read gateways from DB instead of network interface context when
  updating host addresses
- gateway sync updates all host addresses in the DB
- Add Watch<u64> channel for callers to wait on sync completion
- Fix ts-rs codegen bug with #[ts(skip)] on flattened Plugin field
- Update SDK getServiceInterface.ts for new HostnameInfo shape
- Remove unnecessary HTTPS redirect in static_server.rs
- Fix tunnel/api.rs to filter for WAN IPv4 address

* re-arrange (#3123)

* new service interfacee page

* feat: add mdns hostname metadata variant and fix vhost routing

- Add HostnameMetadata::Mdns variant to distinguish mDNS from private domains
- Mark mDNS addresses as private (public: false) since mDNS is local-only
- Fall back to null SNI entry when hostname not found in vhost mapping
- Simplify public detection in ProxyTarget filter
- Pass hostname to update_addresses for mDNS domain name generation

* looking good

* feat: add port_forwards field to Host for tracking gateway forwarding rules

* update bindings for API types, add ARCHITECTURE (#3124)

* update binding for API types, add ARCHITECTURE

* translations

* fix: add CONNMARK restore-mark to mangle OUTPUT chain

The CONNMARK --restore-mark rule was only in PREROUTING, which handles
forwarded packets. Locally-bound listeners (e.g. vhost) generate replies
through the OUTPUT chain, where the fwmark was never restored. This
caused response packets to route via the default table instead of back
through the originating interface.

* chore: reserialize db on equal version, update bindings and docs

- Run de/ser roundtrip in pre_init even when db version matches, ensuring
  all #[serde(default)] fields are populated before any typed access
- Add patchdb.md documentation for TypedDbWatch patterns
- Update TS bindings for CheckPortParams, CheckPortRes, ifconfigUrl
- Update CLAUDE.md docs with patchdb and component-level references

* fix: include public gateways for IP-based addresses in vhost targets

The server hostname vhost construction only collected private IPs,
always setting public to empty. Public IP addresses (Ipv4/Ipv6 metadata
with public=true) were never added to the vhost target's public gateway
set, causing the vhost filter to reject public traffic for IP-based
addresses.

* fix: add TLS handshake timeout and fix accept loop deadlock

Two issues in TlsListener::poll_accept:

1. No timeout on TLS handshakes: LazyConfigAcceptor waits indefinitely
   for ClientHello. Attackers that complete TCP handshake but never send
   TLS data create zombie futures in `in_progress` that never complete.
   Fix: wrap the entire handshake in tokio::time::timeout(15s).

2. Missing waker on new-connection pending path: when a TCP connection
   is accepted and the TLS handshake is pending, poll_accept returned
   Pending without calling wake_by_ref(). Since the TcpListener returned
   Ready (not Pending), no waker was registered for it. With edge-
   triggered epoll and no other wakeup source, the task sleeps forever
   and remaining connections in the kernel accept queue are never
   drained. Fix: add cx.waker().wake_by_ref() so the task immediately
   re-polls and continues draining the accept queue.

* fix: switch BackgroundJobRunner from Vec to FuturesUnordered

BackgroundJobRunner stored active jobs in a Vec<BoxFuture> and polled
ALL of them on every wakeup — O(n) per poll. Since this runs in the
same tokio::select! as the WebServer accept loop, polling overhead from
active connections directly delayed acceptance of new connections.

FuturesUnordered only polls woken futures — O(woken) instead of O(n).

* chore: update bindings and use typed params for outbound gateway API

* feat: per-service and default outbound gateway routing

Add set-outbound-gateway RPC for packages and set-default-outbound RPC
for the server, with policy routing enforcement via ip rules. Fix
connmark restore to skip packets with existing fwmarks, add bridge
subnet routes to per-interface tables, and fix squashfs path in
update-image-local.sh.

* refactor: manifest wraps PackageMetadata, move dependency_metadata to PackageVersionInfo

Manifest now embeds PackageMetadata via #[serde(flatten)] instead of
duplicating ~14 fields. icon and dependency_metadata moved from
PackageMetadata to PackageVersionInfo since they are registry-enrichment
data loaded from the S9PK archive. merge_with now returns errors on
metadata/icon/dependency_metadata mismatches instead of silently ignoring
them.

* fix: replace .status() with .invoke() for iptables/ip commands

Using .status() leaks stderr directly to system logs, causing noisy
iptables error messages. Switch all networking CLI invocations to use
.invoke() which captures stderr properly. For check-then-act patterns
(iptables -C), use .invoke().await.is_err() instead of
.status().await.map_or(false, |s| s.success()).

* feat: add check-dns gateway endpoint and fix per-interface routing tables

Add a `check-dns` RPC endpoint that verifies whether a gateway's DNS
is properly configured for private domain resolution. Uses a three-tier
check: direct match (DNS == server IP), TXT challenge probe (DNS on
LAN), or failure (DNS off-subnet).

Fix per-interface routing tables to clone all non-default routes from
the main table instead of only the interface's own subnets. This
preserves LAN reachability when the priority-75 catch-all overrides
default routing. Filter out status-only flags (linkdown, dead) that
are invalid for `ip route add`.

* refactor: rename manifest metadata fields and improve error display

Rename wrapperRepo→packageRepo, marketingSite→marketingUrl,
docsUrl→docsUrls (array), remove supportSite. Add display_src/display_dbg
helpers to Error. Fix DepInfo description type to LocaleString. Update
web UI, SDK bindings, tests, and fixtures to match. Clean up cli_attach
error handling and remove dead commented code.

* chore: bump sdk version to 0.4.0-beta.49

* chore: add createTask decoupling TODO

* chore: add TODO to clear service error state on install/update

* round out dns check, dns server check, port forward check, and gateway port forwards

* chore: add TODOs for URL plugins, NAT hairpinning, and start-tunnel OTA updates

* version instead of os query param

* interface row clickable again, bu now with a chevron!

* feat: implement URL plugins with table/row actions and prefill support

- Add URL plugin effects (register, export_url, clear_urls) in core
- Add PluginHostnameInfo, HostnameMetadata::Plugin, and plugin registration types
- Implement plugin URL table in web UI with tableAction button and rowAction overflow menus
- Thread urlPluginMetadata (packageId, hostId, interfaceId, internalPort) as prefill to actions
- Add prefill support to PackageActionData so metadata passes through form dialogs
- Add i18n translations for plugin error messages
- Clean up plugin URLs on package uninstall

* feat: split row_actions into remove_action and overflow_actions for URL plugins

* touch up URL plugins table

* show table even when no addresses

* feat: NAT hairpinning, DNS static servers, clear service error on install

- Add POSTROUTING MASQUERADE rules for container and host hairpin NAT
- Allow bridge subnet containers to reach private forwards via LAN IPs
- Pass bridge_subnet env var from forward.rs to forward-port script
- Use DB-configured static DNS servers in resolver with DB watcher
- Fall back to resolv.conf servers when no static servers configured
- Clear service error state when install/update completes successfully
- Remove completed TODO items

* feat: builder-style InputSpec API, prefill plumbing, and port forward fix

- Add addKey() and add() builder methods to InputSpec with InputSpecTools
- Move OuterType to last generic param on Value, List, and all dynamic methods
- Plumb prefill through getActionInput end-to-end (core → container-runtime → SDK)
- Filter port_forwards to enabled addresses only
- Bump SDK to 0.4.0-beta.50

* fix: propagate host locale into LXC containers and write locale.conf

* chore: remove completed URL plugins TODO

* feat: OTA updates for start-tunnel via apt repository (untested)

- Add apt repo publish script (build/apt/publish-deb.sh) for S3-hosted repo
- Add apt source config and GPG key placeholder (apt/)
- Add tunnel.update.check and tunnel.update.apply RPC endpoints
- Wire up update API in tunnel frontend (api service + mock)
- Uses systemd-run --scope to survive service restart during update

* fix: publish script dpkg-name, s3cfg fallback, and --reinstall for apply

* chore: replace OTA updates TODO with UI TODO for MattDHill

* feat: add getOutboundGateway effect and simplify VersionGraph init/uninit

Add getOutboundGateway effect across core, container-runtime, and SDK
to let services query their effective outbound gateway with callback
support. Remove preInstall/uninstall hooks from VersionGraph as they
are no longer needed.

* frontend start-tunnel updates

* chore: remove completed TODO

* feat: tor hidden service key migration

* chore: migrate from ts-matches to zod across all TypeScript packages

* feat(core): allow setting server hostname

* send prefill for tasks and hide operations to hidden fields

* fix(core): preserve plugin URLs across binding updates

BindInfo::update was replacing addresses with a new DerivedAddressInfo
that cleared the available set, wiping plugin-exported URLs whenever
bind() was called. Also simplify update_addresses plugin preservation
to use retain in place rather than collecting into a separate set.

* minor cleanup from patch-db audit

* clean up prefill flow

* frontend support for setting and changing hostname

* feat(core): refactor hostname to ServerHostnameInfo with name/hostname pair

- Rename Hostname to ServerHostnameInfo, add name + hostname fields
- Add set_hostname_rpc for changing hostname at runtime
- Migrate alpha_20: generate serverInfo.name from hostname, delete ui.name
- Extract gateway.rs helpers to fix rustfmt nesting depth issue
- Add i18n key for hostname validation error
- Update SDK bindings

* add comments to everything potentially consumer facing (#3127)

* add comments to everything potentially consumer facing

* rework smtp

---------

Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>

* implement server name

* setup changes

* clean up copy around addresses table

* feat: add zod-deep-partial, partialValidator on InputSpec, and z.deepPartial re-export

* fix: header color in zoom (#3128)

* fix: merge version ranges when adding existing package signer (#3125)

* fix: merge version ranges when adding existing package signer

   Previously, add_package_signer unconditionally inserted the new
   version range, overwriting any existing authorization for that signer.
   Now it OR-merges the new range with the existing one, so running
   signer add multiple times accumulates permissions rather than
   replacing them.

* add --merge flag to registry package signer add

  Default behavior remains overwrite. When --merge is passed, the new
  version range is OR-merged with the existing one, allowing admins to
  accumulate permissions incrementally.

* add missing attribute to TS type

* make merge optional

* upsert instead of insert

* VersionRange::None on upsert

* fix: header color in zoom

---------

Co-authored-by: Dominion5254 <musashidisciple@proton.me>

* update snake and add about this server to system general

* chore: bump sdk to beta.53, wrap z.deepPartial with passthrough

* reset instead of reset defaults

* action failure show dialog

* chore: bump sdk to beta.54, add device-info RPC, improve SDK abort handling and InputSpec filtering

- Bump SDK version to 0.4.0-beta.54
- Add `server.device-info` RPC endpoint and `s9pk select` CLI command
- Extract `HardwareRequirements::is_compatible()` method, reuse in registry filtering
- Add `AbortedError` class with `muteUnhandled` flag, replace generic abort errors
- Handle unhandled promise rejections in container-runtime with mute support
- Improve `InputSpec.filter()` with `keepByDefault` param and boolean filter values
- Accept readonly tuples in `CommandType` and `splitCommand`
- Remove `sync_host` calls from host API handlers (binding/address changes)
- Filter mDNS hostnames by secure gateway availability
- Derive mDNS enabled state from LAN IPs in web UI
- Add "Open UI" action to address table, disable mDNS toggle
- Hide debug details in service error component
- Update rpc-toolkit docs for no-params handlers

* fix: add --no-nvram to efi grub-install to preserve built-in boot order

* update snake

* diable actions when in error state

* chore: split out nvidia variant

* misc bugfixes

* create manage-release script (untested)

* fix: preserve z namespace types for sdk consumers

* sdk version bump

* new checkPort types

* multiple bugs and better port forward ux

* fix link

* chore: todos and formatting

* fix build

---------

Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Dominion5254 <musashidisciple@proton.me>
2026-03-04 04:37:31 -07:00
Dominion5254
26a68afdef fix: merge version ranges when adding existing package signer (#3125)
* fix: merge version ranges when adding existing package signer

   Previously, add_package_signer unconditionally inserted the new
   version range, overwriting any existing authorization for that signer.
   Now it OR-merges the new range with the existing one, so running
   signer add multiple times accumulates permissions rather than
   replacing them.

* add --merge flag to registry package signer add

  Default behavior remains overwrite. When --merge is passed, the new
  version range is OR-merged with the existing one, allowing admins to
  accumulate permissions incrementally.

* add missing attribute to TS type

* make merge optional

* upsert instead of insert

* VersionRange::None on upsert
2026-02-18 13:21:33 -07:00
127 changed files with 3449 additions and 2464 deletions

View File

@@ -62,12 +62,27 @@ fi
chroot /media/startos/next bash -e << "EOF" chroot /media/startos/next bash -e << "EOF"
if [ -f /boot/grub/grub.cfg ]; then if [ -f /boot/grub/grub.cfg ]; then
grub-install --no-nvram /dev/$(eval $(lsblk -o MOUNTPOINT,PKNAME -P | grep 'MOUNTPOINT="/media/startos/root"') && echo $PKNAME) grub-install /dev/$(eval $(lsblk -o MOUNTPOINT,PKNAME -P | grep 'MOUNTPOINT="/media/startos/root"') && echo $PKNAME)
update-grub update-grub
fi fi
EOF EOF
# Promote the USB installer boot entry back to first in EFI boot order.
# The entry number was saved during initial OS install.
if [ -d /sys/firmware/efi ] && [ -f /media/startos/config/efi-installer-entry ]; then
USB_ENTRY=$(cat /media/startos/config/efi-installer-entry)
if [ -n "$USB_ENTRY" ]; then
CURRENT_ORDER=$(efibootmgr | grep BootOrder | sed 's/BootOrder: //')
OTHER_ENTRIES=$(echo "$CURRENT_ORDER" | tr ',' '\n' | grep -v "$USB_ENTRY" | tr '\n' ',' | sed 's/,$//')
if [ -n "$OTHER_ENTRIES" ]; then
efibootmgr -o "$USB_ENTRY,$OTHER_ENTRIES"
else
efibootmgr -o "$USB_ENTRY"
fi
fi
fi
sync sync
umount -Rl /media/startos/next umount -Rl /media/startos/next

View File

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

View File

@@ -25,3 +25,4 @@ cd sdk && make baseDist dist # Rebuild SDK after ts-bindings
- When adding i18n keys, add all 5 locales in `core/locales/i18n.yaml` (see [i18n-patterns.md](i18n-patterns.md)) - When adding i18n keys, add all 5 locales in `core/locales/i18n.yaml` (see [i18n-patterns.md](i18n-patterns.md))
- When using DB watches, follow the `TypedDbWatch<T>` patterns in [patchdb.md](patchdb.md) - When using DB watches, follow the `TypedDbWatch<T>` patterns in [patchdb.md](patchdb.md)
- **Always use `.invoke(ErrorKind::...)` instead of `.status()` when running CLI commands** via `tokio::process::Command`. The `Invoke` trait (from `crate::util::Invoke`) captures stdout/stderr and checks exit codes properly. Using `.status()` leaks stderr directly to system logs, creating noise. For check-then-act patterns (e.g. `iptables -C`), use `.invoke(...).await.is_ok()` / `.is_err()` instead of `.status().await.map_or(false, |s| s.success())`. - **Always use `.invoke(ErrorKind::...)` instead of `.status()` when running CLI commands** via `tokio::process::Command`. The `Invoke` trait (from `crate::util::Invoke`) captures stdout/stderr and checks exit codes properly. Using `.status()` leaks stderr directly to system logs, creating noise. For check-then-act patterns (e.g. `iptables -C`), use `.invoke(...).await.is_ok()` / `.is_err()` instead of `.status().await.map_or(false, |s| s.success())`.
- Always use file utils in util::io instead of tokio::fs when available

View File

@@ -174,11 +174,11 @@ async fn set_name(
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
struct CheckPortParams { pub struct CheckPortParams {
#[arg(help = "help.arg.port")] #[arg(help = "help.arg.port")]
port: u16, pub port: u16,
#[arg(help = "help.arg.gateway-id")] #[arg(help = "help.arg.gateway-id")]
gateway: GatewayId, pub gateway: GatewayId,
} }
#[derive(Debug, Clone, Deserialize, Serialize, TS)] #[derive(Debug, Clone, Deserialize, Serialize, TS)]
@@ -200,7 +200,7 @@ pub struct IfconfigPortRes {
pub reachable: bool, pub reachable: bool,
} }
async fn check_port( pub async fn check_port(
ctx: RpcContext, ctx: RpcContext,
CheckPortParams { port, gateway }: CheckPortParams, CheckPortParams { port, gateway }: CheckPortParams,
) -> Result<CheckPortRes, Error> { ) -> Result<CheckPortRes, Error> {
@@ -276,12 +276,12 @@ async fn check_port(
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
struct CheckDnsParams { pub struct CheckDnsParams {
#[arg(help = "help.arg.gateway-id")] #[arg(help = "help.arg.gateway-id")]
gateway: GatewayId, pub gateway: GatewayId,
} }
async fn check_dns( pub async fn check_dns(
ctx: RpcContext, ctx: RpcContext,
CheckDnsParams { gateway }: CheckDnsParams, CheckDnsParams { gateway }: CheckDnsParams,
) -> Result<bool, Error> { ) -> Result<bool, Error> {
@@ -1238,8 +1238,7 @@ async fn poll_ip_info(
device_type, device_type,
Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback) Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback)
) { ) {
*prev_attempt = Some(Instant::now()); let res = match get_wan_ipv4(iface.as_str(), &ifconfig_url).await {
match get_wan_ipv4(iface.as_str(), &ifconfig_url).await {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
@@ -1253,7 +1252,9 @@ async fn poll_ip_info(
tracing::debug!("{e:?}"); tracing::debug!("{e:?}");
None None
} }
} };
*prev_attempt = Some(Instant::now());
res
} else { } else {
None None
}; };

View File

@@ -12,6 +12,7 @@ use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel; use crate::db::model::DatabaseModel;
use crate::hostname::ServerHostname; use crate::hostname::ServerHostname;
use crate::net::acme::AcmeProvider; use crate::net::acme::AcmeProvider;
use crate::net::gateway::{CheckDnsParams, CheckPortParams, CheckPortRes, check_dns, check_port};
use crate::net::host::{HostApiKind, all_hosts}; use crate::net::host::{HostApiKind, all_hosts};
use crate::prelude::*; use crate::prelude::*;
use crate::util::serde::{HandlerExtSerde, display_serializable}; use crate::util::serde::{HandlerExtSerde, display_serializable};
@@ -160,6 +161,7 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
} }
#[derive(Deserialize, Serialize, Parser, TS)] #[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct AddPublicDomainParams { pub struct AddPublicDomainParams {
#[arg(help = "help.arg.fqdn")] #[arg(help = "help.arg.fqdn")]
@@ -168,6 +170,17 @@ pub struct AddPublicDomainParams {
pub acme: Option<AcmeProvider>, pub acme: Option<AcmeProvider>,
#[arg(help = "help.arg.gateway-id")] #[arg(help = "help.arg.gateway-id")]
pub gateway: GatewayId, pub gateway: GatewayId,
#[arg(help = "help.arg.internal-port")]
pub internal_port: u16,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct AddPublicDomainRes {
#[ts(type = "string | null")]
pub dns: Option<Ipv4Addr>,
pub port: CheckPortRes,
} }
pub async fn add_public_domain<Kind: HostApiKind>( pub async fn add_public_domain<Kind: HostApiKind>(
@@ -176,10 +189,12 @@ pub async fn add_public_domain<Kind: HostApiKind>(
fqdn, fqdn,
acme, acme,
gateway, gateway,
internal_port,
}: AddPublicDomainParams, }: AddPublicDomainParams,
inheritance: Kind::Inheritance, inheritance: Kind::Inheritance,
) -> Result<Option<Ipv4Addr>, Error> { ) -> Result<AddPublicDomainRes, Error> {
ctx.db let ext_port = ctx
.db
.mutate(|db| { .mutate(|db| {
if let Some(acme) = &acme { if let Some(acme) = &acme {
if !db if !db
@@ -195,21 +210,92 @@ pub async fn add_public_domain<Kind: HostApiKind>(
Kind::host_for(&inheritance, db)? Kind::host_for(&inheritance, db)?
.as_public_domains_mut() .as_public_domains_mut()
.insert(&fqdn, &PublicDomainConfig { acme, gateway })?; .insert(
&fqdn,
&PublicDomainConfig {
acme,
gateway: gateway.clone(),
},
)?;
handle_duplicates(db)?; handle_duplicates(db)?;
let hostname = ServerHostname::load(db.as_public().as_server_info())?; let hostname = ServerHostname::load(db.as_public().as_server_info())?;
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db
let ports = db.as_private().as_available_ports().de()?; .as_public()
Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports) .as_server_info()
.as_network()
.as_gateways()
.de()?;
let available_ports = db.as_private().as_available_ports().de()?;
let host = Kind::host_for(&inheritance, db)?;
host.update_addresses(&hostname, &gateways, &available_ports)?;
// Find the external port for the target binding
let bindings = host.as_bindings().de()?;
let target_bind = bindings
.get(&internal_port)
.ok_or_else(|| Error::new(eyre!("binding not found for internal port {internal_port}"), ErrorKind::NotFound))?;
let ext_port = target_bind
.addresses
.available
.iter()
.find(|a| a.public && a.hostname == fqdn)
.and_then(|a| a.port)
.ok_or_else(|| Error::new(eyre!("no public address found for {fqdn} on port {internal_port}"), ErrorKind::NotFound))?;
// Disable the domain on all other bindings
host.as_bindings_mut().mutate(|b| {
for (&port, bind) in b.iter_mut() {
if port == internal_port {
continue;
}
let has_addr = bind
.addresses
.available
.iter()
.any(|a| a.public && a.hostname == fqdn);
if has_addr {
let other_ext = bind
.addresses
.available
.iter()
.find(|a| a.public && a.hostname == fqdn)
.and_then(|a| a.port)
.unwrap_or(ext_port);
bind.addresses.disabled.insert((fqdn.clone(), other_ext));
}
}
Ok(())
})?;
Ok(ext_port)
}) })
.await .await
.result?; .result?;
tokio::task::spawn_blocking(|| { let ctx2 = ctx.clone();
crate::net::dns::query_dns(ctx, crate::net::dns::QueryDnsParams { fqdn }) let fqdn2 = fqdn.clone();
let (dns_result, port_result) = tokio::join!(
async {
tokio::task::spawn_blocking(move || {
crate::net::dns::query_dns(ctx2, crate::net::dns::QueryDnsParams { fqdn: fqdn2 })
})
.await
.with_kind(ErrorKind::Unknown)?
},
check_port(
ctx.clone(),
CheckPortParams {
port: ext_port,
gateway: gateway.clone(),
},
)
);
Ok(AddPublicDomainRes {
dns: dns_result?,
port: port_result?,
}) })
.await
.with_kind(ErrorKind::Unknown)?
} }
#[derive(Deserialize, Serialize, Parser, TS)] #[derive(Deserialize, Serialize, Parser, TS)]
@@ -257,13 +343,13 @@ pub async fn add_private_domain<Kind: HostApiKind>(
ctx: RpcContext, ctx: RpcContext,
AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams, AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams,
inheritance: Kind::Inheritance, inheritance: Kind::Inheritance,
) -> Result<(), Error> { ) -> Result<bool, Error> {
ctx.db ctx.db
.mutate(|db| { .mutate(|db| {
Kind::host_for(&inheritance, db)? Kind::host_for(&inheritance, db)?
.as_private_domains_mut() .as_private_domains_mut()
.upsert(&fqdn, || Ok(BTreeSet::new()))? .upsert(&fqdn, || Ok(BTreeSet::new()))?
.mutate(|d| Ok(d.insert(gateway)))?; .mutate(|d| Ok(d.insert(gateway.clone())))?;
handle_duplicates(db)?; handle_duplicates(db)?;
let hostname = ServerHostname::load(db.as_public().as_server_info())?; let hostname = ServerHostname::load(db.as_public().as_server_info())?;
let gateways = db let gateways = db
@@ -278,7 +364,7 @@ pub async fn add_private_domain<Kind: HostApiKind>(
.await .await
.result?; .result?;
Ok(()) check_dns(ctx, CheckDnsParams { gateway }).await
} }
pub async fn remove_private_domain<Kind: HostApiKind>( pub async fn remove_private_domain<Kind: HostApiKind>(

View File

@@ -27,6 +27,63 @@ use crate::util::serde::IoFormat;
mod gpt; mod gpt;
mod mbr; mod mbr;
/// Get the EFI BootCurrent entry number (the entry firmware used to boot).
/// Returns None on non-EFI systems or if BootCurrent is not set.
async fn get_efi_boot_current() -> Result<Option<String>, Error> {
let efi_output = String::from_utf8(
Command::new("efibootmgr")
.invoke(ErrorKind::Grub)
.await?,
)
.map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?;
Ok(efi_output
.lines()
.find(|line| line.starts_with("BootCurrent:"))
.and_then(|line| line.strip_prefix("BootCurrent:"))
.map(|s| s.trim().to_string()))
}
/// Promote a specific boot entry to first in the EFI boot order.
async fn promote_efi_entry(entry: &str) -> Result<(), Error> {
let efi_output = String::from_utf8(
Command::new("efibootmgr")
.invoke(ErrorKind::Grub)
.await?,
)
.map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?;
let current_order = efi_output
.lines()
.find(|line| line.starts_with("BootOrder:"))
.and_then(|line| line.strip_prefix("BootOrder:"))
.map(|s| s.trim())
.unwrap_or("");
if current_order.is_empty() || current_order.starts_with(entry) {
return Ok(());
}
let other_entries: Vec<&str> = current_order
.split(',')
.filter(|e| e.trim() != entry)
.collect();
let new_order = if other_entries.is_empty() {
entry.to_string()
} else {
format!("{},{}", entry, other_entries.join(","))
};
Command::new("efibootmgr")
.arg("-o")
.arg(&new_order)
.invoke(ErrorKind::Grub)
.await?;
Ok(())
}
/// Probe a squashfs image to determine its target architecture /// Probe a squashfs image to determine its target architecture
async fn probe_squashfs_arch(squashfs_path: &Path) -> Result<InternedString, Error> { async fn probe_squashfs_arch(squashfs_path: &Path) -> Result<InternedString, Error> {
let output = String::from_utf8( let output = String::from_utf8(
@@ -359,7 +416,6 @@ pub async fn install_os_to(
"riscv64" => install.arg("--target=riscv64-efi"), "riscv64" => install.arg("--target=riscv64-efi"),
_ => &mut install, _ => &mut install,
}; };
install.arg("--no-nvram");
} }
install install
.arg(disk_path) .arg(disk_path)
@@ -429,6 +485,21 @@ pub async fn install_os(
}); });
let use_efi = tokio::fs::metadata("/sys/firmware/efi").await.is_ok(); let use_efi = tokio::fs::metadata("/sys/firmware/efi").await.is_ok();
// Save the boot entry we booted from (the USB installer) before grub-install
// overwrites the boot order.
let boot_current = if use_efi {
match get_efi_boot_current().await {
Ok(entry) => entry,
Err(e) => {
tracing::warn!("Failed to get EFI BootCurrent: {e}");
None
}
}
} else {
None
};
let InstallOsResult { part_info, rootfs } = install_os_to( let InstallOsResult { part_info, rootfs } = install_os_to(
"/run/live/medium/live/filesystem.squashfs", "/run/live/medium/live/filesystem.squashfs",
&disk.logicalname, &disk.logicalname,
@@ -440,6 +511,20 @@ pub async fn install_os(
) )
.await?; .await?;
// grub-install prepends its new entry to the EFI boot order, overriding the
// USB-first priority. Promote the USB entry (identified by BootCurrent from
// when we booted the installer) back to first, and persist the entry number
// so the upgrade script can do the same.
if let Some(ref entry) = boot_current {
if let Err(e) = promote_efi_entry(entry).await {
tracing::warn!("Failed to restore EFI boot order: {e}");
}
let efi_entry_path = rootfs.path().join("config/efi-installer-entry");
if let Err(e) = tokio::fs::write(&efi_entry_path, entry).await {
tracing::warn!("Failed to save EFI installer entry number: {e}");
}
}
ctx.config ctx.config
.mutate(|c| c.os_partitions = Some(part_info.clone())); .mutate(|c| c.os_partitions = Some(part_info.clone()));

View File

@@ -1238,19 +1238,13 @@ pub async fn test_smtp(
.body("This is a test email sent from your StartOS Server".to_owned())?; .body("This is a test email sent from your StartOS Server".to_owned())?;
let transport = match security { let transport = match security {
SmtpSecurity::Starttls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)? SmtpSecurity::Starttls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
.port(port) SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
.credentials(creds) }
.build(), .port(port)
SmtpSecurity::Tls => { .tls(Tls::Wrapper(TlsParameters::new(host.clone())?))
let tls = TlsParameters::new(host.clone())?; .credentials(creds)
AsyncSmtpTransport::<Tokio1Executor>::relay(&host)? .build();
.port(port)
.tls(Tls::Wrapper(tls))
.credentials(creds)
.build()
}
};
transport.send(message).await?; transport.send(message).await?;
Ok(()) Ok(())

View File

@@ -524,26 +524,26 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
"To access your Web URL securely, trust your Root CA (displayed above) on your client device(s):\n", "To access your Web URL securely, trust your Root CA (displayed above) on your client device(s):\n",
" - MacOS\n", " - MacOS\n",
" 1. Open the Terminal app\n", " 1. Open the Terminal app\n",
" 2. Paste the following command (**DO NOT** click Return): pbcopy < ~/Desktop/ca.crt\n", " 2. Type or copy/paste the following command (**DO NOT** click Enter/Return yet): pbpaste > ~/Desktop/tunnel-ca.crt\n",
" 3. Copy your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", " 3. Copy your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
" 4. Back in Terminal, click Return. ca.crt is saved to your Desktop\n", " 4. Back in Terminal, click Enter/Return. tunnel-ca.crt is saved to your Desktop\n",
" 5. Complete by trusting your Root CA: https://docs.start9.com/device-guides/mac/ca.html\n", " 5. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Mac\n",
" - Linux\n", " - Linux\n",
" 1. Open gedit, nano, or any editor\n", " 1. Open gedit, nano, or any editor\n",
" 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
" 3. Name the file ca.crt and save as plaintext\n", " 3. Name the file tunnel-ca.crt and save as plaintext\n",
" 4. Complete by trusting your Root CA: https://docs.start9.com/device-guides/linux/ca.html\n", " 4. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Debian+%252F+Ubuntu\n",
" - Windows\n", " - Windows\n",
" 1. Open the Notepad app\n", " 1. Open the Notepad app\n",
" 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n", " 2. Copy/paste your Root CA (including -----BEGIN CERTIFICATE----- and -----END CERTIFICATE-----)\n",
" 3. Name the file ca.crt and save as plaintext\n", " 3. Name the file tunnel-ca.crt and save as plaintext\n",
" 4. Complete by trusting your Root CA: https://docs.start9.com/device-guides/windows/ca.html\n", " 4. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Windows\n",
" - Android/Graphene\n", " - Android/Graphene\n",
" 1. Send the ca.crt file (created above) to yourself\n", " 1. Send the tunnel-ca.crt file (created above) to yourself\n",
" 2. Complete by trusting your Root CA: https://docs.start9.com/device-guides/android/ca.html\n", " 2. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=Android+%252F+Graphene\n",
" - iOS\n", " - iOS\n",
" 1. Send the ca.crt file (created above) to yourself\n", " 1. Send the tunnel-ca.crt file (created above) to yourself\n",
" 2. Complete by trusting your Root CA: https://docs.start9.com/device-guides/ios/ca.html\n", " 2. Complete by trusting your Root CA: https://docs.start9.com/start-os/0.4.0.x/user-manual/trust-ca.html?platform=iOS\n",
)); ));
return Ok(()); return Ok(());

View File

@@ -1,41 +1,57 @@
import { SmtpValue } from '../../types'
import { GetSystemSmtp, Patterns } from '../../util' import { GetSystemSmtp, Patterns } from '../../util'
import { InputSpec, InputSpecOf } from './builder/inputSpec' import { InputSpec } from './builder/inputSpec'
import { Value } from './builder/value' import { Value } from './builder/value'
import { Variants } from './builder/variants' import { Variants } from './builder/variants'
const securityVariants = Variants.of({
tls: {
name: 'TLS',
spec: InputSpec.of({
port: Value.dynamicText(async () => ({
name: 'Port',
required: true,
default: '465',
disabled: 'Fixed for TLS',
})),
}),
},
starttls: {
name: 'STARTTLS',
spec: InputSpec.of({
port: Value.select({
name: 'Port',
default: '587',
values: { '25': '25', '587': '587', '2525': '2525' },
}),
}),
},
})
/** /**
* Creates an SMTP field spec with provider-specific defaults pre-filled. * Creates an SMTP field spec with provider-specific defaults pre-filled.
*/ */
function smtpFields( function smtpFields(
defaults: { defaults: {
host?: string host?: string
port?: number
security?: 'starttls' | 'tls' security?: 'starttls' | 'tls'
hostDisabled?: boolean
} = {}, } = {},
): InputSpec<SmtpValue> { ) {
return InputSpec.of<InputSpecOf<SmtpValue>>({ const hostSpec = Value.text({
host: Value.text({ name: 'Host',
name: 'Host', required: true,
required: true, default: defaults.host ?? null,
default: defaults.host ?? null, placeholder: 'smtp.example.com',
placeholder: 'smtp.example.com', })
}),
port: Value.number({ return InputSpec.of({
name: 'Port', host: defaults.hostDisabled
required: true, ? hostSpec.withDisabled('Fixed for this provider')
default: defaults.port ?? 587, : hostSpec,
min: 1, security: Value.union({
max: 65535,
integer: true,
}),
security: Value.select({
name: 'Connection Security', name: 'Connection Security',
default: defaults.security ?? 'starttls', default: defaults.security ?? 'tls',
values: { variants: securityVariants,
starttls: 'STARTTLS',
tls: 'TLS',
},
}), }),
from: Value.text({ from: Value.text({
name: 'From Address', name: 'From Address',
@@ -72,40 +88,39 @@ export const smtpProviderVariants = Variants.of({
name: 'Gmail', name: 'Gmail',
spec: smtpFields({ spec: smtpFields({
host: 'smtp.gmail.com', host: 'smtp.gmail.com',
port: 587, security: 'tls',
security: 'starttls', hostDisabled: true,
}), }),
}, },
ses: { ses: {
name: 'Amazon SES', name: 'Amazon SES',
spec: smtpFields({ spec: smtpFields({
host: 'email-smtp.us-east-1.amazonaws.com', host: 'email-smtp.us-east-1.amazonaws.com',
port: 587, security: 'tls',
security: 'starttls',
}), }),
}, },
sendgrid: { sendgrid: {
name: 'SendGrid', name: 'SendGrid',
spec: smtpFields({ spec: smtpFields({
host: 'smtp.sendgrid.net', host: 'smtp.sendgrid.net',
port: 587, security: 'tls',
security: 'starttls', hostDisabled: true,
}), }),
}, },
mailgun: { mailgun: {
name: 'Mailgun', name: 'Mailgun',
spec: smtpFields({ spec: smtpFields({
host: 'smtp.mailgun.org', host: 'smtp.mailgun.org',
port: 587, security: 'tls',
security: 'starttls', hostDisabled: true,
}), }),
}, },
protonmail: { protonmail: {
name: 'Proton Mail', name: 'Proton Mail',
spec: smtpFields({ spec: smtpFields({
host: 'smtp.protonmail.ch', host: 'smtp.protonmail.ch',
port: 587, security: 'tls',
security: 'starttls', hostDisabled: true,
}), }),
}, },
other: { other: {
@@ -121,7 +136,7 @@ export const smtpProviderVariants = Variants.of({
export const systemSmtpSpec = InputSpec.of({ export const systemSmtpSpec = InputSpec.of({
provider: Value.union({ provider: Value.union({
name: 'Provider', name: 'Provider',
default: null as any, default: 'gmail',
variants: smtpProviderVariants, variants: smtpProviderVariants,
}), }),
}) })

View File

@@ -14,28 +14,34 @@ export const knownProtocols = {
defaultPort: 80, defaultPort: 80,
withSsl: 'https', withSsl: 'https',
alpn: { specified: ['http/1.1'] } as AlpnInfo, alpn: { specified: ['http/1.1'] } as AlpnInfo,
addXForwardedHeaders: true,
}, },
https: { https: {
secure: { ssl: true }, secure: { ssl: true },
defaultPort: 443, defaultPort: 443,
addXForwardedHeaders: true,
}, },
ws: { ws: {
secure: null, secure: null,
defaultPort: 80, defaultPort: 80,
withSsl: 'wss', withSsl: 'wss',
alpn: { specified: ['http/1.1'] } as AlpnInfo, alpn: { specified: ['http/1.1'] } as AlpnInfo,
addXForwardedHeaders: true,
}, },
wss: { wss: {
secure: { ssl: true }, secure: { ssl: true },
defaultPort: 443, defaultPort: 443,
addXForwardedHeaders: true,
}, },
ssh: { ssh: {
secure: { ssl: false }, secure: { ssl: false },
defaultPort: 22, defaultPort: 22,
addXForwardedHeaders: false,
}, },
dns: { dns: {
secure: { ssl: false }, secure: { ssl: false },
defaultPort: 53, defaultPort: 53,
addXForwardedHeaders: false,
}, },
} as const } as const
@@ -136,7 +142,7 @@ export class MultiHost {
const sslProto = this.getSslProto(options) const sslProto = this.getSslProto(options)
const addSsl = sslProto const addSsl = sslProto
? { ? {
addXForwardedHeaders: false, addXForwardedHeaders: knownProtocols[sslProto].addXForwardedHeaders,
preferredExternalPort: knownProtocols[sslProto].defaultPort, preferredExternalPort: knownProtocols[sslProto].defaultPort,
scheme: sslProto, scheme: sslProto,
alpn: 'alpn' in protoInfo ? protoInfo.alpn : null, alpn: 'alpn' in protoInfo ? protoInfo.alpn : null,
@@ -148,7 +154,7 @@ export class MultiHost {
preferredExternalPort: 443, preferredExternalPort: 443,
scheme: sslProto, scheme: sslProto,
alpn: null, alpn: null,
...('addSsl' in options ? options.addSsl : null), ...options.addSsl,
} }
: null : null

View File

@@ -6,4 +6,5 @@ export type AddPublicDomainParams = {
fqdn: string fqdn: string
acme: AcmeProvider | null acme: AcmeProvider | null
gateway: GatewayId gateway: GatewayId
internalPort: number
} }

View File

@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CheckPortRes } from './CheckPortRes'
export type AddPublicDomainRes = { dns: string | null; port: CheckPortRes }

View File

@@ -5,6 +5,7 @@ import type { DnsSettings } from './DnsSettings'
import type { GatewayId } from './GatewayId' import type { GatewayId } from './GatewayId'
import type { Host } from './Host' import type { Host } from './Host'
import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo' import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo'
import type { PassthroughInfo } from './PassthroughInfo'
import type { WifiInfo } from './WifiInfo' import type { WifiInfo } from './WifiInfo'
export type NetworkInfo = { export type NetworkInfo = {
@@ -14,4 +15,5 @@ export type NetworkInfo = {
acme: { [key: AcmeProvider]: AcmeSettings } acme: { [key: AcmeProvider]: AcmeSettings }
dns: DnsSettings dns: DnsSettings
defaultOutbound: string | null defaultOutbound: string | null
passthroughs: Array<PassthroughInfo>
} }

View File

@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PassthroughInfo = {
hostname: string
listenPort: number
backend: string
publicGateways: string[]
privateIps: string[]
}

View File

@@ -19,6 +19,7 @@ export { AddPackageSignerParams } from './AddPackageSignerParams'
export { AddPackageToCategoryParams } from './AddPackageToCategoryParams' export { AddPackageToCategoryParams } from './AddPackageToCategoryParams'
export { AddPrivateDomainParams } from './AddPrivateDomainParams' export { AddPrivateDomainParams } from './AddPrivateDomainParams'
export { AddPublicDomainParams } from './AddPublicDomainParams' export { AddPublicDomainParams } from './AddPublicDomainParams'
export { AddPublicDomainRes } from './AddPublicDomainRes'
export { AddressInfo } from './AddressInfo' export { AddressInfo } from './AddressInfo'
export { AddSslOptions } from './AddSslOptions' export { AddSslOptions } from './AddSslOptions'
export { AddTunnelParams } from './AddTunnelParams' export { AddTunnelParams } from './AddTunnelParams'
@@ -201,6 +202,7 @@ export { PackagePlugin } from './PackagePlugin'
export { PackageState } from './PackageState' export { PackageState } from './PackageState'
export { PackageVersionInfo } from './PackageVersionInfo' export { PackageVersionInfo } from './PackageVersionInfo'
export { PartitionInfo } from './PartitionInfo' export { PartitionInfo } from './PartitionInfo'
export { PassthroughInfo } from './PassthroughInfo'
export { PasswordType } from './PasswordType' export { PasswordType } from './PasswordType'
export { PathOrUrl } from './PathOrUrl' export { PathOrUrl } from './PathOrUrl'
export { Pem } from './Pem' export { Pem } from './Pem'

View File

@@ -1,25 +1,25 @@
export * as inputSpecTypes from './actions/input/inputSpecTypes' export * as inputSpecTypes from './actions/input/inputSpecTypes'
export {
CurrentDependenciesResult,
OptionalDependenciesOf as OptionalDependencies,
RequiredDependenciesOf as RequiredDependencies,
} from './dependencies/setupDependencies'
export * from './osBindings'
export { SDKManifest } from './types/ManifestTypes'
export { Effects }
import { InputSpec as InputSpecClass } from './actions/input/builder/inputSpec' import { InputSpec as InputSpecClass } from './actions/input/builder/inputSpec'
import {
DependencyRequirement,
NamedHealthCheckResult,
Manifest,
ServiceInterface,
ActionId,
} from './osBindings'
import { Affine, StringObject, ToKebab } from './util'
import { Action, Actions } from './actions/setupActions' import { Action, Actions } from './actions/setupActions'
import { Effects } from './Effects' import { Effects } from './Effects'
import { ExtendedVersion, VersionRange } from './exver' import { ExtendedVersion, VersionRange } from './exver'
export { Effects } import {
export * from './osBindings' ActionId,
export { SDKManifest } from './types/ManifestTypes' DependencyRequirement,
export { Manifest,
RequiredDependenciesOf as RequiredDependencies, NamedHealthCheckResult,
OptionalDependenciesOf as OptionalDependencies, ServiceInterface,
CurrentDependenciesResult, } from './osBindings'
} from './dependencies/setupDependencies' import { StringObject, ToKebab } from './util'
/** An object that can be built into a terminable daemon process. */ /** An object that can be built into a terminable daemon process. */
export type DaemonBuildable = { export type DaemonBuildable = {

View File

@@ -26,6 +26,18 @@ export const getHostname = (url: string): Hostname | null => {
return last return last
} }
/**
* The kinds of hostnames that can be filtered on.
*
* - `'mdns'` — mDNS / Bonjour `.local` hostnames
* - `'domain'` — any os-managed domain name (matches both `'private-domain'` and `'public-domain'` metadata kinds)
* - `'ip'` — shorthand for both `'ipv4'` and `'ipv6'`
* - `'ipv4'` — IPv4 addresses only
* - `'ipv6'` — IPv6 addresses only
* - `'localhost'` — loopback addresses (`localhost`, `127.0.0.1`, `::1`)
* - `'link-local'` — IPv6 link-local addresses (fe80::/10)
* - `'plugin'` — hostnames provided by a plugin package
*/
type FilterKinds = type FilterKinds =
| 'mdns' | 'mdns'
| 'domain' | 'domain'
@@ -34,10 +46,25 @@ type FilterKinds =
| 'ipv6' | 'ipv6'
| 'localhost' | 'localhost'
| 'link-local' | 'link-local'
| 'plugin'
/**
* Describes which hostnames to include (or exclude) when filtering a `Filled` address.
*
* Every field is optional — omitted fields impose no constraint.
* Filters are composable: the `.filter()` method intersects successive filters,
* and the `exclude` field inverts a nested filter.
*/
export type Filter = { export type Filter = {
/** Keep only hostnames with the given visibility. `'public'` = externally reachable, `'private'` = LAN-only. */
visibility?: 'public' | 'private' visibility?: 'public' | 'private'
/** Keep only hostnames whose metadata kind matches. A single kind or array of kinds. `'ip'` expands to `['ipv4','ipv6']`, `'domain'` matches both `'private-domain'` and `'public-domain'`. */
kind?: FilterKinds | FilterKinds[] kind?: FilterKinds | FilterKinds[]
/** Arbitrary predicate — hostnames for which this returns `false` are excluded. */
predicate?: (h: HostnameInfo) => boolean predicate?: (h: HostnameInfo) => boolean
/** Keep only plugin hostnames provided by this package. Implies `kind: 'plugin'`. */
pluginId?: PackageId
/** A nested filter whose matches are *removed* from the result (logical NOT). */
exclude?: Filter exclude?: Filter
} }
@@ -65,9 +92,13 @@ type KindFilter<K extends FilterKinds> = K extends 'mdns'
? ?
| (HostnameInfo & { metadata: { kind: 'ipv6' } }) | (HostnameInfo & { metadata: { kind: 'ipv6' } })
| KindFilter<Exclude<K, 'ipv6'>> | KindFilter<Exclude<K, 'ipv6'>>
: K extends 'ip' : K extends 'plugin'
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'> ?
: never | (HostnameInfo & { metadata: { kind: 'plugin' } })
| KindFilter<Exclude<K, 'plugin'>>
: K extends 'ip'
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
: never
type FilterReturnTy<F extends Filter> = F extends { type FilterReturnTy<F extends Filter> = F extends {
visibility: infer V extends 'public' | 'private' visibility: infer V extends 'public' | 'private'
@@ -107,20 +138,62 @@ type FormatReturnTy<
? UrlString | FormatReturnTy<F, Exclude<Format, 'urlstring'>> ? UrlString | FormatReturnTy<F, Exclude<Format, 'urlstring'>>
: never : never
/**
* A resolved address with its hostnames already populated, plus helpers
* for filtering, formatting, and converting hostnames to URLs.
*
* Filters are chainable and each call returns a new `Filled` narrowed to the
* matching subset of hostnames:
*
* ```ts
* addresses.nonLocal // exclude localhost & link-local
* addresses.public // only publicly-reachable hostnames
* addresses.filter({ kind: 'domain' }) // only domain-name hostnames
* addresses.filter({ visibility: 'private' }) // only LAN-reachable hostnames
* addresses.nonLocal.filter({ kind: 'ip' }) // chainable — non-local IPs only
* ```
*/
export type Filled<F extends Filter = {}> = { export type Filled<F extends Filter = {}> = {
/** The hostnames that survived all applied filters. */
hostnames: HostnameInfo[] hostnames: HostnameInfo[]
/** Convert a single hostname into a fully-formed URL string, applying the address's scheme, username, and suffix. */
toUrl: (h: HostnameInfo) => UrlString toUrl: (h: HostnameInfo) => UrlString
/**
* Return every hostname in the requested format.
*
* - `'urlstring'` (default) — formatted URL strings
* - `'url'` — `URL` objects
* - `'hostname-info'` — raw `HostnameInfo` objects
*/
format: <Format extends Formats = 'urlstring'>( format: <Format extends Formats = 'urlstring'>(
format?: Format, format?: Format,
) => FormatReturnTy<{}, Format>[] ) => FormatReturnTy<{}, Format>[]
/**
* Apply an arbitrary {@link Filter} and return a new `Filled` containing only
* the hostnames that match. Filters compose: calling `.filter()` on an
* already-filtered `Filled` intersects the constraints.
*/
filter: <NewFilter extends Filter>( filter: <NewFilter extends Filter>(
filter: NewFilter, filter: NewFilter,
) => Filled<NewFilter & Filter> ) => Filled<NewFilter & Filter>
/**
* Apply multiple filters and return hostnames that match **any** of them (union / OR).
*
* ```ts
* addresses.matchesAny([{ kind: 'domain' }, { kind: 'mdns' }])
* ```
*/
matchesAny: <NewFilters extends Filter[]>(
filters: [...NewFilters],
) => Filled<NewFilters[number] & F>
/** Shorthand filter that excludes `localhost` and IPv6 link-local addresses — keeps only network-reachable hostnames. */
nonLocal: Filled<typeof nonLocalFilter & Filter> nonLocal: Filled<typeof nonLocalFilter & Filter>
/** Shorthand filter that keeps only publicly-reachable hostnames (those with `public: true`). */
public: Filled<typeof publicFilter & Filter> public: Filled<typeof publicFilter & Filter>
} }
export type FilledAddressInfo = AddressInfo & Filled export type FilledAddressInfo = AddressInfo & Filled
@@ -210,7 +283,16 @@ function filterRec(
['localhost', '127.0.0.1', '::1'].includes(h.hostname)) || ['localhost', '127.0.0.1', '::1'].includes(h.hostname)) ||
(kind.has('link-local') && (kind.has('link-local') &&
h.metadata.kind === 'ipv6' && h.metadata.kind === 'ipv6' &&
IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname)))), IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname))) ||
(kind.has('plugin') && h.metadata.kind === 'plugin')),
)
}
if (filter.pluginId) {
const id = filter.pluginId
hostnames = hostnames.filter(
(h) =>
invert !==
(h.metadata.kind === 'plugin' && h.metadata.packageId === id),
) )
} }
@@ -242,6 +324,14 @@ function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] {
}) })
} }
/**
* Filters out localhost and IPv6 link-local hostnames from a list.
* Equivalent to the `nonLocal` filter on `Filled` addresses.
*/
export function filterNonLocal(hostnames: HostnameInfo[]): HostnameInfo[] {
return filterRec(hostnames, nonLocalFilter, false)
}
export const filledAddress = ( export const filledAddress = (
host: Host, host: Host,
addressInfo: AddressInfo, addressInfo: AddressInfo,
@@ -280,6 +370,19 @@ export const filledAddress = (
filterRec(hostnames, filter, false), filterRec(hostnames, filter, false),
) )
}, },
matchesAny: <NewFilters extends Filter[]>(filters: [...NewFilters]) => {
const seen = new Set<HostnameInfo>()
const union: HostnameInfo[] = []
for (const f of filters) {
for (const h of filterRec(hostnames, f, false)) {
if (!seen.has(h)) {
seen.add(h)
union.push(h)
}
}
}
return filledAddressFromHostnames<NewFilters[number] & F>(union)
},
get nonLocal(): Filled<typeof nonLocalFilter & F> { get nonLocal(): Filled<typeof nonLocalFilter & F> {
return getNonLocal() return getNonLocal()
}, },

View File

@@ -8,6 +8,7 @@ export {
GetServiceInterface, GetServiceInterface,
getServiceInterface, getServiceInterface,
filledAddress, filledAddress,
filterNonLocal,
} from './getServiceInterface' } from './getServiceInterface'
export { getServiceInterfaces } from './getServiceInterfaces' export { getServiceInterfaces } from './getServiceInterfaces'
export { once } from './once' export { once } from './once'

View File

@@ -141,6 +141,7 @@ export class StartSdk<Manifest extends T.SDKManifest> {
| 'getSystemSmtp' | 'getSystemSmtp'
| 'getOutboundGateway' | 'getOutboundGateway'
| 'getContainerIp' | 'getContainerIp'
| 'getStatus'
| 'getDataVersion' | 'getDataVersion'
| 'setDataVersion' | 'setDataVersion'
| 'getServiceManifest' | 'getServiceManifest'
@@ -164,7 +165,6 @@ export class StartSdk<Manifest extends T.SDKManifest> {
getSslKey: (effects, ...args) => effects.getSslKey(...args), getSslKey: (effects, ...args) => effects.getSslKey(...args),
shutdown: (effects, ...args) => effects.shutdown(...args), shutdown: (effects, ...args) => effects.shutdown(...args),
getDependencies: (effects, ...args) => effects.getDependencies(...args), getDependencies: (effects, ...args) => effects.getDependencies(...args),
getStatus: (effects, ...args) => effects.getStatus(...args),
setHealth: (effects, ...args) => effects.setHealth(...args), setHealth: (effects, ...args) => effects.setHealth(...args),
} }
@@ -342,6 +342,104 @@ export class StartSdk<Manifest extends T.SDKManifest> {
} }
}, },
/**
* Get the service's current status with reactive subscription support.
*
* Returns an object with multiple read strategies: `const()` for a value
* that retries on change, `once()` for a single read, `watch()` for an async
* generator, `onChange()` for a callback, and `waitFor()` to block until a predicate is met.
*
* @param effects - The effects context
* @param options - Optional filtering options (e.g. `packageId`)
*/
getStatus: (
effects: T.Effects,
options: Omit<Parameters<T.Effects['getStatus']>[0], 'callback'> = {},
) => {
async function* watch(abort?: AbortSignal) {
const resolveCell = { resolve: () => {} }
effects.onLeaveContext(() => {
resolveCell.resolve()
})
abort?.addEventListener('abort', () => resolveCell.resolve())
while (effects.isInContext && !abort?.aborted) {
let callback: () => void = () => {}
const waitForNext = new Promise<void>((resolve) => {
callback = resolve
resolveCell.resolve = resolve
})
yield await effects.getStatus({ ...options, callback })
await waitForNext
}
}
return {
const: () =>
effects.getStatus({
...options,
callback:
effects.constRetry &&
(() => effects.constRetry && effects.constRetry()),
}),
once: () => effects.getStatus(options),
watch: (abort?: AbortSignal) => {
const ctrl = new AbortController()
abort?.addEventListener('abort', () => ctrl.abort())
return DropGenerator.of(watch(ctrl.signal), () => ctrl.abort())
},
onChange: (
callback: (
value: T.StatusInfo | null,
error?: Error,
) => { cancel: boolean } | Promise<{ cancel: boolean }>,
) => {
;(async () => {
const ctrl = new AbortController()
for await (const value of watch(ctrl.signal)) {
try {
const res = await callback(value)
if (res.cancel) {
ctrl.abort()
break
}
} catch (e) {
console.error(
'callback function threw an error @ getStatus.onChange',
e,
)
}
}
})()
.catch((e) => callback(null, e))
.catch((e) =>
console.error(
'callback function threw an error @ getStatus.onChange',
e,
),
)
},
waitFor: async (pred: (value: T.StatusInfo | null) => boolean) => {
const resolveCell = { resolve: () => {} }
effects.onLeaveContext(() => {
resolveCell.resolve()
})
while (effects.isInContext) {
let callback: () => void = () => {}
const waitForNext = new Promise<void>((resolve) => {
callback = resolve
resolveCell.resolve = resolve
})
const res = await effects.getStatus({ ...options, callback })
if (pred(res)) {
resolveCell.resolve()
return res
}
await waitForNext
}
return null
},
}
},
MultiHost: { MultiHost: {
/** /**
* Create a new MultiHost instance for binding ports and exporting interfaces. * Create a new MultiHost instance for binding ports and exporting interfaces.

View File

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

View File

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

2498
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,34 +33,33 @@
"format:check": "prettier --check projects/" "format:check": "prettier --check projects/"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "^20.3.0", "@angular/animations": "^21.2.1",
"@angular/cdk": "^20.1.0", "@angular/cdk": "^21.2.1",
"@angular/common": "^20.3.0", "@angular/common": "^21.2.1",
"@angular/compiler": "^20.3.0", "@angular/compiler": "^21.2.1",
"@angular/core": "^20.3.0", "@angular/core": "^21.2.1",
"@angular/forms": "^20.3.0", "@angular/forms": "^21.2.1",
"@angular/platform-browser": "^20.3.0", "@angular/platform-browser": "^21.2.1",
"@angular/platform-browser-dynamic": "^20.1.0", "@angular/pwa": "^21.2.1",
"@angular/pwa": "^20.3.0", "@angular/router": "^21.2.1",
"@angular/router": "^20.3.0", "@angular/service-worker": "^21.2.1",
"@angular/service-worker": "^20.3.0",
"@materia-ui/ngx-monaco-editor": "^6.0.0", "@materia-ui/ngx-monaco-editor": "^6.0.0",
"@noble/curves": "^1.4.0", "@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.3.0", "@start9labs/argon2": "^0.3.0",
"@start9labs/start-sdk": "file:../sdk/baseDist", "@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.66.0", "@taiga-ui/addon-charts": "4.73.0",
"@taiga-ui/addon-commerce": "4.66.0", "@taiga-ui/addon-commerce": "4.73.0",
"@taiga-ui/addon-mobile": "4.66.0", "@taiga-ui/addon-mobile": "4.73.0",
"@taiga-ui/addon-table": "4.66.0", "@taiga-ui/addon-table": "4.73.0",
"@taiga-ui/cdk": "4.66.0", "@taiga-ui/cdk": "4.73.0",
"@taiga-ui/core": "4.66.0", "@taiga-ui/core": "4.73.0",
"@taiga-ui/dompurify": "4.1.11", "@taiga-ui/dompurify": "4.1.11",
"@taiga-ui/event-plugins": "4.7.0", "@taiga-ui/event-plugins": "4.7.0",
"@taiga-ui/experimental": "4.66.0", "@taiga-ui/experimental": "4.73.0",
"@taiga-ui/icons": "4.66.0", "@taiga-ui/icons": "4.73.0",
"@taiga-ui/kit": "4.66.0", "@taiga-ui/kit": "4.73.0",
"@taiga-ui/layout": "4.66.0", "@taiga-ui/layout": "4.73.0",
"@taiga-ui/polymorpheus": "4.9.0", "@taiga-ui/polymorpheus": "4.9.0",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
@@ -80,7 +79,7 @@
"mime": "^4.0.3", "mime": "^4.0.3",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"ng-qrcode": "^20.0.0", "ng-qrcode": "^21.0.0",
"node-jose": "^2.2.0", "node-jose": "^2.2.0",
"patch-db-client": "file:../patch-db/client", "patch-db-client": "file:../patch-db/client",
"pbkdf2": "^3.1.2", "pbkdf2": "^3.1.2",
@@ -92,10 +91,10 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-experts/hawkeye": "^1.7.2", "@angular-experts/hawkeye": "^1.7.2",
"@angular/build": "^20.1.0", "@angular/build": "^21.2.1",
"@angular/cli": "^20.1.0", "@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^20.1.0", "@angular/compiler-cli": "^21.2.1",
"@angular/language-service": "^20.1.0", "@angular/language-service": "^21.2.1",
"@types/dompurify": "3.0.5", "@types/dompurify": "3.0.5",
"@types/estree": "^0.0.51", "@types/estree": "^0.0.51",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
@@ -107,7 +106,7 @@
"@types/uuid": "^8.3.1", "@types/uuid": "^8.3.1",
"husky": "^4.3.8", "husky": "^4.3.8",
"lint-staged": "^13.2.0", "lint-staged": "^13.2.0",
"ng-packagr": "^20.1.0", "ng-packagr": "^21.2.0",
"node-html-parser": "^5.3.3", "node-html-parser": "^5.3.3",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^3.5.3", "prettier": "^3.5.3",

View File

@@ -1,42 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import {
DocsLinkDirective,
i18nPipe,
SharedPipesModule,
} from '@start9labs/shared'
import {
TuiAppearance,
TuiButton,
TuiIcon,
TuiLoader,
TuiPopup,
} from '@taiga-ui/core'
import { TuiDrawer, TuiSkeleton } from '@taiga-ui/kit'
import { CategoriesModule } from '../../pages/list/categories/categories.module'
import { SearchModule } from '../../pages/list/search/search.module'
import { StoreIconComponentModule } from '../store-icon/store-icon.component.module'
import { MenuComponent } from './menu.component'
@NgModule({
imports: [
CommonModule,
SharedPipesModule,
SearchModule,
CategoriesModule,
TuiLoader,
TuiButton,
CategoriesModule,
StoreIconComponentModule,
TuiAppearance,
TuiIcon,
TuiSkeleton,
TuiDrawer,
TuiPopup,
i18nPipe,
DocsLinkDirective,
],
declarations: [MenuComponent],
exports: [MenuComponent],
})
export class MenuModule {}

View File

@@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -6,16 +7,35 @@ import {
OnDestroy, OnDestroy,
signal, signal,
} from '@angular/core' } from '@angular/core'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiAppearance, TuiButton, TuiIcon, TuiPopup } from '@taiga-ui/core'
import { TuiDrawer, TuiSkeleton } from '@taiga-ui/kit'
import { Subject, takeUntil } from 'rxjs' import { Subject, takeUntil } from 'rxjs'
import { CategoriesComponent } from '../../pages/list/categories/categories.component'
import { SearchComponent } from '../../pages/list/search/search.component'
import { AbstractCategoryService } from '../../services/category.service' import { AbstractCategoryService } from '../../services/category.service'
import { StoreDataWithUrl } from '../../types' import { StoreDataWithUrl } from '../../types'
import { StoreIconComponent } from '../store-icon.component'
@Component({ @Component({
selector: 'menu', selector: 'menu',
templateUrl: './menu.component.html', templateUrl: './menu.component.html',
styleUrls: ['./menu.component.scss'], styleUrls: ['./menu.component.scss'],
imports: [
CommonModule,
SearchComponent,
CategoriesComponent,
TuiButton,
StoreIconComponent,
TuiAppearance,
TuiIcon,
TuiSkeleton,
TuiDrawer,
TuiPopup,
i18nPipe,
DocsLinkDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
}) })
export class MenuComponent implements OnDestroy { export class MenuComponent implements OnDestroy {
@Input({ required: true }) @Input({ required: true })

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiIcon, TuiTitle } from '@taiga-ui/core' import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { StoreIconComponentModule } from './store-icon/store-icon.component.module' import { StoreIconComponent } from './store-icon.component'
@Component({ @Component({
selector: '[registry]', selector: '[registry]',
@@ -17,7 +17,7 @@ import { StoreIconComponentModule } from './store-icon/store-icon.component.modu
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [StoreIconComponentModule, TuiIcon, TuiTitle], imports: [StoreIconComponent, TuiIcon, TuiTitle],
}) })
export class MarketplaceRegistryComponent { export class MarketplaceRegistryComponent {
@Input() @Input()

View File

@@ -21,7 +21,6 @@ import { knownRegistries, sameUrl } from '@start9labs/shared'
`, `,
styles: ':host { overflow: hidden; }', styles: ':host { overflow: hidden; }',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
}) })
export class StoreIconComponent { export class StoreIconComponent {
@Input() @Input()

View File

@@ -1,10 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { StoreIconComponent } from './store-icon.component'
@NgModule({
declarations: [StoreIconComponent],
imports: [CommonModule],
exports: [StoreIconComponent],
})
export class StoreIconComponentModule {}

View File

@@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -5,7 +6,11 @@ import {
Input, Input,
Output, Output,
} from '@angular/core' } from '@angular/core'
import { RouterModule } from '@angular/router'
import { LocalizePipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { TuiAppearance, TuiIcon } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
const ICONS: Record<string, string> = { const ICONS: Record<string, string> = {
all: '@tui.layout-grid', all: '@tui.layout-grid',
@@ -26,8 +31,15 @@ const ICONS: Record<string, string> = {
selector: 'marketplace-categories', selector: 'marketplace-categories',
templateUrl: 'categories.component.html', templateUrl: 'categories.component.html',
styleUrls: ['categories.component.scss'], styleUrls: ['categories.component.scss'],
imports: [
RouterModule,
CommonModule,
TuiAppearance,
TuiIcon,
TuiSkeleton,
LocalizePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
}) })
export class CategoriesComponent { export class CategoriesComponent {
@Input() @Input()

View File

@@ -1,15 +0,0 @@
import { TuiIcon, TuiAppearance } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { LocalizePipe } from '@start9labs/shared'
import { CategoriesComponent } from './categories.component'
import { RouterModule } from '@angular/router'
@NgModule({
imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton, LocalizePipe],
declarations: [CategoriesComponent],
exports: [CategoriesComponent],
})
export class CategoriesModule {}

View File

@@ -1,12 +1,15 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterModule } from '@angular/router'
import { LocalizePipe, TickerComponent } from '@start9labs/shared'
import { MarketplacePkg } from '../../../types' import { MarketplacePkg } from '../../../types'
@Component({ @Component({
selector: 'marketplace-item', selector: 'marketplace-item',
templateUrl: 'item.component.html', templateUrl: 'item.component.html',
styleUrls: ['item.component.scss'], styleUrls: ['item.component.scss'],
imports: [CommonModule, RouterModule, TickerComponent, LocalizePipe],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
}) })
export class ItemComponent { export class ItemComponent {
@Input({ required: true }) @Input({ required: true })

View File

@@ -1,12 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { LocalizePipe, SharedPipesModule, TickerComponent } from '@start9labs/shared'
import { ItemComponent } from './item.component'
@NgModule({
declarations: [ItemComponent],
exports: [ItemComponent],
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent, LocalizePipe],
})
export class ItemModule {}

View File

@@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -5,13 +6,15 @@ import {
Input, Input,
Output, Output,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiIcon } from '@taiga-ui/core'
@Component({ @Component({
selector: 'marketplace-search', selector: 'marketplace-search',
templateUrl: 'search.component.html', templateUrl: 'search.component.html',
styleUrls: ['search.component.scss'], styleUrls: ['search.component.scss'],
imports: [FormsModule, CommonModule, TuiIcon],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
}) })
export class SearchComponent { export class SearchComponent {
@Input() @Input()

View File

@@ -1,12 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiIcon } from '@taiga-ui/core'
import { SearchComponent } from './search.component'
@NgModule({
imports: [FormsModule, CommonModule, TuiIcon],
declarations: [SearchComponent],
exports: [SearchComponent],
})
export class SearchModule {}

View File

@@ -1,7 +1,12 @@
import { KeyValue } from '@angular/common' import { KeyValue } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core' import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { ExverPipesModule, i18nPipe, i18nService } from '@start9labs/shared' import { i18nPipe, i18nService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit' import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
import { MarketplacePkgBase } from '../../../types' import { MarketplacePkgBase } from '../../../types'
@@ -20,9 +25,7 @@ import { MarketplacePkgBase } from '../../../types'
<tui-line-clamp [linesLimit]="2" [content]="titleContent" /> <tui-line-clamp [linesLimit]="2" [content]="titleContent" />
<ng-template #titleContent> <ng-template #titleContent>
<div class="title"> <div class="title">
<span> <span>{{ getTitle(dep.key) }}</span>
{{ getTitle(dep.key) }}
</span>
<p> <p>
@if (dep.value.optional) { @if (dep.value.optional) {
<span>({{ 'Optional' | i18n }})</span> <span>({{ 'Optional' | i18n }})</span>
@@ -37,9 +40,7 @@ import { MarketplacePkgBase } from '../../../types'
[content]="descContent" [content]="descContent"
class="description" class="description"
/> />
<ng-template #descContent> <ng-template #descContent>{{ dep.value.description }}</ng-template>
{{ dep.value.description }}
</ng-template>
</div> </div>
</div> </div>
`, `,
@@ -94,7 +95,7 @@ import { MarketplacePkgBase } from '../../../types'
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterModule, TuiAvatar, ExverPipesModule, TuiLineClamp, i18nPipe], imports: [RouterModule, TuiAvatar, TuiLineClamp, i18nPipe],
}) })
export class MarketplaceDepItemComponent { export class MarketplaceDepItemComponent {
private readonly i18nService = inject(i18nService) private readonly i18nService = inject(i18nService)

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { i18nPipe, SharedPipesModule } from '@start9labs/shared' import { i18nPipe, TrustUrlPipe } from '@start9labs/shared'
import { TuiTitle } from '@taiga-ui/core' import { TuiTitle } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit' import { TuiAvatar } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
@@ -47,14 +47,7 @@ import { MarketplacePkg } from '../../types'
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [RouterLink, TuiCell, TuiTitle, TrustUrlPipe, TuiAvatar, i18nPipe],
RouterLink,
TuiCell,
TuiTitle,
SharedPipesModule,
TuiAvatar,
i18nPipe,
],
}) })
export class MarketplaceFlavorsComponent { export class MarketplaceFlavorsComponent {
@Input() @Input()

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { SharedPipesModule, TickerComponent } from '@start9labs/shared' import { TickerComponent } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
@Component({ @Component({
@@ -118,7 +118,7 @@ import { T } from '@start9labs/start-sdk'
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SharedPipesModule, TickerComponent], imports: [TickerComponent],
}) })
export class MarketplacePackageHeroComponent { export class MarketplacePackageHeroComponent {
@Input({ required: true }) @Input({ required: true })

View File

@@ -7,7 +7,7 @@ import {
TemplateRef, TemplateRef,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { DialogService, i18nPipe, SharedPipesModule } from '@start9labs/shared' import { DialogService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core' import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { TuiRadioList } from '@taiga-ui/kit' import { TuiRadioList } from '@taiga-ui/kit'
import { filter } from 'rxjs' import { filter } from 'rxjs'
@@ -76,7 +76,6 @@ import { MarketplaceItemComponent } from './item.component'
imports: [ imports: [
MarketplaceItemComponent, MarketplaceItemComponent,
TuiButton, TuiButton,
SharedPipesModule,
FormsModule, FormsModule,
TuiRadioList, TuiRadioList,
i18nPipe, i18nPipe,

View File

@@ -4,7 +4,6 @@ import Fuse from 'fuse.js'
@Pipe({ @Pipe({
name: 'filterPackages', name: 'filterPackages',
standalone: false,
}) })
export class FilterPackagesPipe implements PipeTransform { export class FilterPackagesPipe implements PipeTransform {
transform( transform(
@@ -79,9 +78,3 @@ export class FilterPackagesPipe implements PipeTransform {
.map(a => ({ ...a })) .map(a => ({ ...a }))
} }
} }
@NgModule({
declarations: [FilterPackagesPipe],
exports: [FilterPackagesPipe],
})
export class FilterPackagesPipeModule {}

View File

@@ -3,11 +3,8 @@
*/ */
export * from './pages/list/categories/categories.component' export * from './pages/list/categories/categories.component'
export * from './pages/list/categories/categories.module'
export * from './pages/list/item/item.component' export * from './pages/list/item/item.component'
export * from './pages/list/item/item.module'
export * from './pages/list/search/search.component' export * from './pages/list/search/search.component'
export * from './pages/list/search/search.module'
export * from './pages/show/link.component' export * from './pages/show/link.component'
export * from './pages/show/item.component' export * from './pages/show/item.component'
export * from './pages/show/links.component' export * from './pages/show/links.component'
@@ -22,10 +19,7 @@ export * from './pages/show/release-notes.component'
export * from './pipes/filter-packages.pipe' export * from './pipes/filter-packages.pipe'
export * from './components/store-icon/store-icon.component' export * from './components/store-icon.component'
export * from './components/store-icon/store-icon.component.module'
export * from './components/store-icon/store-icon.component'
export * from './components/menu/menu.component.module'
export * from './components/menu/menu.component' export * from './components/menu/menu.component'
export * from './components/registry.component' export * from './components/registry.component'

View File

@@ -1,15 +1,17 @@
import { Component, inject, DOCUMENT } from '@angular/core' import { Component, DOCUMENT, inject, OnInit } from '@angular/core'
import { Router } from '@angular/router' import { Router, RouterOutlet } from '@angular/router'
import { ErrorService } from '@start9labs/shared' import { ErrorService } from '@start9labs/shared'
import { TuiRoot } from '@taiga-ui/core'
import { ApiService } from './services/api.service' import { ApiService } from './services/api.service'
import { StateService } from './services/state.service' import { StateService } from './services/state.service'
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
template: '<tui-root tuiTheme="dark"><router-outlet /></tui-root>', template: '<tui-root tuiTheme="dark"><router-outlet /></tui-root>',
standalone: false, imports: [TuiRoot, RouterOutlet],
}) })
export class AppComponent { export class AppComponent implements OnInit {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly router = inject(Router) private readonly router = inject(Router)

View File

@@ -3,9 +3,20 @@ import {
withFetch, withFetch,
withInterceptorsFromDi, withInterceptorsFromDi,
} from '@angular/common/http' } from '@angular/common/http'
import { inject, NgModule, provideAppInitializer } from '@angular/core' import {
import { BrowserAnimationsModule } from '@angular/platform-browser/animations' ApplicationConfig,
import { PreloadAllModules, RouterModule } from '@angular/router' inject,
provideAppInitializer,
provideZoneChangeDetection,
signal,
} from '@angular/core'
import { provideAnimations } from '@angular/platform-browser/animations'
import {
PreloadAllModules,
provideRouter,
withDisabledInitialNavigation,
withPreloading,
} from '@angular/router'
import { WA_LOCATION } from '@ng-web-apis/common' import { WA_LOCATION } from '@ng-web-apis/common'
import initArgon from '@start9labs/argon2' import initArgon from '@start9labs/argon2'
import { import {
@@ -15,13 +26,16 @@ import {
VERSION, VERSION,
WorkspaceConfig, WorkspaceConfig,
} from '@start9labs/shared' } from '@start9labs/shared'
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core' import {
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins' tuiButtonOptionsProvider,
tuiTextfieldOptionsProvider,
} from '@taiga-ui/core'
import { provideEventPlugins } from '@taiga-ui/event-plugins'
import { ROUTES } from './app.routes'
import { ApiService } from './services/api.service' import { ApiService } from './services/api.service'
import { LiveApiService } from './services/live-api.service' import { LiveApiService } from './services/live-api.service'
import { MockApiService } from './services/mock-api.service' import { MockApiService } from './services/mock-api.service'
import { AppComponent } from './app.component'
import { ROUTES } from './app.routes'
const { const {
useMocks, useMocks,
@@ -30,18 +44,16 @@ const {
const version = require('../../../../package.json').version const version = require('../../../../package.json').version
@NgModule({ export const APP_CONFIG: ApplicationConfig = {
declarations: [AppComponent],
imports: [
BrowserAnimationsModule,
RouterModule.forRoot(ROUTES, {
preloadingStrategy: PreloadAllModules,
initialNavigation: 'disabled',
}),
TuiRoot,
],
providers: [ providers: [
NG_EVENT_PLUGINS, provideZoneChangeDetection(),
provideAnimations(),
provideEventPlugins(),
provideRouter(
ROUTES,
withDisabledInitialNavigation(),
withPreloading(PreloadAllModules),
),
I18N_PROVIDERS, I18N_PROVIDERS,
provideSetupLogsService(ApiService), provideSetupLogsService(ApiService),
tuiButtonOptionsProvider({ size: 'm' }), tuiButtonOptionsProvider({ size: 'm' }),
@@ -64,7 +76,6 @@ const version = require('../../../../package.json').version
initArgon({ module_or_path }) initArgon({ module_or_path })
}), }),
tuiTextfieldOptionsProvider({ cleaner: signal(false) }),
], ],
bootstrap: [AppComponent], }
})
export class AppModule {}

View File

@@ -5,7 +5,6 @@ import { TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus' import { injectContext } from '@taiga-ui/polymorpheus'
@Component({ @Component({
standalone: true,
imports: [TuiButton, i18nPipe], imports: [TuiButton, i18nPipe],
template: ` template: `
<p>{{ 'This drive contains existing StartOS data.' | i18n }}</p> <p>{{ 'This drive contains existing StartOS data.' | i18n }}</p>

View File

@@ -4,7 +4,6 @@ import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus' import { injectContext } from '@taiga-ui/polymorpheus'
@Component({ @Component({
standalone: true,
imports: [TuiButton, i18nPipe], imports: [TuiButton, i18nPipe],
template: ` template: `
<div class="animation-container"> <div class="animation-container">

View File

@@ -11,7 +11,6 @@ interface Data {
} }
@Component({ @Component({
standalone: true,
imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper, i18nPipe], imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper, i18nPipe],
template: ` template: `
<p>{{ 'Multiple backups found. Select which one to restore.' | i18n }}</p> <p>{{ 'Multiple backups found. Select which one to restore.' | i18n }}</p>

View File

@@ -11,7 +11,6 @@ import { TuiPassword } from '@taiga-ui/kit'
import { injectContext } from '@taiga-ui/polymorpheus' import { injectContext } from '@taiga-ui/polymorpheus'
@Component({ @Component({
standalone: true,
imports: [ imports: [
FormsModule, FormsModule,
TuiButton, TuiButton,

View File

@@ -34,110 +34,121 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
@Component({ @Component({
template: ` template: `
@if (!shuttingDown) { @if (!shuttingDown) {
<section tuiCardLarge="compact"> <section tuiCardLarge="compact">
<header tuiHeader> <header tuiHeader>
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2> <h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
</header> </header>
@if (loading) { @if (loading) {
<tui-loader /> <tui-loader />
} @else if (drives.length === 0) { } @else if (drives.length === 0) {
<p class="no-drives"> <p class="no-drives">
{{ {{
'No drives found. Please connect a drive and click Refresh.' | i18n 'No drives found. Please connect a drive and click Refresh.'
}} | i18n
</p> }}
} @else { </p>
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedOsDrive"
[items]="drives"
></select>
} @else {
<input tuiSelect [(ngModel)]="selectedOsDrive" />
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
<tui-icon [tuiTooltip]="osDriveTooltip" />
</tui-textfield>
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
[items]="drives"
></select>
} @else {
<input
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
@if (preserveData === true) {
<tui-icon
icon="@tui.database"
style="color: var(--tui-status-positive); pointer-events: none"
/>
}
@if (preserveData === false) {
<tui-icon
icon="@tui.database-zap"
style="color: var(--tui-status-negative); pointer-events: none"
/>
}
<tui-icon [tuiTooltip]="dataDriveTooltip" />
</tui-textfield>
<ng-template #driveContent let-drive>
<div class="drive-item">
<span class="drive-name">
{{ drive.vendor || ('Unknown' | i18n) }}
{{ drive.model || ('Drive' | i18n) }}
</span>
<small>
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
</small>
</div>
</ng-template>
}
<footer>
@if (drives.length === 0) {
<button tuiButton appearance="secondary" (click)="refresh()">
{{ 'Refresh' | i18n }}
</button>
} @else { } @else {
<button <tui-textfield
tuiButton [stringify]="stringify"
[disabled]="!selectedOsDrive || !selectedDataDrive" [disabledItemHandler]="osDisabled"
(click)="continue()"
> >
{{ 'Continue' | i18n }} <label tuiLabel>{{ 'OS Drive' | i18n }}</label>
</button> @if (mobile) {
<select
tuiSelect
[ngModel]="selectedOsDrive"
(ngModelChange)="onOsDriveChange($event)"
[items]="drives"
></select>
} @else {
<input
tuiSelect
[ngModel]="selectedOsDrive"
(ngModelChange)="onOsDriveChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
<tui-icon [tuiTooltip]="osDriveTooltip" />
</tui-textfield>
<tui-textfield
[stringify]="stringify"
[disabledItemHandler]="dataDisabled"
>
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
@if (mobile) {
<select
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
[items]="drives"
></select>
} @else {
<input
tuiSelect
[(ngModel)]="selectedDataDrive"
(ngModelChange)="onDataDriveChange($event)"
/>
}
@if (!mobile) {
<tui-data-list-wrapper
new
*tuiTextfieldDropdown
[items]="drives"
[itemContent]="driveContent"
/>
}
@if (preserveData === true) {
<tui-icon
icon="@tui.database"
style="color: var(--tui-status-positive); pointer-events: none"
/>
}
@if (preserveData === false) {
<tui-icon
icon="@tui.database-zap"
style="color: var(--tui-status-negative); pointer-events: none"
/>
}
<tui-icon [tuiTooltip]="dataDriveTooltip" />
</tui-textfield>
<ng-template #driveContent let-drive>
<div class="drive-item">
<span class="drive-name">
{{ driveName(drive) }}
</span>
<small>
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
</small>
</div>
</ng-template>
} }
</footer>
</section> <footer>
@if (drives.length === 0) {
<button tuiButton appearance="secondary" (click)="refresh()">
{{ 'Refresh' | i18n }}
</button>
} @else {
<button
tuiButton
[disabled]="!selectedOsDrive || !selectedDataDrive"
(click)="continue()"
>
{{ 'Continue' | i18n }}
</button>
}
</footer>
</section>
} }
`, `,
styles: ` styles: `
@@ -198,6 +209,10 @@ export default class DrivesPage {
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.', 'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
) )
private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB
private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB
private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB
drives: DiskInfo[] = [] drives: DiskInfo[] = []
loading = true loading = true
shuttingDown = false shuttingDown = false
@@ -206,10 +221,17 @@ export default class DrivesPage {
selectedDataDrive: DiskInfo | null = null selectedDataDrive: DiskInfo | null = null
preserveData: boolean | null = null preserveData: boolean | null = null
readonly osDisabled = (drive: DiskInfo): boolean =>
drive.capacity < this.MIN_OS
dataDisabled = (drive: DiskInfo): boolean => drive.capacity < this.MIN_DATA
readonly driveName = (drive: DiskInfo): string =>
[drive.vendor, drive.model].filter(Boolean).join(' ') ||
this.i18n.transform('Unknown Drive')
readonly stringify = (drive: DiskInfo | null) => readonly stringify = (drive: DiskInfo | null) =>
drive drive ? this.driveName(drive) : ''
? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}`
: ''
formatCapacity(bytes: number): string { formatCapacity(bytes: number): string {
const gb = bytes / 1e9 const gb = bytes / 1e9
@@ -231,6 +253,22 @@ export default class DrivesPage {
await this.loadDrives() await this.loadDrives()
} }
onOsDriveChange(osDrive: DiskInfo | null) {
this.selectedOsDrive = osDrive
this.dataDisabled = (drive: DiskInfo) => {
if (osDrive && drive.logicalname === osDrive.logicalname) {
return drive.capacity < this.MIN_BOTH
}
return drive.capacity < this.MIN_DATA
}
// Clear data drive if it's now invalid
if (this.selectedDataDrive && this.dataDisabled(this.selectedDataDrive)) {
this.selectedDataDrive = null
this.preserveData = null
}
}
onDataDriveChange(drive: DiskInfo | null) { onDataDriveChange(drive: DiskInfo | null) {
this.preserveData = null this.preserveData = null
@@ -400,7 +438,7 @@ export default class DrivesPage {
private async loadDrives() { private async loadDrives() {
try { try {
this.drives = await this.api.getDisks() this.drives = (await this.api.getDisks()).filter(d => d.capacity > 0)
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {

View File

@@ -1,5 +1,4 @@
import { Component, inject, signal } from '@angular/core' import { Component, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { import {
getAllKeyboardsSorted, getAllKeyboardsSorted,
@@ -72,7 +71,6 @@ import { StateService } from '../services/state.service'
], ],
}) })
export default class KeyboardPage { export default class KeyboardPage {
private readonly router = inject(Router)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly stateService = inject(StateService) private readonly stateService = inject(StateService)
@@ -103,22 +101,9 @@ export default class KeyboardPage {
}) })
this.stateService.keyboard = this.selected.layout this.stateService.keyboard = this.selected.layout
await this.navigateToNextStep() await this.stateService.navigateAfterLocale()
} finally { } finally {
this.saving.set(false) this.saving.set(false)
} }
} }
private async navigateToNextStep() {
if (this.stateService.dataDriveGuid) {
if (this.stateService.attach) {
this.stateService.setupType = 'attach'
await this.router.navigate(['/password'])
} else {
await this.router.navigate(['/home'])
}
} else {
await this.router.navigate(['/drives'])
}
}
} }

View File

@@ -141,8 +141,12 @@ export default class LanguagePage {
try { try {
await this.api.setLanguage({ language: this.selected.name }) await this.api.setLanguage({ language: this.selected.name })
// Always go to keyboard selection
await this.router.navigate(['/keyboard']) if (this.stateService.kiosk) {
await this.router.navigate(['/keyboard'])
} else {
await this.stateService.navigateAfterLocale()
}
} finally { } finally {
this.saving.set(false) this.saving.set(false)
} }

View File

@@ -286,7 +286,7 @@ export default class SuccessPage implements AfterViewInit {
while (attempts < maxAttempts) { while (attempts < maxAttempts) {
try { try {
await this.api.echo({ message: 'ping' }, this.lanAddress) await this.api.echo({ message: 'ping' }, `${this.lanAddress}/rpc/v1`)
return return
} catch { } catch {
await new Promise(resolve => setTimeout(resolve, 5000)) await new Promise(resolve => setTimeout(resolve, 5000))

View File

@@ -191,7 +191,118 @@ export class MockApiService extends ApiService {
} }
} }
const GiB = 2 ** 30
const MOCK_DISKS: DiskInfo[] = [ const MOCK_DISKS: DiskInfo[] = [
// 0 capacity - should be hidden entirely
{
logicalname: '/dev/sdd',
vendor: 'Generic',
model: 'Card Reader',
partitions: [],
capacity: 0,
guid: null,
},
// 10 GiB - too small for OS and data; also tests both vendor+model null
{
logicalname: '/dev/sde',
vendor: null,
model: null,
partitions: [
{
logicalname: '/dev/sde1',
label: null,
capacity: 10 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 10 * GiB,
guid: null,
},
// 18 GiB - exact OS boundary; tests vendor null with model present
{
logicalname: '/dev/sdf',
vendor: null,
model: 'SATA Flash Drive',
partitions: [
{
logicalname: '/dev/sdf1',
label: null,
capacity: 18 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 18 * GiB,
guid: null,
},
// 20 GiB - exact data boundary; tests vendor present with model null
{
logicalname: '/dev/sdg',
vendor: 'PNY',
model: null,
partitions: [
{
logicalname: '/dev/sdg1',
label: null,
capacity: 20 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 20 * GiB,
guid: null,
},
// 30 GiB - OK for OS or data alone, too small for both (< 38 GiB)
{
logicalname: '/dev/sdh',
vendor: 'SanDisk',
model: 'Ultra',
partitions: [
{
logicalname: '/dev/sdh1',
label: null,
capacity: 30 * GiB,
used: null,
startOs: {},
guid: null,
},
],
capacity: 30 * GiB,
guid: null,
},
// 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint
{
logicalname: '/dev/sdi',
vendor: 'Kingston',
model: 'A400',
partitions: [
{
logicalname: '/dev/sdi1',
label: null,
capacity: 30 * GiB,
used: null,
startOs: {
'small-server-id': {
hostname: 'small-server',
version: '0.3.6',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: 'small-existing-guid',
},
],
capacity: 30 * GiB,
guid: 'small-existing-guid',
},
// 500 GB - large, always OK
{ {
logicalname: '/dev/sda', logicalname: '/dev/sda',
vendor: 'Samsung', vendor: 'Samsung',
@@ -209,6 +320,7 @@ const MOCK_DISKS: DiskInfo[] = [
capacity: 500000000000, capacity: 500000000000,
guid: null, guid: null,
}, },
// 1 TB with existing StartOS data
{ {
logicalname: '/dev/sdb', logicalname: '/dev/sdb',
vendor: 'Crucial', vendor: 'Crucial',
@@ -235,6 +347,7 @@ const MOCK_DISKS: DiskInfo[] = [
capacity: 1000000000000, capacity: 1000000000000,
guid: 'existing-guid', guid: 'existing-guid',
}, },
// 2 TB
{ {
logicalname: '/dev/sdc', logicalname: '/dev/sdc',
vendor: 'WD', vendor: 'WD',

View File

@@ -1,4 +1,5 @@
import { inject, Injectable } from '@angular/core' import { inject, Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { ApiService } from './api.service' import { ApiService } from './api.service'
@@ -29,6 +30,7 @@ export type RecoverySource =
}) })
export class StateService { export class StateService {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly router = inject(Router)
// Determined at app init // Determined at app init
kiosk = false kiosk = false
@@ -45,6 +47,23 @@ export class StateService {
setupType?: SetupType setupType?: SetupType
recoverySource?: RecoverySource recoverySource?: RecoverySource
/**
* Navigate to the appropriate step after language/keyboard selection.
* Keyboard selection is only needed in kiosk mode.
*/
async navigateAfterLocale(): Promise<void> {
if (this.dataDriveGuid) {
if (this.attach) {
this.setupType = 'attach'
await this.router.navigate(['/password'])
} else {
await this.router.navigate(['/home'])
}
} else {
await this.router.navigate(['/drives'])
}
}
/** /**
* Called for attach flow (existing data drive) * Called for attach flow (existing data drive)
*/ */

View File

@@ -1,13 +1,11 @@
import { enableProdMode } from '@angular/core' import { enableProdMode } from '@angular/core'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' import { bootstrapApplication } from '@angular/platform-browser'
import { AppComponent } from 'src/app/app.component'
import { AppModule } from './app/app.module' import { APP_CONFIG } from 'src/app/app.config'
import { environment } from './environments/environment' import { environment } from 'src/environments/environment'
if (environment.production) { if (environment.production) {
enableProdMode() enableProdMode()
} }
platformBrowserDynamic() bootstrapApplication(AppComponent, APP_CONFIG).catch(console.error)
.bootstrapModule(AppModule)
.catch(err => console.error(err))

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M22.7 17.21h-3.83v-1.975c0-1.572-1.3-2.86-2.86-2.86s-2.86 1.3-2.86 2.86v1.975H9.33v-1.975c0-3.708 3.023-6.7 6.7-6.7 3.708 0 6.7 3.023 6.7 6.7z" fill="#ffa400"/><path d="M24.282 17.21H7.758a1.27 1.27 0 0 0-1.29 1.29V30.7A1.27 1.27 0 0 0 7.758 32h16.524a1.27 1.27 0 0 0 1.29-1.29V18.5c-.04-.725-.605-1.3-1.3-1.3zm-7.456 8.02v1.652c0 .443-.363.846-.846.846-.443 0-.846-.363-.846-.846V25.23c-.524-.282-.846-.846-.846-1.49 0-.927.766-1.693 1.693-1.693s1.693.766 1.693 1.693c.04.645-.322 1.21-.846 1.49z" fill="#003a70"/><path d="M6.066 15.395h-4a1.17 1.17 0 0 1-1.169-1.169 1.17 1.17 0 0 1 1.169-1.169h4a1.17 1.17 0 0 1 1.169 1.169 1.17 1.17 0 0 1-1.169 1.169zm2.82-6.287a1.03 1.03 0 0 1-.725-.282l-3.144-2.58c-.484-.403-.564-1.128-.16-1.652.403-.484 1.128-.564 1.652-.16l3.144 2.58c.484.403.564 1.128.16 1.652-.282.282-.605.443-.927.443zm7.134-2.74a1.17 1.17 0 0 1-1.169-1.169V1.17A1.17 1.17 0 0 1 16.02 0a1.17 1.17 0 0 1 1.169 1.169V5.2a1.17 1.17 0 0 1-1.169 1.169zm7.093 2.74c-.322 0-.685-.16-.887-.443-.403-.484-.322-1.25.16-1.652l3.144-2.58c.484-.403 1.25-.322 1.652.16s.322 1.25-.16 1.652l-3.144 2.58a1.13 1.13 0 0 1-.766.282zm6.81 6.287h-4.03a1.17 1.17 0 0 1-1.169-1.169 1.17 1.17 0 0 1 1.169-1.169h4.03a1.17 1.17 0 0 1 1.169 1.169 1.17 1.17 0 0 1-1.169 1.169z" fill="#ffa400"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -16,7 +16,7 @@ export const VERSION = new InjectionToken<string>('VERSION')
host: { host: {
target: '_blank', target: '_blank',
rel: 'noreferrer', rel: 'noreferrer',
'[href]': 'url()', '[attr.href]': 'url()',
}, },
}) })
export class DocsLinkDirective { export class DocsLinkDirective {

View File

@@ -360,7 +360,6 @@ export default {
377: 'StartOS-Sicherungen erkannt', 377: 'StartOS-Sicherungen erkannt',
378: 'Keine StartOS-Sicherungen erkannt', 378: 'Keine StartOS-Sicherungen erkannt',
379: 'StartOS-Version', 379: 'StartOS-Version',
381: 'SMTP-Zugangsdaten',
382: 'Test-E-Mail senden', 382: 'Test-E-Mail senden',
383: 'Senden', 383: 'Senden',
384: 'E-Mail wird gesendet', 384: 'E-Mail wird gesendet',
@@ -644,7 +643,6 @@ export default {
706: 'Beibehalten', 706: 'Beibehalten',
707: 'Überschreiben', 707: 'Überschreiben',
708: 'Entsperren', 708: 'Entsperren',
709: 'Laufwerk',
710: 'Übertragen', 710: 'Übertragen',
711: 'Die Liste ist leer', 711: 'Die Liste ist leer',
712: 'Jetzt neu starten', 712: 'Jetzt neu starten',
@@ -659,8 +657,6 @@ export default {
721: 'Gateway für ausgehenden Datenverkehr auswählen', 721: 'Gateway für ausgehenden Datenverkehr auswählen',
722: 'Der Typ des Gateways', 722: 'Der Typ des Gateways',
723: 'Nur ausgehend', 723: 'Nur ausgehend',
724: 'Als Standard für ausgehenden Verkehr festlegen',
725: 'Gesamten ausgehenden Datenverkehr über dieses Gateway leiten',
726: 'WireGuard-Konfigurationsdatei', 726: 'WireGuard-Konfigurationsdatei',
727: 'Eingehend/Ausgehend', 727: 'Eingehend/Ausgehend',
728: 'StartTunnel (Eingehend/Ausgehend)', 728: 'StartTunnel (Eingehend/Ausgehend)',
@@ -669,7 +665,6 @@ export default {
731: 'Öffentliche Domain', 731: 'Öffentliche Domain',
732: 'Private Domain', 732: 'Private Domain',
733: 'Ausblenden', 733: 'Ausblenden',
734: 'Standard ausgehend',
735: 'Zertifikat', 735: 'Zertifikat',
736: 'Selbstsigniert', 736: 'Selbstsigniert',
737: 'Portweiterleitung', 737: 'Portweiterleitung',
@@ -704,4 +699,14 @@ export default {
774: 'Der Portstatus kann nicht ermittelt werden, solange der Dienst nicht läuft', 774: 'Der Portstatus kann nicht ermittelt werden, solange der Dienst nicht läuft',
775: 'Diese Adresse funktioniert nicht aus Ihrem lokalen Netzwerk aufgrund einer Router-Hairpinning-Einschränkung', 775: 'Diese Adresse funktioniert nicht aus Ihrem lokalen Netzwerk aufgrund einer Router-Hairpinning-Einschränkung',
776: 'Aktion nicht gefunden', 776: 'Aktion nicht gefunden',
777: 'Diese Domain wird auch gelten für',
778: 'Plugin',
779: 'Öffentlich',
780: 'Privat',
781: 'Lokal',
782: 'Unbekanntes Laufwerk',
783: 'Muss eine gültige E-Mail-Adresse sein',
786: 'Automatisch',
787: 'Ausgehender Datenverkehr',
788: 'Gateway verwenden',
} satisfies i18n } satisfies i18n

View File

@@ -359,7 +359,6 @@ export const ENGLISH: Record<string, number> = {
'StartOS backups detected': 377, 'StartOS backups detected': 377,
'No StartOS backups detected': 378, 'No StartOS backups detected': 378,
'StartOS Version': 379, 'StartOS Version': 379,
'SMTP Credentials': 381,
'Send test email': 382, 'Send test email': 382,
'Send': 383, 'Send': 383,
'Sending email': 384, 'Sending email': 384,
@@ -644,7 +643,6 @@ export const ENGLISH: Record<string, number> = {
'Preserve': 706, 'Preserve': 706,
'Overwrite': 707, 'Overwrite': 707,
'Unlock': 708, 'Unlock': 708,
'Drive': 709, // the noun, a storage device
'Transfer': 710, // the verb 'Transfer': 710, // the verb
'The list is empty': 711, 'The list is empty': 711,
'Restart now': 712, 'Restart now': 712,
@@ -659,8 +657,6 @@ export const ENGLISH: Record<string, number> = {
'Select the gateway for outbound traffic': 721, 'Select the gateway for outbound traffic': 721,
'The type of gateway': 722, 'The type of gateway': 722,
'Outbound Only': 723, 'Outbound Only': 723,
'Set as default outbound': 724,
'Route all outbound traffic through this gateway': 725,
'WireGuard Config File': 726, 'WireGuard Config File': 726,
'Inbound/Outbound': 727, 'Inbound/Outbound': 727,
'StartTunnel (Inbound/Outbound)': 728, 'StartTunnel (Inbound/Outbound)': 728,
@@ -669,7 +665,6 @@ export const ENGLISH: Record<string, number> = {
'Public Domain': 731, 'Public Domain': 731,
'Private Domain': 732, 'Private Domain': 732,
'Hide': 733, 'Hide': 733,
'default outbound': 734,
'Certificate': 735, 'Certificate': 735,
'Self signed': 736, 'Self signed': 736,
'Port Forwarding': 737, 'Port Forwarding': 737,
@@ -704,4 +699,14 @@ export const ENGLISH: Record<string, number> = {
'Port status cannot be determined while service is not running': 774, 'Port status cannot be determined while service is not running': 774,
'This address will not work from your local network due to a router hairpinning limitation': 775, 'This address will not work from your local network due to a router hairpinning limitation': 775,
'Action not found': 776, 'Action not found': 776,
'This domain will also apply to': 777,
'Plugin': 778,
'Public': 779, // as in, publicly accessible
'Private': 780, // as in, privately accessible
'Local': 781, // as in, locally accessible
'Unknown Drive': 782,
'Must be a valid email address': 783,
'Auto': 786,
'Outbound Traffic': 787,
'Use gateway': 788,
} }

View File

@@ -360,7 +360,6 @@ export default {
377: 'Copias de seguridad de StartOS detectadas', 377: 'Copias de seguridad de StartOS detectadas',
378: 'No se detectaron copias de seguridad de StartOS', 378: 'No se detectaron copias de seguridad de StartOS',
379: 'Versión de StartOS', 379: 'Versión de StartOS',
381: 'Credenciales SMTP',
382: 'Enviar correo de prueba', 382: 'Enviar correo de prueba',
383: 'Enviar', 383: 'Enviar',
384: 'Enviando correo', 384: 'Enviando correo',
@@ -644,7 +643,6 @@ export default {
706: 'Conservar', 706: 'Conservar',
707: 'Sobrescribir', 707: 'Sobrescribir',
708: 'Desbloquear', 708: 'Desbloquear',
709: 'Unidad',
710: 'Transferir', 710: 'Transferir',
711: 'La lista está vacía', 711: 'La lista está vacía',
712: 'Reiniciar ahora', 712: 'Reiniciar ahora',
@@ -659,8 +657,6 @@ export default {
721: 'Selecciona la puerta de enlace para el tráfico saliente', 721: 'Selecciona la puerta de enlace para el tráfico saliente',
722: 'El tipo de puerta de enlace', 722: 'El tipo de puerta de enlace',
723: 'Solo saliente', 723: 'Solo saliente',
724: 'Establecer como saliente predeterminado',
725: 'Enrutar todo el tráfico saliente a través de esta puerta de enlace',
726: 'Archivo de configuración WireGuard', 726: 'Archivo de configuración WireGuard',
727: 'Entrante/Saliente', 727: 'Entrante/Saliente',
728: 'StartTunnel (Entrante/Saliente)', 728: 'StartTunnel (Entrante/Saliente)',
@@ -669,7 +665,6 @@ export default {
731: 'Dominio público', 731: 'Dominio público',
732: 'Dominio privado', 732: 'Dominio privado',
733: 'Ocultar', 733: 'Ocultar',
734: 'saliente predeterminado',
735: 'Certificado', 735: 'Certificado',
736: 'Autofirmado', 736: 'Autofirmado',
737: 'Reenvío de puertos', 737: 'Reenvío de puertos',
@@ -704,4 +699,14 @@ export default {
774: 'El estado del puerto no se puede determinar mientras el servicio no está en ejecución', 774: 'El estado del puerto no se puede determinar mientras el servicio no está en ejecución',
775: 'Esta dirección no funcionará desde tu red local debido a una limitación de hairpinning del router', 775: 'Esta dirección no funcionará desde tu red local debido a una limitación de hairpinning del router',
776: 'Acción no encontrada', 776: 'Acción no encontrada',
777: 'Este dominio también se aplicará a',
778: 'Plugin',
779: 'Público',
780: 'Privado',
781: 'Local',
782: 'Unidad desconocida',
783: 'Debe ser una dirección de correo electrónico válida',
786: 'Automático',
787: 'Tráfico saliente',
788: 'Usar gateway',
} satisfies i18n } satisfies i18n

View File

@@ -360,7 +360,6 @@ export default {
377: 'Sauvegardes StartOS détectées', 377: 'Sauvegardes StartOS détectées',
378: 'Aucune sauvegarde StartOS détectée', 378: 'Aucune sauvegarde StartOS détectée',
379: 'Version de StartOS', 379: 'Version de StartOS',
381: 'Identifiants SMTP',
382: 'Envoyer un email de test', 382: 'Envoyer un email de test',
383: 'Envoyer', 383: 'Envoyer',
384: 'Envoi de lemail', 384: 'Envoi de lemail',
@@ -644,7 +643,6 @@ export default {
706: 'Conserver', 706: 'Conserver',
707: 'Écraser', 707: 'Écraser',
708: 'Déverrouiller', 708: 'Déverrouiller',
709: 'Disque',
710: 'Transférer', 710: 'Transférer',
711: 'La liste est vide', 711: 'La liste est vide',
712: 'Redémarrer maintenant', 712: 'Redémarrer maintenant',
@@ -659,8 +657,6 @@ export default {
721: 'Sélectionnez la passerelle pour le trafic sortant', 721: 'Sélectionnez la passerelle pour le trafic sortant',
722: 'Le type de passerelle', 722: 'Le type de passerelle',
723: 'Sortant uniquement', 723: 'Sortant uniquement',
724: 'Définir comme sortant par défaut',
725: 'Acheminer tout le trafic sortant via cette passerelle',
726: 'Fichier de configuration WireGuard', 726: 'Fichier de configuration WireGuard',
727: 'Entrant/Sortant', 727: 'Entrant/Sortant',
728: 'StartTunnel (Entrant/Sortant)', 728: 'StartTunnel (Entrant/Sortant)',
@@ -669,7 +665,6 @@ export default {
731: 'Domaine public', 731: 'Domaine public',
732: 'Domaine privé', 732: 'Domaine privé',
733: 'Masquer', 733: 'Masquer',
734: 'sortant par défaut',
735: 'Certificat', 735: 'Certificat',
736: 'Auto-signé', 736: 'Auto-signé',
737: 'Redirection de ports', 737: 'Redirection de ports',
@@ -704,4 +699,14 @@ export default {
774: "L'état du port ne peut pas être déterminé tant que le service n'est pas en cours d'exécution", 774: "L'état du port ne peut pas être déterminé tant que le service n'est pas en cours d'exécution",
775: "Cette adresse ne fonctionnera pas depuis votre réseau local en raison d'une limitation de hairpinning du routeur", 775: "Cette adresse ne fonctionnera pas depuis votre réseau local en raison d'une limitation de hairpinning du routeur",
776: 'Action introuvable', 776: 'Action introuvable',
777: "Ce domaine s'appliquera également à",
778: 'Plugin',
779: 'Public',
780: 'Privé',
781: 'Local',
782: 'Lecteur inconnu',
783: 'Doit être une adresse e-mail valide',
786: 'Automatique',
787: 'Trafic sortant',
788: 'Utiliser la passerelle',
} satisfies i18n } satisfies i18n

View File

@@ -360,7 +360,6 @@ export default {
377: 'Wykryto kopie zapasowe StartOS', 377: 'Wykryto kopie zapasowe StartOS',
378: 'Nie wykryto kopii zapasowych StartOS', 378: 'Nie wykryto kopii zapasowych StartOS',
379: 'Wersja StartOS', 379: 'Wersja StartOS',
381: 'Dane logowania SMTP',
382: 'Wyślij e-mail testowy', 382: 'Wyślij e-mail testowy',
383: 'Wyślij', 383: 'Wyślij',
384: 'Wysyłanie e-maila', 384: 'Wysyłanie e-maila',
@@ -644,7 +643,6 @@ export default {
706: 'Zachowaj', 706: 'Zachowaj',
707: 'Nadpisz', 707: 'Nadpisz',
708: 'Odblokuj', 708: 'Odblokuj',
709: 'Dysk',
710: 'Przenieś', 710: 'Przenieś',
711: 'Lista jest pusta', 711: 'Lista jest pusta',
712: 'Uruchom ponownie teraz', 712: 'Uruchom ponownie teraz',
@@ -659,8 +657,6 @@ export default {
721: 'Wybierz bramę dla ruchu wychodzącego', 721: 'Wybierz bramę dla ruchu wychodzącego',
722: 'Typ bramy', 722: 'Typ bramy',
723: 'Tylko wychodzący', 723: 'Tylko wychodzący',
724: 'Ustaw jako domyślne wychodzące',
725: 'Kieruj cały ruch wychodzący przez tę bramę',
726: 'Plik konfiguracyjny WireGuard', 726: 'Plik konfiguracyjny WireGuard',
727: 'Przychodzący/Wychodzący', 727: 'Przychodzący/Wychodzący',
728: 'StartTunnel (Przychodzący/Wychodzący)', 728: 'StartTunnel (Przychodzący/Wychodzący)',
@@ -669,7 +665,6 @@ export default {
731: 'Domena publiczna', 731: 'Domena publiczna',
732: 'Domena prywatna', 732: 'Domena prywatna',
733: 'Ukryj', 733: 'Ukryj',
734: 'domyślne wychodzące',
735: 'Certyfikat', 735: 'Certyfikat',
736: 'Samopodpisany', 736: 'Samopodpisany',
737: 'Przekierowanie portów', 737: 'Przekierowanie portów',
@@ -704,4 +699,14 @@ export default {
774: 'Status portu nie może być określony, gdy usługa nie jest uruchomiona', 774: 'Status portu nie może być określony, gdy usługa nie jest uruchomiona',
775: 'Ten adres nie będzie działać z Twojej sieci lokalnej z powodu ograniczenia hairpinning routera', 775: 'Ten adres nie będzie działać z Twojej sieci lokalnej z powodu ograniczenia hairpinning routera',
776: 'Nie znaleziono akcji', 776: 'Nie znaleziono akcji',
777: 'Ta domena będzie również dotyczyć',
778: 'Wtyczka',
779: 'Publiczny',
780: 'Prywatny',
781: 'Lokalny',
782: 'Nieznany dysk',
783: 'Musi być prawidłowy adres e-mail',
786: 'Automatycznie',
787: 'Ruch wychodzący',
788: 'Użyj bramy',
} satisfies i18n } satisfies i18n

View File

@@ -0,0 +1,22 @@
import { Pipe, PipeTransform } from '@angular/core'
// converts bytes to gigabytes
@Pipe({
name: 'convertBytes',
})
export class ConvertBytesPipe implements PipeTransform {
transform(bytes: number): string {
return convertBytes(bytes)
}
}
export function convertBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']

View File

@@ -1,13 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core' import { Pipe, PipeTransform } from '@angular/core'
import { isEmptyObject } from '../../util/misc.util' import { isEmptyObject } from '../util/misc.util'
@Pipe({ @Pipe({
name: 'empty', name: 'empty',
standalone: false,
}) })
export class EmptyPipe implements PipeTransform { export class EmptyPipe implements PipeTransform {
transform(val: object | [] = {}): boolean { transform(val: object | [] = {}): boolean {
if (Array.isArray(val)) return !val.length return Array.isArray(val) ? !val.length : isEmptyObject(val)
return isEmptyObject(val)
} }
} }

View File

@@ -1,28 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core' import { inject, Pipe, PipeTransform } from '@angular/core'
import { Exver } from '../../services/exver.service' import { Exver } from '../services/exver.service'
@Pipe({
name: 'satisfiesExver',
standalone: false,
})
export class ExverSatisfiesPipe implements PipeTransform {
constructor(private readonly exver: Exver) {}
transform(versionUnderTest?: string, range?: string): boolean {
return (
!!versionUnderTest &&
!!range &&
this.exver.satisfies(versionUnderTest, range)
)
}
}
@Pipe({ @Pipe({
name: 'compareExver', name: 'compareExver',
standalone: false,
}) })
export class ExverComparesPipe implements PipeTransform { export class ExverComparesPipe implements PipeTransform {
constructor(private readonly exver: Exver) {} private readonly exver = inject(Exver)
transform(first: string, second: string): SemverResult { transform(first: string, second: string): SemverResult {
try { try {

View File

@@ -1,8 +0,0 @@
import { NgModule } from '@angular/core'
import { ExverComparesPipe, ExverSatisfiesPipe } from './exver.pipe'
@NgModule({
declarations: [ExverComparesPipe, ExverSatisfiesPipe],
exports: [ExverComparesPipe, ExverSatisfiesPipe],
})
export class ExverPipesModule {}

View File

@@ -1,11 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
@Pipe({
name: 'includes',
standalone: false,
})
export class IncludesPipe implements PipeTransform {
transform<T>(list: T[], val: T): boolean {
return list.includes(val)
}
}

View File

@@ -1,10 +0,0 @@
import { NgModule } from '@angular/core'
import { IncludesPipe } from './includes.pipe'
import { EmptyPipe } from './empty.pipe'
import { TrustUrlPipe } from './trust.pipe'
@NgModule({
declarations: [IncludesPipe, EmptyPipe, TrustUrlPipe],
exports: [IncludesPipe, EmptyPipe, TrustUrlPipe],
})
export class SharedPipesModule {}

View File

@@ -1,35 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
@Pipe({
name: 'sort',
standalone: false,
})
export class SortPipe implements PipeTransform {
transform(
value: any[],
column: string = '',
direction: string = 'asc',
): any[] {
// If the value is not an array or is empty, return the original value
if (!Array.isArray(value) || value.length === 0) {
return value
}
// Clone the array to avoid modifying the original value
const sortedValue = [...value]
// Define the sorting function based on the column and direction parameters
const sortingFn = (a: any, b: any): number => {
if (a[column] < b[column]) {
return direction === 'asc' ? -1 : 1
} else if (a[column] > b[column]) {
return direction === 'asc' ? 1 : -1
} else {
return 0
}
}
// Sort the array and return the result
return sortedValue.sort(sortingFn)
}
}

View File

@@ -1,12 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core' import { inject, Pipe, PipeTransform } from '@angular/core'
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
@Pipe({ @Pipe({
name: 'trustUrl', name: 'trustUrl',
standalone: false,
}) })
export class TrustUrlPipe implements PipeTransform { export class TrustUrlPipe implements PipeTransform {
constructor(private readonly sanitizer: DomSanitizer) {} private readonly sanitizer = inject(DomSanitizer)
transform(base64Icon: string): SafeResourceUrl { transform(base64Icon: string): SafeResourceUrl {
return this.sanitizer.bypassSecurityTrustResourceUrl(base64Icon) return this.sanitizer.bypassSecurityTrustResourceUrl(base64Icon)

View File

@@ -1,8 +0,0 @@
import { NgModule } from '@angular/core'
import { ConvertBytesPipe, DurationToSecondsPipe } from './unit-conversion.pipe'
@NgModule({
declarations: [ConvertBytesPipe, DurationToSecondsPipe],
exports: [ConvertBytesPipe, DurationToSecondsPipe],
})
export class UnitConversionPipesModule {}

View File

@@ -1,49 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
// converts bytes to gigabytes
@Pipe({
name: 'convertBytes',
standalone: false,
})
export class ConvertBytesPipe implements PipeTransform {
transform(bytes: number): string {
return convertBytes(bytes)
}
}
export function convertBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
@Pipe({
name: 'durationToSeconds',
standalone: false,
})
export class DurationToSecondsPipe implements PipeTransform {
transform(duration?: string | null): number {
if (!duration) return 0
const regex = /^([0-9]*(\.[0-9]+)?)(ns|µs|ms|s|m|d)$/
const [, num, , unit] = duration.match(regex) || []
const multiplier = (unit && unitsToSeconds[unit]) || NaN
return unit ? Number(num) * multiplier : NaN
}
}
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const unitsToSeconds: Record<string, number> = {
ns: 1e-9,
µs: 1e-6,
ms: 0.001,
s: 1,
m: 60,
h: 3600,
d: 86400,
}

View File

@@ -20,14 +20,10 @@ export * from './i18n/i18n.providers'
export * from './i18n/i18n.service' export * from './i18n/i18n.service'
export * from './i18n/localize.pipe' export * from './i18n/localize.pipe'
export * from './pipes/exver/exver.module' export * from './pipes/exver-compares.pipe'
export * from './pipes/exver/exver.pipe' export * from './pipes/empty.pipe'
export * from './pipes/shared/shared.module' export * from './pipes/trust.pipe'
export * from './pipes/shared/empty.pipe' export * from './pipes/convert-bytes.pipe'
export * from './pipes/shared/includes.pipe'
export * from './pipes/shared/trust.pipe'
export * from './pipes/unit-conversion/unit-conversion.module'
export * from './pipes/unit-conversion/unit-conversion.pipe'
export * from './pipes/markdown.pipe' export * from './pipes/markdown.pipe'
export * from './services/copy.service' export * from './services/copy.service'

View File

@@ -1,14 +1,18 @@
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { RouterOutlet } from '@angular/router'
import { i18nService } from '@start9labs/shared' import { i18nService } from '@start9labs/shared'
import { TuiRoot } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { merge } from 'rxjs' import { merge } from 'rxjs'
import { ToastContainerComponent } from 'src/app/components/toast-container.component'
import { PatchDataService } from './services/patch-data.service' import { PatchDataService } from './services/patch-data.service'
import { DataModel } from './services/patch-db/data-model' import { DataModel } from './services/patch-db/data-model'
import { PatchMonitorService } from './services/patch-monitor.service' import { PatchMonitorService } from './services/patch-monitor.service'
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [TuiRoot, RouterOutlet, ToastContainerComponent],
template: ` template: `
<tui-root tuiTheme="dark"> <tui-root tuiTheme="dark">
<router-outlet /> <router-outlet />
@@ -26,7 +30,6 @@ import { PatchMonitorService } from './services/patch-monitor.service'
font-family: 'Proxima Nova', system-ui; font-family: 'Proxima Nova', system-ui;
} }
`, `,
standalone: false,
}) })
export class AppComponent { export class AppComponent {
private readonly i18n = inject(i18nService) private readonly i18n = inject(i18nService)

View File

@@ -0,0 +1,199 @@
import {
provideHttpClient,
withFetch,
withInterceptorsFromDi,
} from '@angular/common/http'
import {
ApplicationConfig,
inject,
provideAppInitializer,
provideZoneChangeDetection,
} from '@angular/core'
import { UntypedFormBuilder } from '@angular/forms'
import { provideAnimations } from '@angular/platform-browser/animations'
import {
ActivationStart,
PreloadAllModules,
provideRouter,
Router,
withComponentInputBinding,
withDisabledInitialNavigation,
withInMemoryScrolling,
withPreloading,
withRouterConfig,
} from '@angular/router'
import { provideServiceWorker } from '@angular/service-worker'
import { WA_LOCATION } from '@ng-web-apis/common'
import initArgon from '@start9labs/argon2'
import {
AbstractCategoryService,
FilterPackagesPipe,
} from '@start9labs/marketplace'
import {
I18N_PROVIDERS,
I18N_STORAGE,
i18nService,
Languages,
RELATIVE_URL,
VERSION,
WorkspaceConfig,
} from '@start9labs/shared'
import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
import {
TUI_DATE_FORMAT,
TUI_DIALOGS_CLOSE,
TUI_MEDIA,
tuiAlertOptionsProvider,
tuiButtonOptionsProvider,
tuiDropdownOptionsProvider,
tuiNumberFormatProvider,
} from '@taiga-ui/core'
import { provideEventPlugins } from '@taiga-ui/event-plugins'
import {
TUI_DATE_TIME_VALUE_TRANSFORMER,
TUI_DATE_VALUE_TRANSFORMER,
} from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, identity, merge, of, pairwise } from 'rxjs'
import { FilterUpdatesPipe } from 'src/app/routes/portal/routes/updates/filter-updates.pipe'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { LiveApiService } from 'src/app/services/api/embassy-live-api.service'
import { MockApiService } from 'src/app/services/api/embassy-mock-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { CategoryService } from 'src/app/services/category.service'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { ConfigService } from 'src/app/services/config.service'
import { DateTransformerService } from 'src/app/services/date-transformer.service'
import { DatetimeTransformerService } from 'src/app/services/datetime-transformer.service'
import {
PATCH_CACHE,
PatchDbSource,
} from 'src/app/services/patch-db/patch-db-source'
import { StateService } from 'src/app/services/state.service'
import { StorageService } from 'src/app/services/storage.service'
import { environment } from 'src/environments/environment'
import { ROUTES } from './app.routes'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
export const APP_CONFIG: ApplicationConfig = {
providers: [
provideZoneChangeDetection(),
provideAnimations(),
provideEventPlugins(),
provideHttpClient(withInterceptorsFromDi(), withFetch()),
provideRouter(
ROUTES,
withDisabledInitialNavigation(),
withComponentInputBinding(),
withPreloading(PreloadAllModules),
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
withRouterConfig({ paramsInheritanceStrategy: 'always' }),
),
provideServiceWorker('ngsw-worker.js', {
enabled: environment.useServiceWorker,
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000',
}),
I18N_PROVIDERS,
FilterPackagesPipe,
FilterUpdatesPipe,
UntypedFormBuilder,
tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }),
tuiButtonOptionsProvider({ size: 'm' }),
tuiDropdownOptionsProvider({ appearance: 'start-os' }),
tuiAlertOptionsProvider({
autoClose: appearance => (appearance === 'negative' ? 0 : 3000),
}),
{
provide: TUI_DATE_FORMAT,
useValue: of({
mode: 'MDY',
separator: '/',
}),
},
{
provide: TUI_DATE_VALUE_TRANSFORMER,
useClass: DateTransformerService,
},
{
provide: TUI_DATE_TIME_VALUE_TRANSFORMER,
useClass: DatetimeTransformerService,
},
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: PatchDB,
deps: [PatchDbSource, PATCH_CACHE],
useClass: PatchDB,
},
provideAppInitializer(() => {
const i18n = inject(i18nService)
const origin = inject(WA_LOCATION).origin
const module_or_path = new URL('/assets/argon2_bg.wasm', origin)
initArgon({ module_or_path })
inject(StorageService).migrate036()
inject(AuthService).init()
inject(ClientStorageService).init()
inject(Router).initialNavigation()
i18n.setLanguage(i18n.language || 'english')
}),
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
{
provide: AbstractCategoryService,
useClass: CategoryService,
},
{
provide: TUI_DIALOGS_CLOSE,
useFactory: () =>
merge(
inject(Router).events.pipe(filter(e => e instanceof ActivationStart)),
inject(StateService).pipe(
pairwise(),
filter(
([prev, curr]) =>
prev === 'running' &&
(curr === 'error' || curr === 'initializing'),
),
),
),
},
{
provide: I18N_STORAGE,
useFactory: () => {
const api = inject(ApiService)
return (language: Languages) => api.setLanguage({ language })
},
},
{
provide: VERSION,
useFactory: () => inject(ConfigService).version,
},
tuiObfuscateOptionsProvider({
recipes: {
mask: ({ length }) => '•'.repeat(length),
none: identity,
},
}),
{
provide: TUI_MEDIA,
useValue: {
mobile: 1000,
desktopSmall: 1280,
desktopLarge: Infinity,
},
},
],
}

View File

@@ -1,36 +0,0 @@
import {
provideHttpClient,
withFetch,
withInterceptorsFromDi,
} from '@angular/common/http'
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { ServiceWorkerModule } from '@angular/service-worker'
import { TuiRoot } from '@taiga-ui/core'
import { ToastContainerComponent } from 'src/app/components/toast-container.component'
import { environment } from '../environments/environment'
import { AppComponent } from './app.component'
import { APP_PROVIDERS } from './app.providers'
import { RoutingModule } from './routing.module'
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
RoutingModule,
ToastContainerComponent,
TuiRoot,
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.useServiceWorker,
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000',
}),
],
providers: [
APP_PROVIDERS,
provideHttpClient(withInterceptorsFromDi(), withFetch()),
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -1,157 +0,0 @@
import { inject, provideAppInitializer } from '@angular/core'
import { UntypedFormBuilder } from '@angular/forms'
import { provideAnimations } from '@angular/platform-browser/animations'
import { ActivationStart, Router } from '@angular/router'
import { WA_LOCATION } from '@ng-web-apis/common'
import initArgon from '@start9labs/argon2'
import {
AbstractCategoryService,
FilterPackagesPipe,
} from '@start9labs/marketplace'
import {
I18N_PROVIDERS,
I18N_STORAGE,
i18nService,
Languages,
RELATIVE_URL,
VERSION,
WorkspaceConfig,
} from '@start9labs/shared'
import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
import {
TUI_DATE_FORMAT,
TUI_DIALOGS_CLOSE,
TUI_MEDIA,
tuiAlertOptionsProvider,
tuiButtonOptionsProvider,
tuiDropdownOptionsProvider,
tuiNumberFormatProvider,
} from '@taiga-ui/core'
import { provideEventPlugins } from '@taiga-ui/event-plugins'
import {
TUI_DATE_TIME_VALUE_TRANSFORMER,
TUI_DATE_VALUE_TRANSFORMER,
} from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter, identity, merge, of, pairwise } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import {
PATCH_CACHE,
PatchDbSource,
} from 'src/app/services/patch-db/patch-db-source'
import { StateService } from 'src/app/services/state.service'
import { FilterUpdatesPipe } from './routes/portal/routes/updates/filter-updates.pipe'
import { ApiService } from './services/api/embassy-api.service'
import { LiveApiService } from './services/api/embassy-live-api.service'
import { MockApiService } from './services/api/embassy-mock-api.service'
import { AuthService } from './services/auth.service'
import { CategoryService } from './services/category.service'
import { ClientStorageService } from './services/client-storage.service'
import { DateTransformerService } from './services/date-transformer.service'
import { DatetimeTransformerService } from './services/datetime-transformer.service'
import { StorageService } from './services/storage.service'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
export const APP_PROVIDERS = [
provideAnimations(),
provideEventPlugins(),
I18N_PROVIDERS,
FilterPackagesPipe,
FilterUpdatesPipe,
UntypedFormBuilder,
tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }),
tuiButtonOptionsProvider({ size: 'm' }),
tuiDropdownOptionsProvider({ appearance: 'start-os' }),
tuiAlertOptionsProvider({
autoClose: appearance => (appearance === 'negative' ? 0 : 3000),
}),
{
provide: TUI_DATE_FORMAT,
useValue: of({
mode: 'MDY',
separator: '/',
}),
},
{
provide: TUI_DATE_VALUE_TRANSFORMER,
useClass: DateTransformerService,
},
{
provide: TUI_DATE_TIME_VALUE_TRANSFORMER,
useClass: DatetimeTransformerService,
},
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: PatchDB,
deps: [PatchDbSource, PATCH_CACHE],
useClass: PatchDB,
},
provideAppInitializer(() => {
const i18n = inject(i18nService)
const origin = inject(WA_LOCATION).origin
const module_or_path = new URL('/assets/argon2_bg.wasm', origin)
initArgon({ module_or_path })
inject(StorageService).migrate036()
inject(AuthService).init()
inject(ClientStorageService).init()
inject(Router).initialNavigation()
i18n.setLanguage(i18n.language || 'english')
}),
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
{
provide: AbstractCategoryService,
useClass: CategoryService,
},
{
provide: TUI_DIALOGS_CLOSE,
useFactory: () =>
merge(
inject(Router).events.pipe(filter(e => e instanceof ActivationStart)),
inject(StateService).pipe(
pairwise(),
filter(
([prev, curr]) =>
prev === 'running' &&
(curr === 'error' || curr === 'initializing'),
),
),
),
},
{
provide: I18N_STORAGE,
useFactory: () => {
const api = inject(ApiService)
return (language: Languages) => api.setLanguage({ language })
},
},
{
provide: VERSION,
useFactory: () => inject(ConfigService).version,
},
tuiObfuscateOptionsProvider({
recipes: {
mask: ({ length }) => '•'.repeat(length),
none: identity,
},
}),
{
provide: TUI_MEDIA,
useValue: {
mobile: 1000,
desktopSmall: 1280,
desktopLarge: Infinity,
},
},
]

View File

@@ -1,14 +1,14 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router' import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
import { AuthGuard } from 'src/app/guards/auth.guard'
import { UnauthGuard } from 'src/app/guards/unauth.guard'
import { stateNot } from 'src/app/services/state.service' import { stateNot } from 'src/app/services/state.service'
import { AuthGuard } from './guards/auth.guard'
import { UnauthGuard } from './guards/unauth.guard'
const routes: Routes = [ export const ROUTES: Routes = [
{ {
path: 'diagnostic', path: 'diagnostic',
canActivate: [stateNot(['initializing', 'running'])], canActivate: [stateNot(['initializing', 'running'])],
loadChildren: () => import('./routes/diagnostic/diagnostic.module'), loadChildren: () => import('./routes/diagnostic/diagnostic.routes'),
}, },
{ {
path: 'initializing', path: 'initializing',
@@ -18,8 +18,7 @@ const routes: Routes = [
{ {
path: 'login', path: 'login',
canActivate: [UnauthGuard, stateNot(['error', 'initializing'])], canActivate: [UnauthGuard, stateNot(['error', 'initializing'])],
loadChildren: () => loadComponent: () => import('./routes/login/login.page'),
import('./routes/login/login.module').then(m => m.LoginPageModule),
}, },
{ {
path: '', path: '',
@@ -32,17 +31,3 @@ const routes: Routes = [
pathMatch: 'full', pathMatch: 'full',
}, },
] ]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
paramsInheritanceStrategy: 'always',
preloadingStrategy: PreloadAllModules,
initialNavigation: 'disabled',
bindToComponentInputs: true,
}),
],
exports: [RouterModule],
})
export class RoutingModule {}

View File

@@ -1,19 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
const ROUTES: Routes = [
{
path: '',
loadChildren: () =>
import('./home/home.module').then(m => m.HomePageModule),
},
{
path: 'logs',
loadComponent: () => import('./logs.component'),
},
]
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
})
export default class DiagnosticModule {}

View File

@@ -0,0 +1,12 @@
import { Routes } from '@angular/router'
export default [
{
path: '',
loadComponent: () => import('./home/home.page'),
},
{
path: 'logs',
loadComponent: () => import('./logs.component'),
},
] satisfies Routes

View File

@@ -1,19 +0,0 @@
import { TuiButton } from '@taiga-ui/core'
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { RouterModule, Routes } from '@angular/router'
import { HomePage } from './home.page'
import { i18nPipe } from '@start9labs/shared'
const ROUTES: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [CommonModule, TuiButton, RouterModule.forChild(ROUTES), i18nPipe],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -1,6 +1,14 @@
import { CommonModule } from '@angular/common'
import { Component, Inject } from '@angular/core' import { Component, Inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { WA_WINDOW } from '@ng-web-apis/common' import { WA_WINDOW } from '@ng-web-apis/common'
import { DialogService, i18nKey, LoadingService } from '@start9labs/shared' import {
DialogService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { filter } from 'rxjs' import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
@@ -9,9 +17,9 @@ import { ConfigService } from 'src/app/services/config.service'
selector: 'diagnostic-home', selector: 'diagnostic-home',
templateUrl: 'home.component.html', templateUrl: 'home.component.html',
styleUrls: ['home.page.scss'], styleUrls: ['home.page.scss'],
standalone: false, imports: [CommonModule, TuiButton, i18nPipe, RouterLink],
}) })
export class HomePage { export default class HomePage {
restarted = false restarted = false
error?: { error?: {
code: number code: number

View File

@@ -20,9 +20,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { StateService } from 'src/app/services/state.service' import { StateService } from 'src/app/services/state.service'
@Component({ @Component({
template: ` template: '<app-initializing [progress]="progress()" />',
<app-initializing [progress]="progress()" />
`,
providers: [provideSetupLogsService(ApiService)], providers: [provideSetupLogsService(ApiService)],
styles: ':host { height: 100%; }', styles: ':host { height: 100%; }',
imports: [InitializingComponent], imports: [InitializingComponent],

View File

@@ -1,37 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { RouterModule, Routes } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { TuiAutoFocus } from '@taiga-ui/cdk'
import { TuiButton, TuiError, TuiIcon, TuiTextfield } from '@taiga-ui/core'
import { TuiPassword } from '@taiga-ui/kit'
import { TuiCardLarge } from '@taiga-ui/layout'
import { CAWizardComponent } from './ca-wizard/ca-wizard.component'
import { LoginPage } from './login.page'
const routes: Routes = [
{
path: '',
component: LoginPage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
CAWizardComponent,
TuiButton,
TuiCardLarge,
...TuiTextfield,
TuiIcon,
TuiPassword,
TuiAutoFocus,
TuiError,
RouterModule.forChild(routes),
i18nPipe,
],
declarations: [LoginPage],
})
export class LoginPageModule {}

View File

@@ -1,19 +1,38 @@
import { Router } from '@angular/router' import { CommonModule } from '@angular/common'
import { Component, DestroyRef, DOCUMENT, inject, Inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { Component, Inject, DestroyRef, inject, DOCUMENT } from '@angular/core' import { FormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { i18nKey, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiAutoFocus } from '@taiga-ui/cdk'
import { TuiButton, TuiError, TuiIcon, TuiTextfield } from '@taiga-ui/core'
import { TuiPassword } from '@taiga-ui/kit'
import { TuiCardLarge } from '@taiga-ui/layout'
import { CAWizardComponent } from 'src/app/routes/login/ca-wizard/ca-wizard.component'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service' import { AuthService } from 'src/app/services/auth.service'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { i18nKey, LoadingService } from '@start9labs/shared'
@Component({ @Component({
selector: 'login', selector: 'login',
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrls: ['./login.page.scss'], styleUrls: ['./login.page.scss'],
imports: [
CommonModule,
FormsModule,
CAWizardComponent,
TuiButton,
TuiCardLarge,
TuiTextfield,
TuiIcon,
TuiPassword,
TuiAutoFocus,
TuiError,
i18nPipe,
],
providers: [], providers: [],
standalone: false,
}) })
export class LoginPage { export default class LoginPage {
password = '' password = ''
error: i18nKey | null = null error: i18nKey | null = null

View File

@@ -31,6 +31,7 @@ export interface FormContext<T> {
buttons: ActionButton<T>[] buttons: ActionButton<T>[]
value?: T value?: T
operations?: Operation[] operations?: Operation[]
note?: string
} }
@Component({ @Component({
@@ -43,6 +44,9 @@ export interface FormContext<T> {
(tuiValueChanges)="markAsDirty()" (tuiValueChanges)="markAsDirty()"
> >
<form-group [spec]="spec" /> <form-group [spec]="spec" />
@if (note) {
<p class="note">{{ note }}</p>
}
<footer> <footer>
<ng-content /> <ng-content />
@for (button of buttons; track $index) { @for (button of buttons; track $index) {
@@ -70,6 +74,12 @@ export interface FormContext<T> {
</form> </form>
`, `,
styles: ` styles: `
.note {
color: var(--tui-text-secondary);
font: var(--tui-font-text-s);
margin-top: 1rem;
}
footer { footer {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -106,6 +116,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
@Input() buttons = this.context?.data.buttons || [] @Input() buttons = this.context?.data.buttons || []
@Input() operations = this.context?.data.operations || [] @Input() operations = this.context?.data.operations || []
@Input() value?: T = this.context?.data.value @Input() value?: T = this.context?.data.value
@Input() note = this.context?.data.note || ''
form = new FormGroup({}) form = new FormGroup({})

View File

@@ -36,6 +36,7 @@ import { HeaderStatusComponent } from './status.component'
height: 2.75rem; height: 2.75rem;
border-radius: var(--bumper); border-radius: var(--bumper);
margin: var(--bumper); margin: var(--bumper);
clip-path: inset(0 round var(--bumper));
overflow: hidden; overflow: hidden;
filter: grayscale(1) brightness(0.75); filter: grayscale(1) brightness(0.75);
@@ -107,7 +108,8 @@ import { HeaderStatusComponent } from './status.component'
&:has([data-status='success']) { &:has([data-status='success']) {
--status: transparent; --status: transparent;
filter: none; // "none" breaks border radius in Firefox
filter: grayscale(0.001);
} }
} }

View File

@@ -45,7 +45,7 @@ import { ABOUT } from './about.component'
} }
<tui-data-list [style.width.rem]="13"> <tui-data-list [style.width.rem]="13">
<tui-opt-group> <tui-opt-group>
<button tuiOption iconStart="@tui.info" (click)="about()"> <button tuiOption iconStart="@tui.info" new (click)="about()">
{{ 'About this server' | i18n }} {{ 'About this server' | i18n }}
</button> </button>
</tui-opt-group> </tui-opt-group>
@@ -53,13 +53,15 @@ import { ABOUT } from './about.component'
<a <a
tuiOption tuiOption
docsLink docsLink
iconStart="@tui.book-open" new
path="/start-os/user-manual/index.html" iconStart="@tui.book-open-text"
path="/start-os/user-manual"
> >
{{ 'User manual' | i18n }} {{ 'User manual' | i18n }}
</a> </a>
<a <a
tuiOption tuiOption
new
iconStart="@tui.headphones" iconStart="@tui.headphones"
href="https://start9.com/contact" href="https://start9.com/contact"
> >
@@ -67,6 +69,7 @@ import { ABOUT } from './about.component'
</a> </a>
<a <a
tuiOption tuiOption
new
iconStart="@tui.dollar-sign" iconStart="@tui.dollar-sign"
href="https://donate.start9.com" href="https://donate.start9.com"
> >
@@ -76,6 +79,7 @@ import { ABOUT } from './about.component'
<tui-opt-group label=""> <tui-opt-group label="">
<a <a
tuiOption tuiOption
new
iconStart="@tui.settings" iconStart="@tui.settings"
routerLink="/system" routerLink="/system"
(click)="open = false" (click)="open = false"
@@ -86,6 +90,7 @@ import { ABOUT } from './about.component'
<tui-opt-group label=""> <tui-opt-group label="">
<button <button
tuiOption tuiOption
new
iconStart="@tui.refresh-cw" iconStart="@tui.refresh-cw"
(click)="promptPower('restart')" (click)="promptPower('restart')"
> >
@@ -93,12 +98,13 @@ import { ABOUT } from './about.component'
</button> </button>
<button <button
tuiOption tuiOption
new
iconStart="@tui.power" iconStart="@tui.power"
(click)="promptPower('shutdown')" (click)="promptPower('shutdown')"
> >
{{ 'Shutdown' | i18n }} {{ 'Shutdown' | i18n }}
</button> </button>
<button tuiOption iconStart="@tui.log-out" (click)="logout()"> <button tuiOption new iconStart="@tui.log-out" (click)="logout()">
{{ 'Logout' | i18n }} {{ 'Logout' | i18n }}
</button> </button>
</tui-opt-group> </tui-opt-group>

View File

@@ -30,19 +30,6 @@ import { DomainHealthService } from './domain-health.service'
selector: 'td[actions]', selector: 'td[actions]',
template: ` template: `
<div class="desktop"> <div class="desktop">
@if (address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open UI' | i18n }}
</a>
}
@if (address().deletable) { @if (address().deletable) {
<button <button
tuiIconButton tuiIconButton
@@ -87,6 +74,19 @@ import { DomainHealthService } from './domain-health.service'
{{ 'Address Requirements' | i18n }} {{ 'Address Requirements' | i18n }}
</button> </button>
} }
@if (address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[attr.href]="address().enabled ? address().url : null"
[class.disabled]="!address().enabled"
>
{{ 'Open UI' | i18n }}
</a>
}
<button <button
tuiIconButton tuiIconButton
appearance="flat-grayscale" appearance="flat-grayscale"

View File

@@ -37,7 +37,7 @@ import { InterfaceAddressItemComponent } from './item.component'
selector: 'section[gatewayGroup]', selector: 'section[gatewayGroup]',
template: ` template: `
<header> <header>
{{ gatewayGroup().gatewayName }} {{ 'Gateway' | i18n }}: {{ gatewayGroup().gatewayName }}
<button <button
tuiDropdown tuiDropdown
tuiButton tuiButton
@@ -57,7 +57,14 @@ import { InterfaceAddressItemComponent } from './item.component'
</button> </button>
</header> </header>
<table <table
[appTable]="['Enabled', 'Type', 'Certificate Authority', 'URL', null]" [appTable]="[
null,
'Access',
'Type',
'Certificate Authority',
'URL',
null,
]"
> >
@for (address of gatewayGroup().addresses; track $index) { @for (address of gatewayGroup().addresses; track $index) {
<tr <tr
@@ -69,7 +76,7 @@ import { InterfaceAddressItemComponent } from './item.component'
></tr> ></tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5"> <td colspan="6">
<app-placeholder icon="@tui.list-x"> <app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }} {{ 'No addresses' | i18n }}
</app-placeholder> </app-placeholder>
@@ -132,6 +139,7 @@ export class InterfaceAddressesComponent {
}), }),
}), }),
), ),
note: this.getSharedHostNote(),
buttons: [ buttons: [
{ {
text: this.i18n.transform('Save')!, text: this.i18n.transform('Save')!,
@@ -190,6 +198,7 @@ export class InterfaceAddressesComponent {
size: 's', size: 's',
data: { data: {
spec: await configBuilderToSpec(addSpec), spec: await configBuilderToSpec(addSpec),
note: this.getSharedHostNote(),
buttons: [ buttons: [
{ {
text: this.i18n.transform('Save')!, text: this.i18n.transform('Save')!,
@@ -207,18 +216,22 @@ export class InterfaceAddressesComponent {
const loader = this.loader.open('Saving').subscribe() const loader = this.loader.open('Saving').subscribe()
try { try {
let configured: boolean
if (this.packageId()) { if (this.packageId()) {
await this.api.pkgAddPrivateDomain({ configured = await this.api.pkgAddPrivateDomain({
fqdn, fqdn,
gateway: gatewayId, gateway: gatewayId,
package: this.packageId(), package: this.packageId(),
host: iface?.addressInfo.hostId || '', host: iface?.addressInfo.hostId || '',
}) })
} else { } else {
await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId }) configured = await this.api.osUiAddPrivateDomain({
fqdn,
gateway: gatewayId,
})
} }
await this.domainHealth.checkPrivateDomain(gatewayId) await this.domainHealth.checkPrivateDomain(gatewayId, configured)
return true return true
} catch (e: any) { } catch (e: any) {
@@ -229,6 +242,13 @@ export class InterfaceAddressesComponent {
} }
} }
private getSharedHostNote(): string {
const names = this.value()?.sharedHostNames
if (!names?.length) return ''
return `${this.i18n.transform('This domain will also apply to')} ${names.join(', ')}`
}
private async savePublicDomain( private async savePublicDomain(
fqdn: string, fqdn: string,
authority?: 'local' | string, authority?: 'local' | string,
@@ -241,26 +261,22 @@ export class InterfaceAddressesComponent {
fqdn, fqdn,
gateway: gatewayId, gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority, acme: !authority || authority === 'local' ? null : authority,
internalPort: iface?.addressInfo.internalPort || 80,
} }
try { try {
let res
if (this.packageId()) { if (this.packageId()) {
await this.api.pkgAddPublicDomain({ res = await this.api.pkgAddPublicDomain({
...params, ...params,
package: this.packageId(), package: this.packageId(),
host: iface?.addressInfo.hostId || '', host: iface?.addressInfo.hostId || '',
}) })
} else { } else {
await this.api.osUiAddPublicDomain(params) res = await this.api.osUiAddPublicDomain(params)
} }
const port = this.gatewayGroup().addresses.find( await this.domainHealth.checkPublicDomain(fqdn, gatewayId, res)
a => a.access === 'public' && a.hostnameInfo.port !== null,
)?.hostnameInfo.port
if (port !== undefined && port !== null) {
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
}
return true return true
} catch (e: any) { } catch (e: any) {

View File

@@ -19,21 +19,34 @@ export class DomainHealthService {
async checkPublicDomain( async checkPublicDomain(
fqdn: string, fqdn: string,
gatewayId: string, gatewayId: string,
port: number, portOrRes: number | T.AddPublicDomainRes,
): Promise<void> { ): Promise<void> {
try { try {
const gateway = await this.getGatewayData(gatewayId) const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return if (!gateway) return
const [dnsPass, portResult] = await Promise.all([ let dnsPass: boolean
this.api let port: number
.queryDns({ fqdn }) let portResult: T.CheckPortRes | null
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false), if (typeof portOrRes === 'number') {
this.api port = portOrRes
.checkPort({ gateway: gatewayId, port }) const [dns, portRes] = await Promise.all([
.catch((): null => null), this.api
]) .queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port: portOrRes })
.catch((): null => null),
])
dnsPass = dns
portResult = portRes
} else {
dnsPass = portOrRes.dns === gateway.ipInfo.wanIp
port = portOrRes.port.port
portResult = portOrRes.port
}
const portOk = const portOk =
!!portResult?.openInternally && !!portResult?.openInternally &&
@@ -55,14 +68,17 @@ export class DomainHealthService {
} }
} }
async checkPrivateDomain(gatewayId: string): Promise<void> { async checkPrivateDomain(
gatewayId: string,
prefetchedConfigured?: boolean,
): Promise<void> {
try { try {
const gateway = await this.getGatewayData(gatewayId) const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return if (!gateway) return
const configured = await this.api const configured =
.checkDns({ gateway: gatewayId }) prefetchedConfigured ??
.catch(() => false) (await this.api.checkDns({ gateway: gatewayId }).catch(() => false))
if (!configured) { if (!configured) {
setTimeout( setTimeout(
@@ -150,7 +166,10 @@ export class DomainHealthService {
fqdn: string, fqdn: string,
gateway: DnsGateway, gateway: DnsGateway,
port: number, port: number,
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null }, initialResults?: {
dnsPass: boolean
portResult: T.CheckPortRes | null
},
) { ) {
this.dialog this.dialog
.openComponent(DOMAIN_VALIDATION, { .openComponent(DOMAIN_VALIDATION, {

View File

@@ -10,7 +10,7 @@ import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk' import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core' import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { TuiSwitch } from '@taiga-ui/kit' import { TuiBadge, TuiSwitch } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service' import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { AddressActionsComponent } from './actions.component' import { AddressActionsComponent } from './actions.component'
@@ -36,22 +36,51 @@ import { DomainHealthService } from './domain-health.service'
(ngModelChange)="onToggleEnabled()" (ngModelChange)="onToggleEnabled()"
/> />
</td> </td>
<td class="type"> <td class="access">
<tui-icon <tui-icon
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'" [icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
/> />
{{ address.type }} <span>
{{ (address.access === 'public' ? 'Public' : 'Local') | i18n }}
</span>
</td>
<td class="type">
<tui-badge
size="s"
[appearance]="typeAppearance(address.hostnameInfo.metadata.kind)"
>
{{ address.type }}
</tui-badge>
</td> </td>
<td> <td>
{{ address.certificate }} <div class="cert">
@if (address.certificate === 'Root CA') {
<img src="assets/icons/favicon.svg" alt="" class="cert-icon" />
} @else if (address.certificate.startsWith("Let's Encrypt")) {
<img src="assets/icons/letsencrypt.svg" alt="" class="cert-icon" />
} @else if (
address.certificate !== '-' && address.certificate !== 'Self signed'
) {
<tui-icon icon="@tui.shield" class="cert-icon" />
}
{{ address.certificate }}
</div>
</td> </td>
<td> <td>
<div class="url"> <div class="url">
<span @if (address.masked && currentlyMasked()) {
[title]="address.masked && currentlyMasked() ? '' : address.url" <span>{{ address.url | tuiObfuscate: 'mask' }}</span>
> } @else {
{{ address.url | tuiObfuscate: recipe() }} <span [title]="address.url">
</span> @if (urlParts(); as parts) {
{{ parts.prefix }}
<b>{{ parts.hostname }}</b>
{{ parts.suffix }}
} @else {
{{ address.url }}
}
</span>
}
@if (address.masked) { @if (address.masked) {
<button <button
tuiIconButton tuiIconButton
@@ -81,12 +110,28 @@ import { DomainHealthService } from './domain-health.service'
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem; grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
} }
.type tui-icon { .access tui-icon {
font-size: 1.3rem; font-size: 1.3rem;
margin-right: 0.7rem; margin-right: 0.7rem;
vertical-align: middle; vertical-align: middle;
} }
.cert {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cert-icon {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
}
tui-icon.cert-icon {
font-size: 1.25rem;
}
.url { .url {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -104,6 +149,7 @@ import { DomainHealthService } from './domain-health.service'
:host-context(tui-root._mobile) { :host-context(tui-root._mobile) {
padding-inline-start: 0.75rem !important; padding-inline-start: 0.75rem !important;
row-gap: 0.25rem;
&::before { &::before {
content: ''; content: '';
@@ -129,18 +175,32 @@ import { DomainHealthService } from './domain-health.service'
display: none; display: none;
} }
td:nth-child(2) { .access {
padding-right: 0;
font: var(--tui-font-text-m);
font-weight: bold;
tui-icon {
display: none;
}
}
.type {
font: var(--tui-font-text-m); font: var(--tui-font-text-m);
font-weight: bold; font-weight: bold;
color: var(--tui-text-primary); color: var(--tui-text-primary);
padding-inline-end: 0.5rem; padding-inline-end: 0.5rem;
} }
td:nth-child(3) { td:nth-child(4) {
grid-area: 2 / 1 / 2 / 3; grid-area: 2 / 1 / 2 / 3;
.cert-icon {
display: none;
}
} }
td:nth-child(4) { td:nth-child(5) {
grid-area: 3 / 1 / 3 / 3; grid-area: 3 / 1 / 3 / 3;
} }
@@ -154,6 +214,7 @@ import { DomainHealthService } from './domain-health.service'
imports: [ imports: [
i18nPipe, i18nPipe,
AddressActionsComponent, AddressActionsComponent,
TuiBadge,
TuiButton, TuiButton,
TuiIcon, TuiIcon,
TuiObfuscatePipe, TuiObfuscatePipe,
@@ -180,6 +241,33 @@ export class InterfaceAddressItemComponent {
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none', this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
) )
readonly urlParts = computed(() => {
const { url, hostnameInfo } = this.address()
const idx = url.indexOf(hostnameInfo.hostname)
if (idx === -1) return null
return {
prefix: url.slice(0, idx),
hostname: hostnameInfo.hostname,
suffix: url.slice(idx + hostnameInfo.hostname.length),
}
})
typeAppearance(kind: string): string {
switch (kind) {
case 'public-domain':
case 'private-domain':
return 'info'
case 'mdns':
return 'positive'
case 'ipv4':
return 'warning'
case 'ipv6':
return 'neutral'
default:
return 'neutral'
}
}
async onToggleEnabled() { async onToggleEnabled() {
const addr = this.address() const addr = this.address()
const iface = this.value() const iface = this.value()

View File

@@ -32,7 +32,7 @@ import {
@if (pluginGroup().pluginPkgInfo; as pkgInfo) { @if (pluginGroup().pluginPkgInfo; as pkgInfo) {
<img [src]="pkgInfo.icon" alt="" class="plugin-icon" /> <img [src]="pkgInfo.icon" alt="" class="plugin-icon" />
} }
{{ pluginGroup().pluginName }} {{ 'Plugin' | i18n }}: {{ pluginGroup().pluginName }}
@if (pluginGroup().tableAction; as action) { @if (pluginGroup().tableAction; as action) {
<button <button
tuiButton tuiButton

View File

@@ -81,7 +81,7 @@ function getAddressType(h: T.HostnameInfo): string {
return 'IPv6' return 'IPv6'
case 'public-domain': case 'public-domain':
case 'private-domain': case 'private-domain':
return h.hostname return 'Domain'
case 'mdns': case 'mdns':
return 'mDNS' return 'mDNS'
case 'plugin': case 'plugin':
@@ -116,7 +116,12 @@ export class InterfaceService {
gatewayMap.set(gateway.id, gateway) gatewayMap.set(gateway.id, gateway)
} }
for (const h of addr.available) { const available =
this.config.accessType === 'localhost'
? addr.available
: utils.filterNonLocal(addr.available)
for (const h of available) {
const gatewayIds = getGatewayIds(h) const gatewayIds = getGatewayIds(h)
for (const gid of gatewayIds) { for (const gid of gatewayIds) {
const list = groupMap.get(gid) const list = groupMap.get(gid)
@@ -337,4 +342,5 @@ export type MappedServiceInterface = T.ServiceInterface & {
gatewayGroups: GatewayAddressGroup[] gatewayGroups: GatewayAddressGroup[]
pluginGroups: PluginAddressGroup[] pluginGroups: PluginAddressGroup[]
addSsl: boolean addSsl: boolean
sharedHostNames: string[]
} }

View File

@@ -5,7 +5,7 @@ import {
Input, Input,
Output, Output,
} from '@angular/core' } from '@angular/core'
import { UnitConversionPipesModule } from '@start9labs/shared' import { ConvertBytesPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core' import { TuiButton } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit' import { TuiSkeleton } from '@taiga-ui/kit'
import { UnknownDisk } from 'src/app/services/api/api.types' import { UnknownDisk } from 'src/app/services/api/api.types'
@@ -109,7 +109,7 @@ import { UnknownDisk } from 'src/app/services/api/api.types'
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, UnitConversionPipesModule, TuiSkeleton], imports: [TuiButton, ConvertBytesPipe, TuiSkeleton],
}) })
export class BackupsPhysicalComponent { export class BackupsPhysicalComponent {
@Input() @Input()

View File

@@ -12,7 +12,7 @@ import { MarketplacePkg } from '@start9labs/marketplace'
import { import {
ErrorService, ErrorService,
Exver, Exver,
ExverPipesModule, ExverComparesPipe,
i18nPipe, i18nPipe,
i18nService, i18nService,
LoadingService, LoadingService,
@@ -107,7 +107,7 @@ type KEYS = 'id' | 'version' | 'alerts' | 'flavor'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
ExverPipesModule, ExverComparesPipe,
TuiButton, TuiButton,
ToManifestPipe, ToManifestPipe,
i18nPipe, i18nPipe,
@@ -150,7 +150,11 @@ export class MarketplaceControlsComponent {
const originalUrl = localPkg?.registry || null const originalUrl = localPkg?.registry || null
if (!localPkg) { if (!localPkg) {
if (await this.alerts.alertInstall(this.i18n.localize(this.pkg().alerts.install || ''))) { if (
await this.alerts.alertInstall(
this.i18n.localize(this.pkg().alerts.install || ''),
)
) {
this.installOrUpload(currentUrl) this.installOrUpload(currentUrl)
} }
return return

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { MenuModule } from '@start9labs/marketplace' import { MenuComponent } from '@start9labs/marketplace'
import { TuiIcon, TuiButton, TuiAppearance } from '@taiga-ui/core' import { TuiIcon, TuiButton, TuiAppearance } from '@taiga-ui/core'
import { MARKETPLACE_REGISTRY } from '../modals/registry.component' import { MARKETPLACE_REGISTRY } from '../modals/registry.component'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
@@ -41,7 +41,7 @@ import { DialogService, i18nPipe } from '@start9labs/shared'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
MenuModule, MenuComponent,
TuiButton, TuiButton,
TuiIcon, TuiIcon,
TuiAppearance, TuiAppearance,

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -8,7 +7,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { ItemModule, MarketplacePkg } from '@start9labs/marketplace' import { ItemComponent, MarketplacePkg } from '@start9labs/marketplace'
import { TuiAutoFocus } from '@taiga-ui/cdk' import { TuiAutoFocus } from '@taiga-ui/cdk'
import { TuiButton, TuiDropdownService, TuiPopup } from '@taiga-ui/core' import { TuiButton, TuiDropdownService, TuiPopup } from '@taiga-ui/core'
import { TuiDrawer } from '@taiga-ui/kit' import { TuiDrawer } from '@taiga-ui/kit'
@@ -78,8 +77,7 @@ import { MarketplaceSidebarService } from '../services/sidebar.service'
}, },
], ],
imports: [ imports: [
CommonModule, ItemComponent,
ItemModule,
TuiAutoFocus, TuiAutoFocus,
TuiButton, TuiButton,
TuiPopup, TuiPopup,

View File

@@ -5,7 +5,6 @@ import { ActivatedRoute, Router } from '@angular/router'
import { import {
AbstractCategoryService, AbstractCategoryService,
FilterPackagesPipe, FilterPackagesPipe,
FilterPackagesPipeModule,
} from '@start9labs/marketplace' } from '@start9labs/marketplace'
import { i18nPipe } from '@start9labs/shared' import { i18nPipe } from '@start9labs/shared'
import { TuiScrollbar } from '@taiga-ui/core' import { TuiScrollbar } from '@taiga-ui/core'
@@ -153,7 +152,7 @@ import { ConfigService } from 'src/app/services/config.service'
MarketplaceMenuComponent, MarketplaceMenuComponent,
MarketplaceNotificationComponent, MarketplaceNotificationComponent,
TuiScrollbar, TuiScrollbar,
FilterPackagesPipeModule, FilterPackagesPipe,
TitleDirective, TitleDirective,
i18nPipe, i18nPipe,
], ],

View File

@@ -15,12 +15,7 @@ import {
MarketplaceReleaseNotesComponent, MarketplaceReleaseNotesComponent,
MarketplaceVersionsComponent, MarketplaceVersionsComponent,
} from '@start9labs/marketplace' } from '@start9labs/marketplace'
import { import { DialogService, EmptyPipe, Exver, MARKDOWN } from '@start9labs/shared'
DialogService,
Exver,
MARKDOWN,
SharedPipesModule,
} from '@start9labs/shared'
import { TuiLoader } from '@taiga-ui/core' import { TuiLoader } from '@taiga-ui/core'
import { import {
BehaviorSubject, BehaviorSubject,
@@ -115,7 +110,7 @@ import { MarketplaceControlsComponent } from '../components/controls.component'
CommonModule, CommonModule,
MarketplacePackageHeroComponent, MarketplacePackageHeroComponent,
MarketplaceDependenciesComponent, MarketplaceDependenciesComponent,
SharedPipesModule, EmptyPipe,
TuiLoader, TuiLoader,
MarketplaceLinksComponent, MarketplaceLinksComponent,
MarketplaceFlavorsComponent, MarketplaceFlavorsComponent,

View File

@@ -1,10 +1,7 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { import { MarketplaceRegistryComponent } from '@start9labs/marketplace'
MarketplaceRegistryComponent,
StoreIconComponentModule,
} from '@start9labs/marketplace'
import { import {
DialogService, DialogService,
ErrorService, ErrorService,
@@ -14,6 +11,7 @@ import {
sameUrl, sameUrl,
toUrl, toUrl,
} from '@start9labs/shared' } from '@start9labs/shared'
import { IST, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiDialogContext, TuiIcon, TuiTitle } from '@taiga-ui/core' import { TuiButton, TuiDialogContext, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
@@ -24,7 +22,6 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service' import { FormDialogService } from 'src/app/services/form-dialog.service'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { IST, utils } from '@start9labs/start-sdk'
import { StorageService } from 'src/app/services/storage.service' import { StorageService } from 'src/app/services/storage.service'
@Component({ @Component({
@@ -80,7 +77,6 @@ import { StorageService } from 'src/app/services/storage.service'
TuiTitle, TuiTitle,
TuiButton, TuiButton,
MarketplaceRegistryComponent, MarketplaceRegistryComponent,
StoreIconComponentModule,
i18nPipe, i18nPipe,
], ],
}) })

View File

@@ -28,7 +28,7 @@ interface ActionItem {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiTitle], imports: [TuiTitle],
host: { host: {
'[disabled]': '!!disabled() || inactive()', '[attr.disabled]': '(!!disabled() || inactive()) || null',
}, },
}) })
export class ServiceActionComponent { export class ServiceActionComponent {

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