Compare commits

..

9 Commits

Author SHA1 Message Date
Matt Hill
0b004a19ae wrap text in release notes 2026-03-30 14:59:20 -06:00
Aiden McClelland
ce1da028ce fix: extract hairpin check into platform-conditional function
The hairpin NAT check uses Linux-specific APIs (bind_device, raw fd
conversion). Extract it into a separate function with #[cfg(target_os)]
so the entire block is excluded on non-Linux platforms, rather than
guarding only the unsafe block.
2026-03-30 14:38:13 -06:00
Aiden McClelland
0d4dcf6c61 fix: correct platform extraction in ISO deploy and re-enable raspberrypi
The sed-based platform extraction was greedy, turning "x86_64" into "64".
Replace with explicit platform list iteration. Exclude raspberrypi from
deploy. Re-enable raspberrypi as a platform choice for builds.
2026-03-30 12:11:22 -06:00
crissuper20
8359712cd9 Fix/startos UI empty interface (#3143)
fix: give StartOS UI interface a non-empty id

The iface object in StartOsUiComponent had id: '' (empty string).
Any plugin whose action calls sdk.serviceInterface.get() with
that id triggers an RPC to the host with an empty
serviceInterfaceId, which Rust's ServiceInterfaceId type rejects
via its ID regex (^[a-z0-9]+(-[a-z0-9]+)*$).

The container runtime appends the method name to every error
message as "${msg}@${method}", so the empty-string failure
surfaces in the UI as:

  Action Failed: Deserialization Error: Invalid ID: @get-service-interface

Setting id: 'startos-ui' makes it a valid, stable identifier
that passes the regex and accurately names the interface.
2026-03-30 12:00:14 -06:00
Aiden McClelland
f46cdc6ee5 fix: correct hairpin NAT rules and bind hairpin check to gateway interface
The POSTROUTING MASQUERADE rules in forward-port failed to handle two
hairpin scenarios:

1. Host-to-target hairpin (OUTPUT DNAT): when sip is a WAN IP (tunnel
   case), the old rule matched `-s sip` but the actual source of
   locally-originated packets is a local interface IP, not the WAN IP.
   Fix: use `-m addrtype --src-type LOCAL -m conntrack --ctorigdst sip`
   to match any local source while tying the rule to the specific sip.

2. Same-subnet self-hairpin (PREROUTING DNAT): when a WireGuard peer
   connects to itself via the tunnel's public IP, traffic is DNAT'd back
   to the peer. Without MASQUERADE the response takes a loopback shortcut,
   bypassing the tunnel server's conntrack and breaking NAT reversal.
   Fix: add `-s dip/dprefix -d dip` to masquerade same-subnet traffic,
   which also subsumes the old bridge_subnet rule.

Also bind the hairpin detection socket to the gateway interface and local
IP for consistency with the echoip client.
2026-03-30 11:52:53 -06:00
Aiden McClelland
c96b38f915 fix: bind echoip client to gateway's specific IPv4 to avoid EADDRINUSE
Using Ipv4Addr::UNSPECIFIED (0.0.0.0) as the local address with
SO_BINDTODEVICE caused bind(0.0.0.0:0) to fail with "Address in use"
on interfaces where port 443 was already in use. Binding to the
gateway's actual IPv4 address instead still forces IPv4 DNS filtering
while avoiding the kernel-level conflict.
2026-03-30 08:12:21 -06:00
Matt Hill
c1c8dc8f9c fixes #3150 2026-03-29 20:48:30 -06:00
Matt Hill
e3b7277ccd fix: correct false breakage detection for flavored packages and confi… (#3149)
fix: correct false breakage detection for flavored packages and config changes

Two bugs caused the UI to incorrectly warn about dependency breakages:

1. dryUpdate (version path): Flavored package versions (e.g. #knots:27.0.0:0)
   failed exver.satisfies() against flavorless ranges (e.g. >=26.0.0) due to
   flavor mismatch. Now checks the manifest's `satisfies` declarations,
   matching the pattern already used in DepErrorService. Added `satisfies`
   field to PackageVersionInfo so it's available from registry data.

2. checkConflicts (config path): fast-json-patch's compare() treated missing
   keys as conflicts (add ops) and used positional array comparison, diverging
   from the backend's conflicts() semantics. Replaced with a conflicts()
   function that mirrors core/src/service/action.rs — missing keys are not
   conflicts, and arrays use set-based comparison.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:07:52 -06:00
Matt Hill
b0b4b41c42 feat: unified restart notification with reason-specific messaging (#3147)
* feat: unified restart notification with reason-specific messaging

Replace statusInfo.updated (bool) with serverInfo.restart (nullable enum)
to unify all restart-needed scenarios under a single PatchDB field.

Backend sets the restart reason in RPC handlers for hostname change (mdns),
language change, kiosk toggle, and OS update download. Init clears it on
boot. The update flow checks this field to prevent updates when a restart
is already pending.

Frontend shows a persistent action bar with reason-specific i18n messages
instead of per-feature restart dialogs. For .local hostname changes, the
existing "open new address" dialog is preserved — the restart toast
appears after the user logs in on the new address.

Also includes migration in v0_4_0_alpha_23 to remove statusInfo.updated
and initialize serverInfo.restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix broken styling and improve settings layout

* refactor: move restart field from ServerInfo to ServerStatus

The restart reason belongs with other server state (shutting_down,
restarting, update_progress) rather than on the top-level ServerInfo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix PR comment

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
2026-03-29 02:23:59 -06:00
22 changed files with 413 additions and 171 deletions

View File

@@ -29,7 +29,7 @@ on:
- aarch64 - aarch64
- aarch64-nonfree - aarch64-nonfree
- aarch64-nvidia - aarch64-nvidia
# - raspberrypi - raspberrypi
- riscv64 - riscv64
- riscv64-nonfree - riscv64-nonfree
deploy: deploy:
@@ -296,6 +296,18 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION" echo "Version: $VERSION"
- name: Determine platforms
id: platforms
run: |
INPUT="${{ github.event.inputs.platform }}"
if [ "$INPUT" = "ALL" ]; then
PLATFORMS="x86_64 x86_64-nonfree x86_64-nvidia aarch64 aarch64-nonfree aarch64-nvidia riscv64 riscv64-nonfree"
else
PLATFORMS="$INPUT"
fi
echo "list=$PLATFORMS" >> "$GITHUB_OUTPUT"
echo "Platforms: $PLATFORMS"
- name: Download squashfs artifacts - name: Download squashfs artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
@@ -347,10 +359,12 @@ jobs:
run: | run: |
VERSION="${{ steps.version.outputs.version }}" VERSION="${{ steps.version.outputs.version }}"
cd artifacts cd artifacts
for file in *.iso *.squashfs; do for PLATFORM in ${{ steps.platforms.outputs.list }}; do
[ -f "$file" ] || continue for file in *_${PLATFORM}.squashfs *_${PLATFORM}.iso; do
echo "Uploading $file..." [ -f "$file" ] || continue
s3cmd put -P "$file" "${{ env.S3_BUCKET }}/v${VERSION}/$file" echo "Uploading $file..."
s3cmd put -P "$file" "${{ env.S3_BUCKET }}/v${VERSION}/$file"
done
done done
- name: Register OS version - name: Register OS version
@@ -363,13 +377,14 @@ jobs:
run: | run: |
VERSION="${{ steps.version.outputs.version }}" VERSION="${{ steps.version.outputs.version }}"
cd artifacts cd artifacts
for file in *.squashfs *.iso; do for PLATFORM in ${{ steps.platforms.outputs.list }}; do
[ -f "$file" ] || continue for file in *_${PLATFORM}.squashfs *_${PLATFORM}.iso; do
PLATFORM=$(echo "$file" | sed 's/.*_\([^.]*\)\.\(squashfs\|iso\)$/\1/') [ -f "$file" ] || continue
echo "Indexing $file for platform $PLATFORM..." echo "Indexing $file for platform $PLATFORM..."
start-cli --registry="${{ env.REGISTRY }}" registry os asset add \ start-cli --registry="${{ env.REGISTRY }}" registry os asset add \
--platform="$PLATFORM" \ --platform="$PLATFORM" \
--version="$VERSION" \ --version="$VERSION" \
"$file" \ "$file" \
"${{ env.S3_CDN }}/v${VERSION}/$file" "${{ env.S3_CDN }}/v${VERSION}/$file"
done
done done

View File

@@ -58,15 +58,18 @@ iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p udp --dport "$sport" -j DNAT --to
iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT
iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT
# NAT hairpin: masquerade traffic from the bridge subnet or host to the DNAT # NAT hairpin: masquerade so replies route back through this host for proper
# target, so replies route back through the host for proper NAT reversal. # NAT reversal instead of taking a direct path that bypasses conntrack.
# Container-to-container hairpin (source is on the bridge subnet) # Host-to-target hairpin: locally-originated packets whose original destination
if [ -n "$bridge_subnet" ]; then # was sip (before OUTPUT DNAT rewrote it to dip). Using --ctorigdst ties the
iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE # rule to this specific sip, so multiple WAN IPs forwarding the same port to
iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p udp --dport "$dport" -j MASQUERADE # different targets each get their own masquerade.
fi iptables -t nat -A ${NAME}_POSTROUTING -m addrtype --src-type LOCAL -m conntrack --ctorigdst "$sip" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
# Host-to-container hairpin (host connects to its own gateway IP, source is sip) iptables -t nat -A ${NAME}_POSTROUTING -m addrtype --src-type LOCAL -m conntrack --ctorigdst "$sip" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE # Same-subnet hairpin: when traffic originates from the same subnet as the DNAT
iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p udp --dport "$dport" -j MASQUERADE # target (e.g. a container reaching another container, or a WireGuard peer
# connecting to itself via the tunnel's public IP).
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
exit $err exit $err

View File

@@ -241,11 +241,19 @@ pub async fn check_port(
.await .await
.map_or(false, |r| r.is_ok()); .map_or(false, |r| r.is_ok());
let local_ipv4 = ip_info
.subnets
.iter()
.find_map(|s| match s.addr() {
IpAddr::V4(v4) => Some(v4),
_ => None,
})
.unwrap_or(Ipv4Addr::UNSPECIFIED);
let client = reqwest::Client::builder(); let client = reqwest::Client::builder();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let client = client let client = client
.interface(gateway.as_str()) .interface(gateway.as_str())
.local_address(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); .local_address(IpAddr::V4(local_ipv4));
let client = client.build()?; let client = client.build()?;
let mut res = None; let mut res = None;
@@ -282,12 +290,7 @@ pub async fn check_port(
)); ));
}; };
let hairpinning = tokio::time::timeout( let hairpinning = check_hairpin(gateway, local_ipv4, ip, port).await;
Duration::from_secs(5),
tokio::net::TcpStream::connect(SocketAddr::new(ip.into(), port)),
)
.await
.map_or(false, |r| r.is_ok());
Ok(CheckPortRes { Ok(CheckPortRes {
ip, ip,
@@ -298,6 +301,30 @@ pub async fn check_port(
}) })
} }
#[cfg(target_os = "linux")]
async fn check_hairpin(gateway: GatewayId, local_ipv4: Ipv4Addr, ip: Ipv4Addr, port: u16) -> bool {
let hairpinning = tokio::time::timeout(Duration::from_secs(5), async {
let dest = SocketAddr::new(ip.into(), port);
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
socket.bind_device(Some(gateway.as_str().as_bytes()))?;
socket.bind(&SocketAddr::new(IpAddr::V4(local_ipv4), 0).into())?;
socket.set_nonblocking(true)?;
let socket = unsafe {
use std::os::fd::{FromRawFd, IntoRawFd};
tokio::net::TcpSocket::from_raw_fd(socket.into_raw_fd())
};
socket.connect(dest).await.map(|_| ())
})
.await
.map_or(false, |r| r.is_ok());
hairpinning
}
#[cfg(not(target_os = "linux"))]
async fn check_hairpin(_: GatewayId, _: Ipv4Addr, _: Ipv4Addr, _: u16) -> bool {
false
}
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[group(skip)] #[group(skip)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -784,12 +811,16 @@ async fn watcher(
} }
} }
async fn get_wan_ipv4(iface: &str, base_url: &Url) -> Result<Option<Ipv4Addr>, Error> { async fn get_wan_ipv4(
iface: &str,
base_url: &Url,
local_ipv4: Ipv4Addr,
) -> Result<Option<Ipv4Addr>, Error> {
let client = reqwest::Client::builder(); let client = reqwest::Client::builder();
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let client = client let client = client
.interface(iface) .interface(iface)
.local_address(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); .local_address(IpAddr::V4(local_ipv4));
let url = base_url.join("/ip").with_kind(ErrorKind::ParseUrl)?; let url = base_url.join("/ip").with_kind(ErrorKind::ParseUrl)?;
let text = client let text = client
.build()? .build()?
@@ -1412,7 +1443,14 @@ async fn poll_ip_info(
Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback) Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback)
) )
{ {
match get_wan_ipv4(iface.as_str(), &echoip_url).await { let local_ipv4 = subnets
.iter()
.find_map(|s| match s.addr() {
IpAddr::V4(v4) => Some(v4),
_ => None,
})
.unwrap_or(Ipv4Addr::UNSPECIFIED);
match get_wan_ipv4(iface.as_str(), &echoip_url, local_ipv4).await {
Ok(a) => { Ok(a) => {
wan_ip = a; wan_ip = a;
} }

View File

@@ -615,6 +615,7 @@ fn check_matching_info_short() {
sdk_version: None, sdk_version: None,
hardware_acceleration: false, hardware_acceleration: false,
plugins: BTreeSet::new(), plugins: BTreeSet::new(),
satisfies: BTreeSet::new(),
}, },
icon: DataUrl::from_vec("image/png", vec![]), icon: DataUrl::from_vec("image/png", vec![]),
dependency_metadata: BTreeMap::new(), dependency_metadata: BTreeMap::new(),

View File

@@ -110,6 +110,8 @@ pub struct PackageMetadata {
pub hardware_acceleration: bool, pub hardware_acceleration: bool,
#[serde(default)] #[serde(default)]
pub plugins: BTreeSet<PluginId>, pub plugins: BTreeSet<PluginId>,
#[serde(default)]
pub satisfies: BTreeSet<VersionString>,
} }
#[derive(Debug, Deserialize, Serialize, HasModel, TS)] #[derive(Debug, Deserialize, Serialize, HasModel, TS)]

View File

@@ -197,7 +197,6 @@ impl TryFrom<ManifestV1> for Manifest {
Ok(Self { Ok(Self {
id: value.id, id: value.id,
version: version.into(), version: version.into(),
satisfies: BTreeSet::new(),
can_migrate_from: VersionRange::any(), can_migrate_from: VersionRange::any(),
can_migrate_to: VersionRange::none(), can_migrate_to: VersionRange::none(),
metadata: PackageMetadata { metadata: PackageMetadata {
@@ -219,6 +218,7 @@ impl TryFrom<ManifestV1> for Manifest {
PackageProcedure::Script(_) => false, PackageProcedure::Script(_) => false,
}, },
plugins: BTreeSet::new(), plugins: BTreeSet::new(),
satisfies: BTreeSet::new(),
}, },
images: BTreeMap::new(), images: BTreeMap::new(),
volumes: value volumes: value

View File

@@ -32,7 +32,6 @@ pub(crate) fn current_version() -> Version {
pub struct Manifest { pub struct Manifest {
pub id: PackageId, pub id: PackageId,
pub version: VersionString, pub version: VersionString,
pub satisfies: BTreeSet<VersionString>,
#[ts(type = "string")] #[ts(type = "string")]
pub can_migrate_to: VersionRange, pub can_migrate_to: VersionRange,
#[ts(type = "string")] #[ts(type = "string")]

View File

@@ -358,7 +358,7 @@ pub async fn check_dependencies(
}; };
let manifest = package.as_state_info().as_manifest(ManifestPreference::New); let manifest = package.as_state_info().as_manifest(ManifestPreference::New);
let installed_version = manifest.as_version().de()?.into_version(); let installed_version = manifest.as_version().de()?.into_version();
let satisfies = manifest.as_satisfies().de()?; let satisfies = manifest.as_metadata().as_satisfies().de()?;
let installed_version = Some(installed_version.clone().into()); let installed_version = Some(installed_version.clone().into());
let is_running = package let is_running = package
.as_status_info() .as_status_info()

View File

@@ -15,7 +15,6 @@ import type { VolumeId } from './VolumeId'
export type Manifest = { export type Manifest = {
id: PackageId id: PackageId
version: Version version: Version
satisfies: Array<Version>
canMigrateTo: string canMigrateTo: string
canMigrateFrom: string canMigrateFrom: string
images: { [key: ImageId]: ImageConfig } images: { [key: ImageId]: ImageConfig }
@@ -37,4 +36,5 @@ export type Manifest = {
sdkVersion: string | null sdkVersion: string | null
hardwareAcceleration: boolean hardwareAcceleration: boolean
plugins: Array<PluginId> plugins: Array<PluginId>
satisfies: Array<Version>
} }

View File

@@ -10,6 +10,7 @@ import type { MerkleArchiveCommitment } from './MerkleArchiveCommitment'
import type { PackageId } from './PackageId' import type { PackageId } from './PackageId'
import type { PluginId } from './PluginId' import type { PluginId } from './PluginId'
import type { RegistryAsset } from './RegistryAsset' import type { RegistryAsset } from './RegistryAsset'
import type { Version } from './Version'
export type PackageVersionInfo = { export type PackageVersionInfo = {
icon: DataUrl icon: DataUrl
@@ -31,4 +32,5 @@ export type PackageVersionInfo = {
sdkVersion: string | null sdkVersion: string | null
hardwareAcceleration: boolean hardwareAcceleration: boolean
plugins: Array<PluginId> plugins: Array<PluginId>
satisfies: Array<Version>
} }

View File

@@ -4,8 +4,16 @@ import {
HostListener, HostListener,
inject, inject,
} from '@angular/core' } from '@angular/core'
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
ValidatorFn,
Validators,
} from '@angular/forms'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms' import { WA_IS_MOBILE } from '@ng-web-apis/platform'
import { import {
DialogService, DialogService,
DiskInfo, DiskInfo,
@@ -14,13 +22,14 @@ import {
i18nPipe, i18nPipe,
toGuid, toGuid,
} from '@start9labs/shared' } from '@start9labs/shared'
import { WA_IS_MOBILE } from '@ng-web-apis/platform' import { TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import { import {
TuiButton, TuiButton,
TuiError,
TuiIcon, TuiIcon,
TuiLoader, TuiLoader,
TuiInput,
TuiNotification, TuiNotification,
TUI_VALIDATION_ERRORS,
TuiTitle, TuiTitle,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { import {
@@ -29,49 +38,55 @@ import {
TuiSelect, TuiSelect,
TuiTooltip, TuiTooltip,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout' import { TuiCardLarge, TuiForm, TuiHeader } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { distinctUntilChanged, filter, Subscription } from 'rxjs'
import { filter, Subscription } from 'rxjs' import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
import { ApiService } from '../services/api.service' import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service' import { StateService } from '../services/state.service'
import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
@Component({ @Component({
template: ` template: `
@if (!shuttingDown) { @if (!shuttingDown) {
<section tuiCardLarge="compact"> @if (loading) {
<header tuiHeader> <section tuiCardLarge="compact">
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2> <header tuiHeader>
</header> <h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
</header>
@if (loading) {
<tui-loader /> <tui-loader />
} @else if (drives.length === 0) { </section>
} @else if (drives.length === 0) {
<section tuiCardLarge="compact">
<header tuiHeader>
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
</header>
<p tuiNotification size="m" appearance="warning"> <p tuiNotification size="m" appearance="warning">
{{ {{
'No drives found. Please connect a drive and click Refresh.' 'No drives found. Please connect a drive and click Refresh.'
| i18n | i18n
}} }}
</p> </p>
} @else { <footer>
<tui-textfield <button tuiButton appearance="secondary" (click)="refresh()">
[stringify]="stringify" {{ 'Refresh' | i18n }}
[disabledItemHandler]="osDisabled" </button>
> </footer>
</section>
} @else {
<form tuiCardLarge="compact" tuiForm [formGroup]="form">
<header tuiHeader>
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
</header>
<tui-textfield [stringify]="stringify">
<label tuiLabel>{{ 'OS Drive' | i18n }}</label> <label tuiLabel>{{ 'OS Drive' | i18n }}</label>
@if (mobile) { @if (mobile) {
<select <select
tuiSelect tuiSelect
[ngModel]="selectedOsDrive" formControlName="osDrive"
(ngModelChange)="onOsDriveChange($event)"
[items]="drives" [items]="drives"
></select> ></select>
} @else { } @else {
<input <input tuiSelect formControlName="osDrive" />
tuiSelect
[ngModel]="selectedOsDrive"
(ngModelChange)="onOsDriveChange($event)"
/>
} }
@if (!mobile) { @if (!mobile) {
<tui-data-list-wrapper <tui-data-list-wrapper
@@ -82,24 +97,28 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
} }
<tui-icon [tuiTooltip]="osDriveTooltip" /> <tui-icon [tuiTooltip]="osDriveTooltip" />
</tui-textfield> </tui-textfield>
@if (form.controls.osDrive.touched && form.controls.osDrive.invalid) {
<tui-error formControlName="osDrive" />
}
<tui-textfield <tui-textfield [stringify]="stringify">
[stringify]="stringify"
[disabledItemHandler]="dataDisabled"
>
<label tuiLabel>{{ 'Data Drive' | i18n }}</label> <label tuiLabel>{{ 'Data Drive' | i18n }}</label>
@if (mobile) { @if (mobile) {
<select <select
tuiSelect tuiSelect
[(ngModel)]="selectedDataDrive" formControlName="dataDrive"
(ngModelChange)="onDataDriveChange($event)"
[items]="drives" [items]="drives"
[tuiValidator]="
form.controls.osDrive.value | tuiMapper: dataValidator
"
></select> ></select>
} @else { } @else {
<input <input
tuiSelect tuiSelect
[(ngModel)]="selectedDataDrive" formControlName="dataDrive"
(ngModelChange)="onDataDriveChange($event)" [tuiValidator]="
form.controls.osDrive.value | tuiMapper: dataValidator
"
/> />
} }
@if (!mobile) { @if (!mobile) {
@@ -117,6 +136,11 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
} }
<tui-icon [tuiTooltip]="dataDriveTooltip" /> <tui-icon [tuiTooltip]="dataDriveTooltip" />
</tui-textfield> </tui-textfield>
@if (
form.controls.dataDrive.touched && form.controls.dataDrive.invalid
) {
<tui-error formControlName="dataDrive" />
}
<ng-template #driveContent let-drive> <ng-template #driveContent let-drive>
<span tuiTitle> <span tuiTitle>
@@ -126,24 +150,14 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
</span> </span>
</span> </span>
</ng-template> </ng-template>
}
<footer> <footer>
@if (drives.length === 0) { <button tuiButton [disabled]="form.invalid" (click)="continue()">
<button tuiButton appearance="secondary" (click)="refresh()">
{{ 'Refresh' | i18n }}
</button>
} @else {
<button
tuiButton
[disabled]="!selectedOsDrive || !selectedDataDrive"
(click)="continue()"
>
{{ 'Continue' | i18n }} {{ 'Continue' | i18n }}
</button> </button>
} </footer>
</footer> </form>
</section> }
} }
`, `,
styles: ` styles: `
@@ -152,20 +166,34 @@ import { PRESERVE_OVERWRITE } from '../components/preserve-overwrite.dialog'
} }
`, `,
imports: [ imports: [
FormsModule, ReactiveFormsModule,
TuiCardLarge, TuiCardLarge,
TuiForm,
TuiButton, TuiButton,
TuiError,
TuiIcon, TuiIcon,
TuiLoader, TuiLoader,
TuiInput,
TuiNotification, TuiNotification,
TuiSelect, TuiSelect,
TuiDataListWrapper, TuiDataListWrapper,
TuiTooltip, TuiTooltip,
TuiValidator,
TuiMapperPipe,
TuiHeader, TuiHeader,
TuiTitle, TuiTitle,
i18nPipe, i18nPipe,
], ],
providers: [
{
provide: TUI_VALIDATION_ERRORS,
useFactory: () => {
const i18n = inject(i18nPipe)
return {
required: i18n.transform('Required'),
}
},
},
],
}) })
export default class DrivesPage { export default class DrivesPage {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
@@ -188,29 +216,63 @@ export default class DrivesPage {
} }
readonly osDriveTooltip = this.i18n.transform( readonly osDriveTooltip = this.i18n.transform(
'The drive where the StartOS operating system will be installed.', 'The drive where the StartOS operating system will be installed. Minimum 18 GB.',
) )
readonly dataDriveTooltip = this.i18n.transform( readonly dataDriveTooltip = this.i18n.transform(
'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. Minimum 20 GB, or 38 GB if using a single drive for both OS and data.',
) )
private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB
private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB
private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB
private readonly osCapacityValidator: ValidatorFn = ({
value,
}: AbstractControl) => {
if (!value) return null
return value.capacity < this.MIN_OS
? {
tooSmallOs: this.i18n.transform('OS drive must be at least 18 GB'),
}
: null
}
readonly form = new FormGroup({
osDrive: new FormControl<DiskInfo | null>(null, [
Validators.required,
this.osCapacityValidator,
]),
dataDrive: new FormControl<DiskInfo | null>(null, [Validators.required]),
})
readonly dataValidator =
(osDrive: DiskInfo | null): ValidatorFn =>
({ value }: AbstractControl) => {
if (!value) return null
const sameAsOs = osDrive && value.logicalname === osDrive.logicalname
const min = sameAsOs ? this.MIN_BOTH : this.MIN_DATA
if (value.capacity < min) {
return sameAsOs
? {
tooSmallBoth: this.i18n.transform(
'OS + data combined require at least 38 GB',
),
}
: {
tooSmallData: this.i18n.transform(
'Data drive must be at least 20 GB',
),
}
}
return null
}
drives: DiskInfo[] = [] drives: DiskInfo[] = []
loading = true loading = true
shuttingDown = false shuttingDown = false
private dialogSub?: Subscription private dialogSub?: Subscription
selectedOsDrive: 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 => readonly driveName = (drive: DiskInfo): string =>
[drive.vendor, drive.model].filter(Boolean).join(' ') || [drive.vendor, drive.model].filter(Boolean).join(' ') ||
this.i18n.transform('Unknown Drive') this.i18n.transform('Unknown Drive')
@@ -228,51 +290,40 @@ export default class DrivesPage {
async ngOnInit() { async ngOnInit() {
await this.loadDrives() await this.loadDrives()
this.form.controls.osDrive.valueChanges.subscribe(drive => {
if (drive) {
this.form.controls.osDrive.markAsTouched()
}
})
this.form.controls.dataDrive.valueChanges
.pipe(distinctUntilChanged())
.subscribe(drive => {
this.preserveData = null
if (drive) {
this.form.controls.dataDrive.markAsTouched()
if (toGuid(drive)) {
this.showPreserveOverwriteDialog()
}
}
})
} }
async refresh() { async refresh() {
this.loading = true this.loading = true
this.selectedOsDrive = null this.form.reset()
this.selectedDataDrive = null
this.preserveData = null this.preserveData = null
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) {
this.preserveData = null
if (!drive) {
return
}
const hasStartOSData = !!toGuid(drive)
if (hasStartOSData) {
this.showPreserveOverwriteDialog()
}
}
continue() { continue() {
if (!this.selectedOsDrive || !this.selectedDataDrive) return const osDrive = this.form.controls.osDrive.value
const dataDrive = this.form.controls.dataDrive.value
if (!osDrive || !dataDrive) return
const sameDevice = const sameDevice = osDrive.logicalname === dataDrive.logicalname
this.selectedOsDrive.logicalname === this.selectedDataDrive.logicalname const dataHasStartOS = !!toGuid(dataDrive)
const dataHasStartOS = !!toGuid(this.selectedDataDrive)
// Scenario 1: Same drive, has StartOS data, preserving → no warning // Scenario 1: Same drive, has StartOS data, preserving → no warning
if (sameDevice && dataHasStartOS && this.preserveData) { if (sameDevice && dataHasStartOS && this.preserveData) {
@@ -292,7 +343,7 @@ export default class DrivesPage {
private showPreserveOverwriteDialog() { private showPreserveOverwriteDialog() {
let selectionMade = false let selectionMade = false
const drive = this.selectedDataDrive const drive = this.form.controls.dataDrive.value
const filesystem = const filesystem =
drive?.filesystem || drive?.filesystem ||
drive?.partitions.find(p => p.guid)?.filesystem || drive?.partitions.find(p => p.guid)?.filesystem ||
@@ -304,20 +355,20 @@ export default class DrivesPage {
data: { isExt4 }, data: { isExt4 },
}) })
.subscribe({ .subscribe({
next: preserve => { next: preserve => {
selectionMade = true selectionMade = true
this.preserveData = preserve this.preserveData = preserve
this.cdr.markForCheck()
},
complete: () => {
if (!selectionMade) {
// Dialog was dismissed without selection - clear the data drive
this.selectedDataDrive = null
this.preserveData = null
this.cdr.markForCheck() this.cdr.markForCheck()
} },
}, complete: () => {
}) if (!selectionMade) {
// Dialog was dismissed without selection - clear the data drive
this.form.controls.dataDrive.reset()
this.preserveData = null
this.cdr.markForCheck()
}
},
})
} }
private showOsDriveWarning() { private showOsDriveWarning() {
@@ -360,13 +411,15 @@ export default class DrivesPage {
} }
private async installOs(wipe: boolean) { private async installOs(wipe: boolean) {
const osDrive = this.form.controls.osDrive.value!
const dataDrive = this.form.controls.dataDrive.value!
const loader = this.loader.open('Installing StartOS').subscribe() const loader = this.loader.open('Installing StartOS').subscribe()
try { try {
const result = await this.api.installOs({ const result = await this.api.installOs({
osDrive: this.selectedOsDrive!.logicalname, osDrive: osDrive.logicalname,
dataDrive: { dataDrive: {
logicalname: this.selectedDataDrive!.logicalname, logicalname: dataDrive.logicalname,
wipe, wipe,
}, },
}) })

View File

@@ -628,8 +628,8 @@ export default {
697: 'Geben Sie das Passwort ein, das zum Verschlüsseln dieses Backups verwendet wurde.', 697: 'Geben Sie das Passwort ein, das zum Verschlüsseln dieses Backups verwendet wurde.',
698: 'Mehrere Backups gefunden. Wählen Sie aus, welches wiederhergestellt werden soll.', 698: 'Mehrere Backups gefunden. Wählen Sie aus, welches wiederhergestellt werden soll.',
699: 'Backups', 699: 'Backups',
700: 'Das Laufwerk, auf dem das StartOS-Betriebssystem installiert wird.', 700: 'Das Laufwerk, auf dem das StartOS-Betriebssystem installiert wird. Mindestens 18 GB.',
701: 'Das Laufwerk, auf dem Ihre StartOS-Daten (Dienste, Einstellungen usw.) gespeichert werden. Dies kann dasselbe wie das OS-Laufwerk oder ein separates Laufwerk sein.', 701: 'Das Laufwerk, auf dem Ihre StartOS-Daten (Dienste, Einstellungen usw.) gespeichert werden. Dies kann dasselbe wie das OS-Laufwerk oder ein separates Laufwerk sein. Mindestens 20 GB, oder 38 GB bei Verwendung eines einzelnen Laufwerks für OS und Daten.',
702: 'Versuchen Sie nach der Datenübertragung von diesem Laufwerk nicht, erneut als Start9-Server davon zu booten. Dies kann zu Fehlfunktionen von Diensten, Datenbeschädigung oder Geldverlust führen.', 702: 'Versuchen Sie nach der Datenübertragung von diesem Laufwerk nicht, erneut als Start9-Server davon zu booten. Dies kann zu Fehlfunktionen von Diensten, Datenbeschädigung oder Geldverlust führen.',
703: 'Muss mindestens 12 Zeichen lang sein', 703: 'Muss mindestens 12 Zeichen lang sein',
704: 'Darf höchstens 64 Zeichen lang sein', 704: 'Darf höchstens 64 Zeichen lang sein',
@@ -724,4 +724,7 @@ export default {
808: 'Hostname geändert, Neustart damit installierte Dienste die neue Adresse verwenden', 808: 'Hostname geändert, Neustart damit installierte Dienste die neue Adresse verwenden',
809: 'Sprache geändert, Neustart damit installierte Dienste die neue Sprache verwenden', 809: 'Sprache geändert, Neustart damit installierte Dienste die neue Sprache verwenden',
810: 'Kioskmodus geändert, Neustart zum Anwenden', 810: 'Kioskmodus geändert, Neustart zum Anwenden',
811: 'OS-Laufwerk muss mindestens 18 GB groß sein',
812: 'Datenlaufwerk muss mindestens 20 GB groß sein',
813: 'OS + Daten zusammen erfordern mindestens 38 GB',
} satisfies i18n } satisfies i18n

View File

@@ -628,8 +628,8 @@ export const ENGLISH: Record<string, number> = {
'Enter the password that was used to encrypt this backup.': 697, 'Enter the password that was used to encrypt this backup.': 697,
'Multiple backups found. Select which one to restore.': 698, 'Multiple backups found. Select which one to restore.': 698,
'Backups': 699, 'Backups': 699,
'The drive where the StartOS operating system will be installed.': 700, 'The drive where the StartOS operating system will be installed. Minimum 18 GB.': 700,
'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.': 701, '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. Minimum 20 GB, or 38 GB if using a single drive for both OS and data.': 701,
'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.': 702, 'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.': 702,
'Must be 12 characters or greater': 703, 'Must be 12 characters or greater': 703,
'Must be 64 character or less': 704, 'Must be 64 character or less': 704,
@@ -725,4 +725,7 @@ export const ENGLISH: Record<string, number> = {
'Hostname changed, restart for installed services to use the new address': 808, 'Hostname changed, restart for installed services to use the new address': 808,
'Language changed, restart for installed services to use the new language': 809, 'Language changed, restart for installed services to use the new language': 809,
'Kiosk mode changed, restart to apply': 810, 'Kiosk mode changed, restart to apply': 810,
'OS drive must be at least 18 GB': 811,
'Data drive must be at least 20 GB': 812,
'OS + data combined require at least 38 GB': 813,
} }

View File

@@ -628,8 +628,8 @@ export default {
697: 'Introduzca la contraseña que se utilizó para cifrar esta copia de seguridad.', 697: 'Introduzca la contraseña que se utilizó para cifrar esta copia de seguridad.',
698: 'Se encontraron varias copias de seguridad. Seleccione cuál restaurar.', 698: 'Se encontraron varias copias de seguridad. Seleccione cuál restaurar.',
699: 'Copias de seguridad', 699: 'Copias de seguridad',
700: 'La unidad donde se instalará el sistema operativo StartOS.', 700: 'La unidad donde se instalará el sistema operativo StartOS. Mínimo 18 GB.',
701: 'La unidad donde se almacenarán sus datos de StartOS (servicios, ajustes, etc.). Puede ser la misma que la unidad del sistema operativo o una unidad separada.', 701: 'La unidad donde se almacenarán sus datos de StartOS (servicios, ajustes, etc.). Puede ser la misma que la unidad del sistema operativo o una unidad separada. Mínimo 20 GB, o 38 GB si se usa una sola unidad para el sistema operativo y los datos.',
702: 'Después de transferir datos desde esta unidad, no intente arrancar desde ella nuevamente como un servidor Start9. Esto puede provocar fallos en los servicios, corrupción de datos o pérdida de fondos.', 702: 'Después de transferir datos desde esta unidad, no intente arrancar desde ella nuevamente como un servidor Start9. Esto puede provocar fallos en los servicios, corrupción de datos o pérdida de fondos.',
703: 'Debe tener 12 caracteres o más', 703: 'Debe tener 12 caracteres o más',
704: 'Debe tener 64 caracteres o menos', 704: 'Debe tener 64 caracteres o menos',
@@ -724,4 +724,7 @@ export default {
808: 'Nombre de host cambiado, reiniciar para que los servicios instalados usen la nueva dirección', 808: 'Nombre de host cambiado, reiniciar para que los servicios instalados usen la nueva dirección',
809: 'Idioma cambiado, reiniciar para que los servicios instalados usen el nuevo idioma', 809: 'Idioma cambiado, reiniciar para que los servicios instalados usen el nuevo idioma',
810: 'Modo kiosco cambiado, reiniciar para aplicar', 810: 'Modo kiosco cambiado, reiniciar para aplicar',
811: 'La unidad del SO debe tener al menos 18 GB',
812: 'La unidad de datos debe tener al menos 20 GB',
813: 'SO + datos combinados requieren al menos 38 GB',
} satisfies i18n } satisfies i18n

View File

@@ -628,8 +628,8 @@ export default {
697: 'Saisissez le mot de passe utilisé pour chiffrer cette sauvegarde.', 697: 'Saisissez le mot de passe utilisé pour chiffrer cette sauvegarde.',
698: 'Plusieurs sauvegardes trouvées. Sélectionnez celle à restaurer.', 698: 'Plusieurs sauvegardes trouvées. Sélectionnez celle à restaurer.',
699: 'Sauvegardes', 699: 'Sauvegardes',
700: 'Le disque sur lequel le système dexploitation StartOS sera installé.', 700: 'Le disque sur lequel le système dexploitation StartOS sera installé. Minimum 18 Go.',
701: 'Le disque sur lequel vos données StartOS (services, paramètres, etc.) seront stockées. Il peut sagir du même disque que le système ou dun disque séparé.', 701: 'Le disque sur lequel vos données StartOS (services, paramètres, etc.) seront stockées. Il peut sagir du même disque que le système ou dun disque séparé. Minimum 20 Go, ou 38 Go si un seul disque est utilisé pour le système et les données.',
702: 'Après le transfert des données depuis ce disque, nessayez pas de démarrer dessus à nouveau en tant que serveur Start9. Cela peut entraîner des dysfonctionnements des services, une corruption des données ou une perte de fonds.', 702: 'Après le transfert des données depuis ce disque, nessayez pas de démarrer dessus à nouveau en tant que serveur Start9. Cela peut entraîner des dysfonctionnements des services, une corruption des données ou une perte de fonds.',
703: 'Doit comporter au moins 12 caractères', 703: 'Doit comporter au moins 12 caractères',
704: 'Doit comporter au maximum 64 caractères', 704: 'Doit comporter au maximum 64 caractères',
@@ -724,4 +724,7 @@ export default {
808: "Nom d'hôte modifié, redémarrer pour que les services installés utilisent la nouvelle adresse", 808: "Nom d'hôte modifié, redémarrer pour que les services installés utilisent la nouvelle adresse",
809: 'Langue modifiée, redémarrer pour que les services installés utilisent la nouvelle langue', 809: 'Langue modifiée, redémarrer pour que les services installés utilisent la nouvelle langue',
810: 'Mode kiosque modifié, redémarrer pour appliquer', 810: 'Mode kiosque modifié, redémarrer pour appliquer',
811: 'Le disque système doit faire au moins 18 Go',
812: 'Le disque de données doit faire au moins 20 Go',
813: 'Système + données combinés nécessitent au moins 38 Go',
} satisfies i18n } satisfies i18n

View File

@@ -628,8 +628,8 @@ export default {
697: 'Wprowadź hasło użyte do zaszyfrowania tej kopii zapasowej.', 697: 'Wprowadź hasło użyte do zaszyfrowania tej kopii zapasowej.',
698: 'Znaleziono wiele kopii zapasowych. Wybierz, którą przywrócić.', 698: 'Znaleziono wiele kopii zapasowych. Wybierz, którą przywrócić.',
699: 'Kopie zapasowe', 699: 'Kopie zapasowe',
700: 'Dysk, na którym zostanie zainstalowany system operacyjny StartOS.', 700: 'Dysk, na którym zostanie zainstalowany system operacyjny StartOS. Minimum 18 GB.',
701: 'Dysk, na którym będą przechowywane dane StartOS (usługi, ustawienia itp.). Może to być ten sam dysk co systemowy lub oddzielny dysk.', 701: 'Dysk, na którym będą przechowywane dane StartOS (usługi, ustawienia itp.). Może to być ten sam dysk co systemowy lub oddzielny dysk. Minimum 20 GB lub 38 GB w przypadku jednego dysku na system i dane.',
702: 'Po przeniesieniu danych z tego dysku nie próbuj ponownie uruchamiać z niego systemu jako serwer Start9. Może to spowodować nieprawidłowe działanie usług, uszkodzenie danych lub utratę środków.', 702: 'Po przeniesieniu danych z tego dysku nie próbuj ponownie uruchamiać z niego systemu jako serwer Start9. Może to spowodować nieprawidłowe działanie usług, uszkodzenie danych lub utratę środków.',
703: 'Musi mieć co najmniej 12 znaków', 703: 'Musi mieć co najmniej 12 znaków',
704: 'Musi mieć maksymalnie 64 znaki', 704: 'Musi mieć maksymalnie 64 znaki',
@@ -724,4 +724,7 @@ export default {
808: 'Nazwa hosta zmieniona, uruchom ponownie, aby zainstalowane usługi używały nowego adresu', 808: 'Nazwa hosta zmieniona, uruchom ponownie, aby zainstalowane usługi używały nowego adresu',
809: 'Język zmieniony, uruchom ponownie, aby zainstalowane usługi używały nowego języka', 809: 'Język zmieniony, uruchom ponownie, aby zainstalowane usługi używały nowego języka',
810: 'Tryb kiosku zmieniony, uruchom ponownie, aby zastosować', 810: 'Tryb kiosku zmieniony, uruchom ponownie, aby zastosować',
811: 'Dysk systemowy musi mieć co najmniej 18 GB',
812: 'Dysk danych musi mieć co najmniej 20 GB',
813: 'System + dane łącznie wymagają co najmniej 38 GB',
} satisfies i18n } satisfies i18n

View File

@@ -31,7 +31,7 @@ import { hasCurrentDeps } from 'src/app/utils/has-deps'
import { MarketplaceAlertsService } from '../services/alerts.service' import { MarketplaceAlertsService } from '../services/alerts.service'
type KEYS = 'id' | 'version' | 'alerts' | 'flavor' type KEYS = 'id' | 'version' | 'alerts' | 'flavor' | 'satisfies'
@Component({ @Component({
selector: 'marketplace-controls', selector: 'marketplace-controls',
@@ -185,9 +185,13 @@ export class MarketplaceControlsComponent {
} }
private async dryInstall(url: string | null) { private async dryInstall(url: string | null) {
const { id, version } = this.pkg() const { id, version, satisfies } = this.pkg()
const packages = await getAllPackages(this.patch) const packages = await getAllPackages(this.patch)
const breakages = dryUpdate({ id, version }, packages, this.exver) const breakages = dryUpdate(
{ id, version, satisfies: satisfies || [] },
packages,
this.exver,
)
if (!breakages.length || (await this.alerts.alertBreakages(breakages))) { if (!breakages.length || (await this.alerts.alertBreakages(breakages))) {
this.installOrUpload(url) this.installOrUpload(url)

View File

@@ -14,7 +14,6 @@ import {
TuiNotification, TuiNotification,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus' import { injectContext } from '@taiga-ui/polymorpheus'
import * as json from 'fast-json-patch'
import { compare } from 'fast-json-patch' import { compare } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { catchError, EMPTY, endWith, firstValueFrom, from, map } from 'rxjs' import { catchError, EMPTY, endWith, firstValueFrom, from, map } from 'rxjs'
@@ -191,9 +190,7 @@ export class ActionInputModal {
task.actionId === this.actionId && task.actionId === this.actionId &&
task.when?.condition === 'input-not-matches' && task.when?.condition === 'input-not-matches' &&
task.input && task.input &&
json conflicts(task.input.value, input),
.compare(input, task.input.value)
.some(op => op.op === 'add' || op.op === 'replace'),
), ),
) )
.map(id => id) .map(id => id)
@@ -214,3 +211,26 @@ export class ActionInputModal {
) )
} }
} }
// Mirrors the Rust backend's `conflicts()` function in core/src/service/action.rs.
// A key in the partial that is missing from the full input is NOT a conflict.
function conflicts(left: unknown, right: unknown): boolean {
if (
typeof left === 'object' &&
left !== null &&
!Array.isArray(left) &&
typeof right === 'object' &&
right !== null &&
!Array.isArray(right)
) {
const l = left as Record<string, unknown>
const r = right as Record<string, unknown>
return Object.keys(l).some(k => (k in r ? conflicts(l[k], r[k]) : false))
}
if (Array.isArray(left) && Array.isArray(right)) {
return left.some(v => right.every(vr => conflicts(v, vr)))
}
return left !== right
}

View File

@@ -54,7 +54,7 @@ export default class StartOsUiComponent {
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
readonly iface: T.ServiceInterface = { readonly iface: T.ServiceInterface = {
id: '', id: 'startos-ui',
name: 'StartOS UI', name: 'StartOS UI',
description: this.i18n.transform( description: this.i18n.transform(
'The web user interface for your StartOS server, accessible from any browser.', 'The web user interface for your StartOS server, accessible from any browser.',

View File

@@ -18,10 +18,10 @@ import {
} from '@start9labs/shared' } from '@start9labs/shared'
import { import {
TuiButton, TuiButton,
TuiExpand,
TuiIcon, TuiIcon,
TuiLink, TuiLink,
TuiTitle, TuiTitle,
TuiExpand,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { NgDompurifyPipe } from '@taiga-ui/dompurify' import { NgDompurifyPipe } from '@taiga-ui/dompurify'
import { import {
@@ -199,6 +199,7 @@ import UpdatesComponent from './updates.component'
&[colspan]:only-child { &[colspan]:only-child {
padding: 0 3rem; padding: 0 3rem;
text-align: left; text-align: left;
white-space: normal;
} }
} }

View File

@@ -459,6 +459,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: BTC_ICON, icon: BTC_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: {}, dependencyMetadata: {},
donationUrl: null, donationUrl: null,
alerts: { alerts: {
@@ -501,6 +502,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: BTC_ICON, icon: BTC_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: {}, dependencyMetadata: {},
donationUrl: null, donationUrl: null,
alerts: { alerts: {
@@ -553,6 +555,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: BTC_ICON, icon: BTC_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: {}, dependencyMetadata: {},
donationUrl: null, donationUrl: null,
alerts: { alerts: {
@@ -595,6 +598,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: BTC_ICON, icon: BTC_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: {}, dependencyMetadata: {},
donationUrl: null, donationUrl: null,
alerts: { alerts: {
@@ -649,6 +653,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: LND_ICON, icon: LND_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: { dependencyMetadata: {
bitcoind: BitcoinDep, bitcoind: BitcoinDep,
'btc-rpc-proxy': ProxyDep, 'btc-rpc-proxy': ProxyDep,
@@ -704,6 +709,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: LND_ICON, icon: LND_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: { dependencyMetadata: {
bitcoind: BitcoinDep, bitcoind: BitcoinDep,
'btc-rpc-proxy': ProxyDep, 'btc-rpc-proxy': ProxyDep,
@@ -757,12 +763,74 @@ export namespace Mock {
upstreamRepo: 'https://github.com/bitcoin/bitcoin', upstreamRepo: 'https://github.com/bitcoin/bitcoin',
marketingUrl: 'https://bitcoin.org', marketingUrl: 'https://bitcoin.org',
docsUrls: ['https://bitcoin.org'], docsUrls: ['https://bitcoin.org'],
releaseNotes: 'Even better support for Bitcoin and wallets!', releaseNotes: `# Bitcoin Core v27.0.0 Release Notes
## Overview
This is a major release of Bitcoin Core with significant performance improvements, new RPC methods, and critical security patches. We strongly recommend all users upgrade as soon as possible.
## Breaking Changes
- The deprecated \`getinfo\` RPC has been fully removed. Use \`getblockchaininfo\`, \`getnetworkinfo\`, and \`getwalletinfo\` instead.
- Configuration option \`rpcallowip\` no longer accepts hostnames — only CIDR notation is supported (e.g. \`192.168.1.0/24\`).
- The wallet database format has been migrated from BerkeleyDB to SQLite. Existing wallets will be automatically converted on first load. **This migration is irreversible.**
## New Features
- **Compact Block Filters (BIP 158):** Full support for serving compact block filters to light clients over the P2P network. Enable with \`-blockfilterindex=basic -peerblockfilters=1\`.
- **Miniscript support in descriptors:** You can now use miniscript policies inside \`wsh()\` descriptors for more expressive spending conditions.
- **New RPC: \`getdescriptoractivity\`:** Returns all wallet-relevant transactions for a given set of output descriptors within a block range.
## Performance Improvements
- Block validation is now 18% faster due to improved UTXO cache management and parallel script verification.
- Initial block download (IBD) time reduced by approximately 25% on NVMe storage thanks to batched database writes.
- Memory usage during reindex reduced from ~4.2 GB to ~2.8 GB peak.
## Configuration Changes
\`\`\`ini
# New options added in this release
blockfilterindex=basic # Enable BIP 158 compact block filter index
peerblockfilters=1 # Serve compact block filters to peers
shutdownnotify=<cmd> # Execute command on clean shutdown
v2transport=1 # Prefer BIP 324 encrypted P2P connections
\`\`\`
## Bug Fixes
1. Fixed a race condition in the mempool acceptance logic that could cause \`submitblock\` to return stale rejection reasons under high transaction throughput.
2. Corrected fee estimation for transactions with many inputs where the estimator previously overestimated by up to 15%.
3. Resolved an edge case where \`pruneblockchain\` could delete blocks still needed by an in-progress \`rescanblockchain\` operation.
4. Fixed incorrect handling of \`OP_CHECKSIGADD\` in legacy script verification mode that could lead to consensus divergence on certain non-standard transactions.
5. Patched a denial-of-service vector where a malicious peer could send specially crafted \`inv\` messages causing excessive memory allocation in the transaction request tracker.
## Dependency Updates
| Dependency | Old Version | New Version |
|------------|-------------|-------------|
| OpenSSL | 1.1.1w | 3.0.13 |
| libevent | 2.1.12 | 2.2.1 |
| Boost | 1.81.0 | 1.84.0 |
| SQLite | 3.38.5 | 3.45.1 |
| miniupnpc | 2.2.4 | 2.2.7 |
## Migration Guide
For users running Bitcoin Core as a service behind a reverse proxy, note that the default RPC authentication mechanism now uses cookie-based auth by default. If you previously relied on \`rpcuser\`/\`rpcpassword\`, you must explicitly set \`rpcauth\` in your configuration file. See https://github.com/bitcoin/bitcoin/blob/master/share/rpcauth/rpcauth.py for the auth string generator.
## Known Issues
- Wallet encryption with very long passphrases (>1024 characters) may cause the wallet to become temporarily unresponsive during unlock. A fix is planned for v27.0.1.
- The \`listtransactions\` RPC may return duplicate entries when called with \`include_watchonly=true\` on descriptor wallets that share derivation paths across multiple descriptors.
For the full changelog, see https://github.com/bitcoin/bitcoin/blob/v27.0.0/doc/release-notes/release-notes-27.0.0.md#full-changelog-with-detailed-descriptions-of-every-commit-and-pull-request-merged`,
osVersion: '0.4.0', osVersion: '0.4.0',
sdkVersion: '0.4.0-beta.49', sdkVersion: '0.4.0-beta.49',
gitHash: 'fakehash', gitHash: 'fakehash',
icon: BTC_ICON, icon: BTC_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: {}, dependencyMetadata: {},
donationUrl: null, donationUrl: null,
alerts: { alerts: {
@@ -805,6 +873,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: BTC_ICON, icon: BTC_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: {}, dependencyMetadata: {},
donationUrl: null, donationUrl: null,
alerts: { alerts: {
@@ -857,6 +926,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: LND_ICON, icon: LND_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: { dependencyMetadata: {
bitcoind: BitcoinDep, bitcoind: BitcoinDep,
'btc-rpc-proxy': ProxyDep, 'btc-rpc-proxy': ProxyDep,
@@ -912,6 +982,7 @@ export namespace Mock {
gitHash: 'fakehash', gitHash: 'fakehash',
icon: PROXY_ICON, icon: PROXY_ICON,
sourceVersion: null, sourceVersion: null,
satisfies: [],
dependencyMetadata: { dependencyMetadata: {
bitcoind: BitcoinDep, bitcoind: BitcoinDep,
}, },

View File

@@ -3,7 +3,11 @@ import { DataModel } from '../services/patch-db/data-model'
import { getManifest } from './get-package-data' import { getManifest } from './get-package-data'
export function dryUpdate( export function dryUpdate(
{ id, version }: { id: string; version: string }, {
id,
version,
satisfies,
}: { id: string; version: string; satisfies: string[] },
pkgs: DataModel['packageData'], pkgs: DataModel['packageData'],
exver: Exver, exver: Exver,
): string[] { ): string[] {
@@ -13,10 +17,24 @@ export function dryUpdate(
Object.keys(pkg.currentDependencies || {}).some( Object.keys(pkg.currentDependencies || {}).some(
pkgId => pkgId === id, pkgId => pkgId === id,
) && ) &&
!exver.satisfies( !versionSatisfies(
version, version,
satisfies,
pkg.currentDependencies[id]?.versionRange || '', pkg.currentDependencies[id]?.versionRange || '',
exver,
), ),
) )
.map(pkg => getManifest(pkg).title) .map(pkg => getManifest(pkg).title)
} }
function versionSatisfies(
version: string,
satisfies: string[],
range: string,
exver: Exver,
): boolean {
return (
exver.satisfies(version, range) ||
satisfies.some(v => exver.satisfies(v, range))
)
}