Compare commits

..

2 Commits

Author SHA1 Message Date
Matt Hill
392ae2d675 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 11:38:01 -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
11 changed files with 69 additions and 13 deletions

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

@@ -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

@@ -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,
@@ -763,6 +769,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: {
@@ -805,6 +812,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 +865,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 +921,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))
)
}