mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 04:01:58 +00:00
Compare commits
3 Commits
bugfix/reg
...
fix/volume
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e7d33b07f | ||
|
|
b6262c8e13 | ||
|
|
ba740a9ee2 |
301
agents/exver.md
Normal file
301
agents/exver.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# exver — Extended Versioning
|
||||||
|
|
||||||
|
Extended semver supporting **downstream versioning** (wrapper updates independent of upstream) and **flavors** (package fork variants).
|
||||||
|
|
||||||
|
Two implementations exist:
|
||||||
|
- **Rust crate** (`exver`) — used in `core/`. Source: https://github.com/Start9Labs/exver-rs
|
||||||
|
- **TypeScript** (`sdk/base/lib/exver/index.ts`) — used in `sdk/` and `web/`
|
||||||
|
|
||||||
|
Both parse the same string format and agree on `satisfies` semantics.
|
||||||
|
|
||||||
|
## Version Format
|
||||||
|
|
||||||
|
An **ExtendedVersion** string looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[#flavor:]upstream:downstream
|
||||||
|
```
|
||||||
|
|
||||||
|
- **upstream** — the original package version (semver-style: `1.2.3`, `1.2.3-beta.1`)
|
||||||
|
- **downstream** — the StartOS wrapper version (incremented independently)
|
||||||
|
- **flavor** — optional lowercase ASCII prefix for fork variants
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `1.2.3:0` — upstream 1.2.3, first downstream release
|
||||||
|
- `1.2.3:2` — upstream 1.2.3, third downstream release
|
||||||
|
- `#bitcoin:21.0:1` — bitcoin flavor, upstream 21.0, downstream 1
|
||||||
|
- `1.0.0-rc.1:0` — upstream with prerelease tag
|
||||||
|
|
||||||
|
## Core Types
|
||||||
|
|
||||||
|
### `Version`
|
||||||
|
|
||||||
|
A semver-style version with arbitrary digit segments and optional prerelease.
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
```rust
|
||||||
|
use exver::Version;
|
||||||
|
|
||||||
|
let v = Version::new([1, 2, 3], []); // 1.2.3
|
||||||
|
let v = Version::new([1, 0], ["beta".into()]); // 1.0-beta
|
||||||
|
let v: Version = "1.2.3".parse().unwrap();
|
||||||
|
|
||||||
|
v.number() // &[1, 2, 3]
|
||||||
|
v.prerelease() // &[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
const v = new Version([1, 2, 3], [])
|
||||||
|
const v = Version.parse("1.2.3")
|
||||||
|
|
||||||
|
v.number // number[]
|
||||||
|
v.prerelease // (string | number)[]
|
||||||
|
v.compare(other) // 'greater' | 'equal' | 'less'
|
||||||
|
v.compareForSort(other) // -1 | 0 | 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Default: `0`
|
||||||
|
|
||||||
|
### `ExtendedVersion`
|
||||||
|
|
||||||
|
The primary version type. Wraps upstream + downstream `Version` plus an optional flavor.
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
```rust
|
||||||
|
use exver::ExtendedVersion;
|
||||||
|
|
||||||
|
let ev = ExtendedVersion::new(
|
||||||
|
Version::new([1, 2, 3], []),
|
||||||
|
Version::default(), // downstream = 0
|
||||||
|
);
|
||||||
|
let ev: ExtendedVersion = "1.2.3:0".parse().unwrap();
|
||||||
|
|
||||||
|
ev.flavor() // Option<&str>
|
||||||
|
ev.upstream() // &Version
|
||||||
|
ev.downstream() // &Version
|
||||||
|
|
||||||
|
// Builder methods (consuming):
|
||||||
|
ev.with_flavor("bitcoin")
|
||||||
|
ev.without_flavor()
|
||||||
|
ev.map_upstream(|v| ...)
|
||||||
|
ev.map_downstream(|v| ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
const ev = new ExtendedVersion(null, upstream, downstream)
|
||||||
|
const ev = ExtendedVersion.parse("1.2.3:0")
|
||||||
|
const ev = ExtendedVersion.parseEmver("1.2.3.4") // emver compat
|
||||||
|
|
||||||
|
ev.flavor // string | null
|
||||||
|
ev.upstream // Version
|
||||||
|
ev.downstream // Version
|
||||||
|
|
||||||
|
ev.compare(other) // 'greater' | 'equal' | 'less' | null
|
||||||
|
ev.equals(other) // boolean
|
||||||
|
ev.greaterThan(other) // boolean
|
||||||
|
ev.lessThan(other) // boolean
|
||||||
|
ev.incrementMajor() // new ExtendedVersion
|
||||||
|
ev.incrementMinor() // new ExtendedVersion
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ordering:** Versions with different flavors are **not comparable** (`PartialOrd`/`compare` returns `None`/`null`).
|
||||||
|
|
||||||
|
Default: `0:0`
|
||||||
|
|
||||||
|
### `VersionString` (Rust only, StartOS wrapper)
|
||||||
|
|
||||||
|
Defined in `core/src/util/version.rs`. Caches the original string representation alongside the parsed `ExtendedVersion`. Used as the key type in registry version maps.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::util::VersionString;
|
||||||
|
|
||||||
|
let vs: VersionString = "1.2.3:0".parse().unwrap();
|
||||||
|
let vs = VersionString::from(extended_version);
|
||||||
|
|
||||||
|
// Deref to ExtendedVersion:
|
||||||
|
vs.satisfies(&range);
|
||||||
|
vs.upstream();
|
||||||
|
|
||||||
|
// String access:
|
||||||
|
vs.as_str(); // &str
|
||||||
|
AsRef::<str>::as_ref(&vs);
|
||||||
|
```
|
||||||
|
|
||||||
|
`Ord` is implemented with a total ordering — versions with different flavors are ordered by flavor name (unflavored sorts last).
|
||||||
|
|
||||||
|
### `VersionRange`
|
||||||
|
|
||||||
|
A predicate over `ExtendedVersion`. Supports comparison operators, boolean logic, and flavor constraints.
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
```rust
|
||||||
|
use exver::VersionRange;
|
||||||
|
|
||||||
|
// Constructors:
|
||||||
|
VersionRange::any() // matches everything
|
||||||
|
VersionRange::none() // matches nothing
|
||||||
|
VersionRange::exactly(ev) // = ev
|
||||||
|
VersionRange::anchor(GTE, ev) // >= ev
|
||||||
|
VersionRange::caret(ev) // ^ev (compatible changes)
|
||||||
|
VersionRange::tilde(ev) // ~ev (patch-level changes)
|
||||||
|
|
||||||
|
// Combinators (smart — eagerly simplify):
|
||||||
|
VersionRange::and(a, b) // a && b
|
||||||
|
VersionRange::or(a, b) // a || b
|
||||||
|
VersionRange::not(a) // !a
|
||||||
|
|
||||||
|
// Parsing:
|
||||||
|
let r: VersionRange = ">=1.0.0:0".parse().unwrap();
|
||||||
|
let r: VersionRange = "^1.2.3:0".parse().unwrap();
|
||||||
|
let r: VersionRange = ">=1.0.0 <2.0.0".parse().unwrap(); // implicit AND
|
||||||
|
let r: VersionRange = ">=1.0.0 || >=2.0.0".parse().unwrap();
|
||||||
|
let r: VersionRange = "#bitcoin".parse().unwrap(); // flavor match
|
||||||
|
let r: VersionRange = "*".parse().unwrap(); // any
|
||||||
|
|
||||||
|
// Monoid wrappers for folding:
|
||||||
|
AnyRange // fold with or, empty = None
|
||||||
|
AllRange // fold with and, empty = Any
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
// Constructors:
|
||||||
|
VersionRange.any()
|
||||||
|
VersionRange.none()
|
||||||
|
VersionRange.anchor('=', ev)
|
||||||
|
VersionRange.anchor('>=', ev)
|
||||||
|
VersionRange.anchor('^', ev) // ^ and ~ are first-class operators
|
||||||
|
VersionRange.anchor('~', ev)
|
||||||
|
VersionRange.flavor(null) // match unflavored versions
|
||||||
|
VersionRange.flavor("bitcoin") // match #bitcoin versions
|
||||||
|
|
||||||
|
// Combinators — static (smart, variadic):
|
||||||
|
VersionRange.and(a, b, c, ...)
|
||||||
|
VersionRange.or(a, b, c, ...)
|
||||||
|
|
||||||
|
// Combinators — instance (not smart, just wrap):
|
||||||
|
range.and(other)
|
||||||
|
range.or(other)
|
||||||
|
range.not()
|
||||||
|
|
||||||
|
// Parsing:
|
||||||
|
VersionRange.parse(">=1.0.0:0")
|
||||||
|
VersionRange.parseEmver(">=1.2.3.4") // emver compat
|
||||||
|
|
||||||
|
// Analysis (TS only):
|
||||||
|
range.normalize() // canonical form (see below)
|
||||||
|
range.satisfiable() // boolean
|
||||||
|
range.intersects(other) // boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checking satisfaction:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Rust:
|
||||||
|
version.satisfies(&range) // bool
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
// TypeScript:
|
||||||
|
version.satisfies(range) // boolean
|
||||||
|
range.satisfiedBy(version) // boolean (convenience)
|
||||||
|
```
|
||||||
|
|
||||||
|
Also available on `Version` (wraps in `ExtendedVersion` with downstream=0).
|
||||||
|
|
||||||
|
When no operator is specified in a range string, `^` (caret) is the default.
|
||||||
|
|
||||||
|
## Operators
|
||||||
|
|
||||||
|
| Syntax | Rust | TS | Meaning |
|
||||||
|
|--------|------|----|---------|
|
||||||
|
| `=` | `EQ` | `'='` | Equal |
|
||||||
|
| `!=` | `NEQ` | `'!='` | Not equal |
|
||||||
|
| `>` | `GT` | `'>'` | Greater than |
|
||||||
|
| `>=` | `GTE` | `'>='` | Greater than or equal |
|
||||||
|
| `<` | `LT` | `'<'` | Less than |
|
||||||
|
| `<=` | `LTE` | `'<='` | Less than or equal |
|
||||||
|
| `^` | expanded to `And(GTE, LT)` | `'^'` | Compatible (first non-zero digit unchanged) |
|
||||||
|
| `~` | expanded to `And(GTE, LT)` | `'~'` | Patch-level (minor unchanged) |
|
||||||
|
|
||||||
|
## Flavor Rules
|
||||||
|
|
||||||
|
- Versions with **different flavors** never satisfy comparison operators (except `!=`, which returns true)
|
||||||
|
- `VersionRange::Flavor(Some("bitcoin"))` matches only `#bitcoin:*` versions
|
||||||
|
- `VersionRange::Flavor(None)` matches only unflavored versions
|
||||||
|
- Flavor constraints compose with `and`/`or`/`not` like any other range
|
||||||
|
|
||||||
|
## Reduction and Normalization
|
||||||
|
|
||||||
|
### Rust: `reduce()` (shallow)
|
||||||
|
|
||||||
|
`VersionRange::reduce(self) -> Self` re-applies smart constructor rules to one level of the AST. Useful for simplifying a node that was constructed directly (e.g. deserialized) rather than through the smart constructors.
|
||||||
|
|
||||||
|
**Smart constructor rules applied by `and`, `or`, `not`, and `reduce`:**
|
||||||
|
|
||||||
|
`and`:
|
||||||
|
- `and(Any, b) → b`, `and(a, Any) → a`
|
||||||
|
- `and(None, _) → None`, `and(_, None) → None`
|
||||||
|
|
||||||
|
`or`:
|
||||||
|
- `or(Any, _) → Any`, `or(_, Any) → Any`
|
||||||
|
- `or(None, b) → b`, `or(a, None) → a`
|
||||||
|
|
||||||
|
`not`:
|
||||||
|
- `not(=v) → !=v`, `not(!=v) → =v`
|
||||||
|
- `not(and(a, b)) → or(not(a), not(b))` (De Morgan)
|
||||||
|
- `not(or(a, b)) → and(not(a), not(b))` (De Morgan)
|
||||||
|
- `not(not(a)) → a`
|
||||||
|
- `not(Any) → None`, `not(None) → Any`
|
||||||
|
|
||||||
|
### TypeScript: `normalize()` (deep, canonical)
|
||||||
|
|
||||||
|
`VersionRange.normalize(): VersionRange` in `sdk/base/lib/exver/index.ts` performs full normalization by converting the range AST into a canonical form. This is a deep operation that produces a semantically equivalent but simplified range.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. **`tables()`** — Converts the VersionRange AST into truth tables (`VersionRangeTable`). Each table is a number line split at version boundary points, with boolean values for each segment indicating whether versions in that segment satisfy the range. Separate tables are maintained per flavor (and for flavor negations).
|
||||||
|
|
||||||
|
2. **`VersionRangeTable.zip(a, b, func)`** — Merges two tables by walking their boundary points in sorted order and applying a boolean function (`&&` or `||`) to combine segment values. Adjacent segments with the same boolean value are collapsed automatically.
|
||||||
|
|
||||||
|
3. **`VersionRangeTable.and/or/not`** — Table-level boolean operations. `and` computes the cross-product of flavor tables (since `#a && #b` for different flavors is unsatisfiable). `not` inverts all segment values.
|
||||||
|
|
||||||
|
4. **`VersionRangeTable.collapse()`** — Checks if a table is uniformly true or false across all flavors and segments. Returns `true`, `false`, or `null` (mixed).
|
||||||
|
|
||||||
|
5. **`VersionRangeTable.minterms()`** — Converts truth tables back into a VersionRange AST in [sum-of-products](https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms) canonical form. Each `true` segment becomes a product term (conjunction of boundary constraints), and all terms are joined with `or`. Adjacent boundary points collapse into `=` anchors.
|
||||||
|
|
||||||
|
**Example:** `normalize` can simplify:
|
||||||
|
- `>=1.0.0:0 && <=1.0.0:0` → `=1.0.0:0`
|
||||||
|
- `>=2.0.0:0 || >=1.0.0:0` → `>=1.0.0:0`
|
||||||
|
- `!(!>=1.0.0:0)` → `>=1.0.0:0`
|
||||||
|
|
||||||
|
**Also exposes:**
|
||||||
|
- `satisfiable(): boolean` — returns `true` if there exists any version satisfying the range (checks if `collapse(tables())` is not `false`)
|
||||||
|
- `intersects(other): boolean` — returns `true` if `and(this, other)` is satisfiable
|
||||||
|
|
||||||
|
## API Differences Between Rust and TypeScript
|
||||||
|
|
||||||
|
| | Rust | TypeScript |
|
||||||
|
|-|------|------------|
|
||||||
|
| **`^` / `~`** | Expanded at construction to `And(GTE, LT)` | First-class operator on `Anchor` |
|
||||||
|
| **`not()`** | Static, eagerly simplifies (De Morgan, double negation) | Instance method, just wraps |
|
||||||
|
| **`and()`/`or()`** | Binary static | Both binary instance and variadic static |
|
||||||
|
| **Normalization** | `reduce()` — shallow, one AST level | `normalize()` — deep canonical form via truth tables |
|
||||||
|
| **Satisfiability** | Not available | `satisfiable()` and `intersects(other)` |
|
||||||
|
| **ExtendedVersion helpers** | `with_flavor()`, `without_flavor()`, `map_upstream()`, `map_downstream()` | `incrementMajor()`, `incrementMinor()`, `greaterThan()`, `lessThan()`, `equals()`, etc. |
|
||||||
|
| **Monoid wrappers** | `AnyRange` (fold with `or`) and `AllRange` (fold with `and`) | Not present — use variadic static methods |
|
||||||
|
| **`VersionString`** | Wrapper caching parsed + string form | Not present |
|
||||||
|
| **Emver compat** | `From<emver::Version>` for `ExtendedVersion` | `ExtendedVersion.parseEmver()`, `VersionRange.parseEmver()` |
|
||||||
|
|
||||||
|
## Serde
|
||||||
|
|
||||||
|
All types serialize/deserialize as strings (requires `serde` feature, enabled in StartOS):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.2.3:0",
|
||||||
|
"targetVersion": ">=1.0.0:0 <2.0.0:0",
|
||||||
|
"sourceVersion": "^0.3.0:0"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -61,6 +61,24 @@ pub async fn unmount<P: AsRef<Path>>(mountpoint: P, lazy: bool) -> Result<(), Er
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if any mountpoints exist under (or at) the given path.
|
||||||
|
pub async fn has_mounts_under<P: AsRef<Path>>(path: P) -> Result<bool, Error> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let canonical_path = tokio::fs::canonicalize(path)
|
||||||
|
.await
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("canonicalize {path:?}")))?;
|
||||||
|
|
||||||
|
let mounts_content = tokio::fs::read_to_string("/proc/mounts")
|
||||||
|
.await
|
||||||
|
.with_ctx(|_| (ErrorKind::Filesystem, "read /proc/mounts"))?;
|
||||||
|
|
||||||
|
Ok(mounts_content.lines().any(|line| {
|
||||||
|
line.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.map_or(false, |mp| Path::new(mp).starts_with(&canonical_path))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Unmounts all mountpoints under (and including) the given path, in reverse
|
/// Unmounts all mountpoints under (and including) the given path, in reverse
|
||||||
/// depth order so that nested mounts are unmounted before their parents.
|
/// depth order so that nested mounts are unmounted before their parents.
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ pub async fn install(
|
|||||||
json!({
|
json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"targetVersion": VersionRange::exactly(version.deref().clone()),
|
"targetVersion": VersionRange::exactly(version.deref().clone()),
|
||||||
|
"otherVersions": "none",
|
||||||
}),
|
}),
|
||||||
RegistryUrlParams {
|
RegistryUrlParams {
|
||||||
registry: registry.clone(),
|
registry: registry.clone(),
|
||||||
@@ -484,7 +485,7 @@ pub async fn cli_install(
|
|||||||
let mut packages: GetPackageResponse = from_value(
|
let mut packages: GetPackageResponse = from_value(
|
||||||
ctx.call_remote::<RegistryContext>(
|
ctx.call_remote::<RegistryContext>(
|
||||||
"package.get",
|
"package.get",
|
||||||
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version }),
|
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version, "otherVersions": "none" }),
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::progress::{FullProgressTracker, ProgressUnits};
|
|||||||
use crate::registry::context::RegistryContext;
|
use crate::registry::context::RegistryContext;
|
||||||
use crate::registry::device_info::DeviceInfo;
|
use crate::registry::device_info::DeviceInfo;
|
||||||
use crate::registry::package::index::{PackageIndex, PackageVersionInfo};
|
use crate::registry::package::index::{PackageIndex, PackageVersionInfo};
|
||||||
|
use crate::s9pk::manifest::LocaleString;
|
||||||
use crate::s9pk::merkle_archive::source::ArchiveSource;
|
use crate::s9pk::merkle_archive::source::ArchiveSource;
|
||||||
use crate::s9pk::v2::SIG_CONTEXT;
|
use crate::s9pk::v2::SIG_CONTEXT;
|
||||||
use crate::util::VersionString;
|
use crate::util::VersionString;
|
||||||
@@ -38,11 +39,11 @@ impl Default for PackageDetailLevel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct PackageInfoShort {
|
pub struct PackageInfoShort {
|
||||||
pub release_notes: String,
|
pub release_notes: LocaleString,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, TS, Parser, HasModel)]
|
#[derive(Debug, Deserialize, Serialize, TS, Parser, HasModel)]
|
||||||
@@ -89,17 +90,20 @@ impl GetPackageResponse {
|
|||||||
|
|
||||||
let lesser_versions: BTreeMap<_, _> = self
|
let lesser_versions: BTreeMap<_, _> = self
|
||||||
.other_versions
|
.other_versions
|
||||||
.as_ref()
|
.clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter(|(v, _)| ***v < *version)
|
.filter(|(v, _)| **v < *version)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !lesser_versions.is_empty() {
|
if !lesser_versions.is_empty() {
|
||||||
table.add_row(row![bc => "OLDER VERSIONS"]);
|
table.add_row(row![bc => "OLDER VERSIONS"]);
|
||||||
table.add_row(row![bc => "VERSION", "RELEASE NOTES"]);
|
table.add_row(row![bc => "VERSION", "RELEASE NOTES"]);
|
||||||
for (version, info) in lesser_versions {
|
for (version, info) in lesser_versions {
|
||||||
table.add_row(row![AsRef::<str>::as_ref(version), &info.release_notes]);
|
table.add_row(row![
|
||||||
|
AsRef::<str>::as_ref(&version),
|
||||||
|
&info.release_notes.localized()
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +151,7 @@ fn get_matching_models(
|
|||||||
id,
|
id,
|
||||||
source_version,
|
source_version,
|
||||||
device_info,
|
device_info,
|
||||||
|
target_version,
|
||||||
..
|
..
|
||||||
}: &GetPackageParams,
|
}: &GetPackageParams,
|
||||||
) -> Result<Vec<(PackageId, ExtendedVersion, Model<PackageVersionInfo>)>, Error> {
|
) -> Result<Vec<(PackageId, ExtendedVersion, Model<PackageVersionInfo>)>, Error> {
|
||||||
@@ -165,26 +170,29 @@ fn get_matching_models(
|
|||||||
.as_entries()?
|
.as_entries()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(v, info)| {
|
.map(|(v, info)| {
|
||||||
|
let ev = ExtendedVersion::from(v);
|
||||||
Ok::<_, Error>(
|
Ok::<_, Error>(
|
||||||
if source_version.as_ref().map_or(Ok(true), |source_version| {
|
if target_version.as_ref().map_or(true, |tv| ev.satisfies(tv))
|
||||||
Ok::<_, Error>(
|
&& source_version.as_ref().map_or(Ok(true), |source_version| {
|
||||||
source_version.satisfies(
|
Ok::<_, Error>(
|
||||||
&info
|
source_version.satisfies(
|
||||||
.as_source_version()
|
&info
|
||||||
.de()?
|
.as_source_version()
|
||||||
.unwrap_or(VersionRange::any()),
|
.de()?
|
||||||
),
|
.unwrap_or(VersionRange::any()),
|
||||||
)
|
),
|
||||||
})? {
|
)
|
||||||
|
})?
|
||||||
|
{
|
||||||
let mut info = info.clone();
|
let mut info = info.clone();
|
||||||
if let Some(device_info) = &device_info {
|
if let Some(device_info) = &device_info {
|
||||||
if info.for_device(device_info)? {
|
if info.for_device(device_info)? {
|
||||||
Some((k.clone(), ExtendedVersion::from(v), info))
|
Some((k.clone(), ev, info))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Some((k.clone(), ExtendedVersion::from(v), info))
|
Some((k.clone(), ev, info))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -207,12 +215,7 @@ pub async fn get_package(ctx: RegistryContext, params: GetPackageParams) -> Resu
|
|||||||
for (id, version, info) in get_matching_models(&peek.as_index().as_package(), ¶ms)? {
|
for (id, version, info) in get_matching_models(&peek.as_index().as_package(), ¶ms)? {
|
||||||
let package_best = best.entry(id.clone()).or_default();
|
let package_best = best.entry(id.clone()).or_default();
|
||||||
let package_other = other.entry(id.clone()).or_default();
|
let package_other = other.entry(id.clone()).or_default();
|
||||||
if params
|
if package_best.keys().all(|k| !(**k > version)) {
|
||||||
.target_version
|
|
||||||
.as_ref()
|
|
||||||
.map_or(true, |v| version.satisfies(v))
|
|
||||||
&& package_best.keys().all(|k| !(**k > version))
|
|
||||||
{
|
|
||||||
for worse_version in package_best
|
for worse_version in package_best
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|k| ***k < version)
|
.filter(|k| ***k < version)
|
||||||
@@ -569,3 +572,42 @@ pub async fn cli_download(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_matching_info_short() {
|
||||||
|
use crate::registry::package::index::PackageMetadata;
|
||||||
|
use crate::s9pk::manifest::{Alerts, Description};
|
||||||
|
use crate::util::DataUrl;
|
||||||
|
|
||||||
|
let lang_map = |s: &str| {
|
||||||
|
LocaleString::LanguageMap([("en".into(), s.into())].into_iter().collect())
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = PackageVersionInfo {
|
||||||
|
metadata: PackageMetadata {
|
||||||
|
title: "Test Package".into(),
|
||||||
|
icon: DataUrl::from_vec("image/png", vec![]),
|
||||||
|
description: Description {
|
||||||
|
short: lang_map("A short description"),
|
||||||
|
long: lang_map("A longer description of the test package"),
|
||||||
|
},
|
||||||
|
release_notes: lang_map("Initial release"),
|
||||||
|
git_hash: None,
|
||||||
|
license: "MIT".into(),
|
||||||
|
wrapper_repo: "https://github.com/example/wrapper".parse().unwrap(),
|
||||||
|
upstream_repo: "https://github.com/example/upstream".parse().unwrap(),
|
||||||
|
support_site: "https://example.com/support".parse().unwrap(),
|
||||||
|
marketing_site: "https://example.com".parse().unwrap(),
|
||||||
|
donation_url: None,
|
||||||
|
docs_url: None,
|
||||||
|
alerts: Alerts::default(),
|
||||||
|
dependency_metadata: BTreeMap::new(),
|
||||||
|
os_version: exver::Version::new([0, 3, 6], []),
|
||||||
|
sdk_version: None,
|
||||||
|
hardware_acceleration: false,
|
||||||
|
},
|
||||||
|
source_version: None,
|
||||||
|
s9pks: Vec::new(),
|
||||||
|
};
|
||||||
|
from_value::<PackageInfoShort>(to_value(&info).unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use clap::builder::ValueParserFactory;
|
|||||||
use exver::VersionRange;
|
use exver::VersionRange;
|
||||||
use rust_i18n::t;
|
use rust_i18n::t;
|
||||||
|
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::db::model::package::{
|
use crate::db::model::package::{
|
||||||
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
|
CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference,
|
||||||
TaskEntry,
|
TaskEntry,
|
||||||
@@ -19,7 +21,7 @@ use crate::service::effects::callbacks::CallbackHandler;
|
|||||||
use crate::service::effects::prelude::*;
|
use crate::service::effects::prelude::*;
|
||||||
use crate::service::rpc::CallbackId;
|
use crate::service::rpc::CallbackId;
|
||||||
use crate::status::health_check::NamedHealthCheckResult;
|
use crate::status::health_check::NamedHealthCheckResult;
|
||||||
use crate::util::{FromStrParser, VersionString};
|
use crate::util::{FromStrParser, Invoke, VersionString};
|
||||||
use crate::volume::data_dir;
|
use crate::volume::data_dir;
|
||||||
use crate::{DATA_DIR, HealthCheckId, PackageId, ReplayId, VolumeId};
|
use crate::{DATA_DIR, HealthCheckId, PackageId, ReplayId, VolumeId};
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ pub async fn mount(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.mount(
|
.mount(
|
||||||
mountpoint,
|
&mountpoint,
|
||||||
if readonly {
|
if readonly {
|
||||||
MountType::ReadOnly
|
MountType::ReadOnly
|
||||||
} else {
|
} else {
|
||||||
@@ -99,6 +101,15 @@ pub async fn mount(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Make the dependency mount a slave so it receives propagated mounts
|
||||||
|
// (e.g. NAS mounts from the source service) but cannot propagate
|
||||||
|
// mounts back to the source service's volume.
|
||||||
|
Command::new("mount")
|
||||||
|
.arg("--make-rslave")
|
||||||
|
.arg(&mountpoint)
|
||||||
|
.invoke(ErrorKind::Filesystem)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use crate::disk::mount::filesystem::loop_dev::LoopDev;
|
|||||||
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
|
use crate::disk::mount::filesystem::overlayfs::OverlayGuard;
|
||||||
use crate::disk::mount::filesystem::{MountType, ReadOnly};
|
use crate::disk::mount::filesystem::{MountType, ReadOnly};
|
||||||
use crate::disk::mount::guard::{GenericMountGuard, MountGuard};
|
use crate::disk::mount::guard::{GenericMountGuard, MountGuard};
|
||||||
|
use crate::disk::mount::util::{is_mountpoint, unmount};
|
||||||
use crate::lxc::{HOST_RPC_SERVER_SOCKET, LxcConfig, LxcContainer};
|
use crate::lxc::{HOST_RPC_SERVER_SOCKET, LxcConfig, LxcContainer};
|
||||||
use crate::net::net_controller::NetService;
|
use crate::net::net_controller::NetService;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
@@ -76,6 +77,7 @@ pub struct PersistentContainer {
|
|||||||
pub(super) rpc_client: UnixRpcClient,
|
pub(super) rpc_client: UnixRpcClient,
|
||||||
pub(super) rpc_server: watch::Sender<Option<(NonDetachingJoinHandle<()>, ShutdownHandle)>>,
|
pub(super) rpc_server: watch::Sender<Option<(NonDetachingJoinHandle<()>, ShutdownHandle)>>,
|
||||||
js_mount: MountGuard,
|
js_mount: MountGuard,
|
||||||
|
host_volume_binds: BTreeMap<VolumeId, MountGuard>,
|
||||||
volumes: BTreeMap<VolumeId, MountGuard>,
|
volumes: BTreeMap<VolumeId, MountGuard>,
|
||||||
assets: Vec<MountGuard>,
|
assets: Vec<MountGuard>,
|
||||||
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
|
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
|
||||||
@@ -120,6 +122,7 @@ impl PersistentContainer {
|
|||||||
.is_ok();
|
.is_ok();
|
||||||
|
|
||||||
let mut volumes = BTreeMap::new();
|
let mut volumes = BTreeMap::new();
|
||||||
|
let mut host_volume_binds = BTreeMap::new();
|
||||||
|
|
||||||
// TODO: remove once packages are reconverted
|
// TODO: remove once packages are reconverted
|
||||||
let added = if is_compat {
|
let added = if is_compat {
|
||||||
@@ -128,13 +131,35 @@ impl PersistentContainer {
|
|||||||
BTreeSet::default()
|
BTreeSet::default()
|
||||||
};
|
};
|
||||||
for volume in s9pk.as_manifest().volumes.union(&added) {
|
for volume in s9pk.as_manifest().volumes.union(&added) {
|
||||||
|
let host_volume_dir = data_dir(DATA_DIR, &s9pk.as_manifest().id, volume);
|
||||||
|
|
||||||
|
// Self-bind the host volume directory and mark it rshared so that
|
||||||
|
// mounts created inside the container (e.g. NAS mounts from
|
||||||
|
// postinit.sh) propagate back to the host path and are visible to
|
||||||
|
// dependent services that bind-mount the same volume.
|
||||||
|
if is_mountpoint(&host_volume_dir).await? {
|
||||||
|
unmount(&host_volume_dir, true).await?;
|
||||||
|
}
|
||||||
|
let host_bind = MountGuard::mount(
|
||||||
|
&Bind::new(&host_volume_dir),
|
||||||
|
&host_volume_dir,
|
||||||
|
MountType::ReadWrite,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Command::new("mount")
|
||||||
|
.arg("--make-rshared")
|
||||||
|
.arg(&host_volume_dir)
|
||||||
|
.invoke(ErrorKind::Filesystem)
|
||||||
|
.await?;
|
||||||
|
host_volume_binds.insert(volume.clone(), host_bind);
|
||||||
|
|
||||||
let mountpoint = lxc_container
|
let mountpoint = lxc_container
|
||||||
.rootfs_dir()
|
.rootfs_dir()
|
||||||
.join("media/startos/volumes")
|
.join("media/startos/volumes")
|
||||||
.join(volume);
|
.join(volume);
|
||||||
let mount = MountGuard::mount(
|
let mount = MountGuard::mount(
|
||||||
&IdMapped::new(
|
&IdMapped::new(
|
||||||
Bind::new(data_dir(DATA_DIR, &s9pk.as_manifest().id, volume)),
|
Bind::new(&host_volume_dir),
|
||||||
vec![IdMap {
|
vec![IdMap {
|
||||||
from_id: 0,
|
from_id: 0,
|
||||||
to_id: 100000,
|
to_id: 100000,
|
||||||
@@ -296,6 +321,7 @@ impl PersistentContainer {
|
|||||||
rpc_server: watch::channel(None).0,
|
rpc_server: watch::channel(None).0,
|
||||||
// procedures: Default::default(),
|
// procedures: Default::default(),
|
||||||
js_mount,
|
js_mount,
|
||||||
|
host_volume_binds,
|
||||||
volumes,
|
volumes,
|
||||||
assets,
|
assets,
|
||||||
images,
|
images,
|
||||||
@@ -439,6 +465,7 @@ impl PersistentContainer {
|
|||||||
let rpc_server = self.rpc_server.send_replace(None);
|
let rpc_server = self.rpc_server.send_replace(None);
|
||||||
let js_mount = self.js_mount.take();
|
let js_mount = self.js_mount.take();
|
||||||
let volumes = std::mem::take(&mut self.volumes);
|
let volumes = std::mem::take(&mut self.volumes);
|
||||||
|
let host_volume_binds = std::mem::take(&mut self.host_volume_binds);
|
||||||
let assets = std::mem::take(&mut self.assets);
|
let assets = std::mem::take(&mut self.assets);
|
||||||
let images = std::mem::take(&mut self.images);
|
let images = std::mem::take(&mut self.images);
|
||||||
let subcontainers = self.subcontainers.clone();
|
let subcontainers = self.subcontainers.clone();
|
||||||
@@ -461,6 +488,11 @@ impl PersistentContainer {
|
|||||||
for (_, volume) in volumes {
|
for (_, volume) in volumes {
|
||||||
errs.handle(volume.unmount(true).await);
|
errs.handle(volume.unmount(true).await);
|
||||||
}
|
}
|
||||||
|
// Unmount host-side shared binds after the rootfs-side volume
|
||||||
|
// mounts. Use delete_mountpoint=false to preserve the data dirs.
|
||||||
|
for (_, host_bind) in host_volume_binds {
|
||||||
|
errs.handle(host_bind.unmount(false).await);
|
||||||
|
}
|
||||||
for assets in assets {
|
for assets in assets {
|
||||||
errs.handle(assets.unmount(true).await);
|
errs.handle(assets.unmount(true).await);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use imbl::vector;
|
|||||||
|
|
||||||
use crate::context::RpcContext;
|
use crate::context::RpcContext;
|
||||||
use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState};
|
use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState};
|
||||||
|
use crate::disk::mount::util::{has_mounts_under, unmount_all_under};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::volume::PKG_VOLUME_DIR;
|
use crate::volume::PKG_VOLUME_DIR;
|
||||||
use crate::{DATA_DIR, PACKAGE_DATA, PackageId};
|
use crate::{DATA_DIR, PACKAGE_DATA, PackageId};
|
||||||
@@ -81,6 +82,22 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(),
|
|||||||
if !soft {
|
if !soft {
|
||||||
let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id);
|
let path = Path::new(DATA_DIR).join(PKG_VOLUME_DIR).join(&manifest.id);
|
||||||
if tokio::fs::metadata(&path).await.is_ok() {
|
if tokio::fs::metadata(&path).await.is_ok() {
|
||||||
|
// Best-effort cleanup of any propagated mounts (e.g. NAS)
|
||||||
|
// that survived container destroy or were never cleaned up
|
||||||
|
// (force-uninstall skips destroy entirely).
|
||||||
|
unmount_all_under(&path, true).await.log_err();
|
||||||
|
// Hard check: refuse to delete if mounts are still active,
|
||||||
|
// to avoid traversing into a live NFS/NAS mount.
|
||||||
|
if has_mounts_under(&path).await? {
|
||||||
|
return Err(Error::new(
|
||||||
|
eyre!(
|
||||||
|
"Refusing to remove {}: active mounts remain under this path. \
|
||||||
|
Unmount them manually and retry.",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
ErrorKind::Filesystem,
|
||||||
|
));
|
||||||
|
}
|
||||||
tokio::fs::remove_dir_all(&path).await?;
|
tokio::fs::remove_dir_all(&path).await?;
|
||||||
}
|
}
|
||||||
let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id);
|
let logs_dir = Path::new(PACKAGE_DATA).join("logs").join(&manifest.id);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
{{ pkg.title }}
|
{{ pkg.title }}
|
||||||
</span>
|
</span>
|
||||||
<span class="detail-description">
|
<span class="detail-description">
|
||||||
{{ pkg.description.short }}
|
{{ pkg.description.short | localize }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
|
import { LocalizePipe, SharedPipesModule, TickerComponent } from '@start9labs/shared'
|
||||||
import { ItemComponent } from './item.component'
|
import { ItemComponent } from './item.component'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [ItemComponent],
|
declarations: [ItemComponent],
|
||||||
exports: [ItemComponent],
|
exports: [ItemComponent],
|
||||||
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent],
|
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent, LocalizePipe],
|
||||||
})
|
})
|
||||||
export class ItemModule {}
|
export class ItemModule {}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
output,
|
output,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { MarketplacePkgBase } from '../../types'
|
import { MarketplacePkgBase } from '../../types'
|
||||||
import { CopyService, i18nPipe } from '@start9labs/shared'
|
import { CopyService, i18nPipe, LocalizePipe } from '@start9labs/shared'
|
||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { MarketplaceItemComponent } from './item.component'
|
import { MarketplaceItemComponent } from './item.component'
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ import { MarketplaceItemComponent } from './item.component'
|
|||||||
<div class="background-border box-shadow-lg shadow-color-light">
|
<div class="background-border box-shadow-lg shadow-color-light">
|
||||||
<div class="box-container">
|
<div class="box-container">
|
||||||
<h2 class="additional-detail-title">{{ 'Description' | i18n }}</h2>
|
<h2 class="additional-detail-title">{{ 'Description' | i18n }}</h2>
|
||||||
<p [innerHTML]="pkg().description.long"></p>
|
<p [innerHTML]="pkg().description.long | localize"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -129,7 +129,7 @@ import { MarketplaceItemComponent } from './item.component'
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [MarketplaceItemComponent, DatePipe, i18nPipe],
|
imports: [MarketplaceItemComponent, DatePipe, i18nPipe, LocalizePipe],
|
||||||
})
|
})
|
||||||
export class MarketplaceAboutComponent {
|
export class MarketplaceAboutComponent {
|
||||||
readonly copyService = inject(CopyService)
|
readonly copyService = inject(CopyService)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Component } from '@angular/core'
|
||||||
|
import { i18nPipe } from '@start9labs/shared'
|
||||||
|
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
|
||||||
|
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
imports: [TuiButton, i18nPipe],
|
||||||
|
template: `
|
||||||
|
<div class="animation-container">
|
||||||
|
<div class="port">
|
||||||
|
<div class="port-inner"></div>
|
||||||
|
</div>
|
||||||
|
<div class="usb-stick">
|
||||||
|
<div class="usb-connector"></div>
|
||||||
|
<div class="usb-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
'Remove USB stick or other installation media from your server' | i18n
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<button tuiButton (click)="context.completeWith(true)">
|
||||||
|
{{ 'Done' | i18n }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-container {
|
||||||
|
position: relative;
|
||||||
|
width: 160px;
|
||||||
|
height: 69px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 28px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
border: 2px solid var(--tui-border-normal);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
right: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: var(--tui-background-neutral-2);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usb-stick {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
animation: slide-out 2s ease-in-out 0.5s infinite;
|
||||||
|
left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usb-connector {
|
||||||
|
width: 20px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--tui-text-secondary);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usb-body {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--tui-status-info);
|
||||||
|
border-radius: 2px 4px 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-out {
|
||||||
|
0% {
|
||||||
|
left: 32px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
5% {
|
||||||
|
left: 32px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
left: 130px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 130px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RemoveMediaDialog {
|
||||||
|
protected readonly context = injectContext<TuiDialogContext<boolean>>()
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { ChangeDetectorRef, Component, inject } from '@angular/core'
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
} from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -21,13 +26,14 @@ import {
|
|||||||
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
|
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
|
||||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
import { filter } from 'rxjs'
|
import { filter, Subscription } from 'rxjs'
|
||||||
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 { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
|
import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
|
@if (!shuttingDown) {
|
||||||
<section tuiCardLarge="compact">
|
<section tuiCardLarge="compact">
|
||||||
<header tuiHeader>
|
<header tuiHeader>
|
||||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||||
@@ -132,6 +138,7 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
|
|||||||
}
|
}
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
.no-drives {
|
.no-drives {
|
||||||
@@ -176,6 +183,14 @@ export default class DrivesPage {
|
|||||||
|
|
||||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||||
|
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.ctrlKey && event.shiftKey && event.key === 'X') {
|
||||||
|
event.preventDefault()
|
||||||
|
this.shutdownServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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.',
|
||||||
)
|
)
|
||||||
@@ -185,6 +200,8 @@ export default class DrivesPage {
|
|||||||
|
|
||||||
drives: DiskInfo[] = []
|
drives: DiskInfo[] = []
|
||||||
loading = true
|
loading = true
|
||||||
|
shuttingDown = false
|
||||||
|
private dialogSub?: Subscription
|
||||||
selectedOsDrive: DiskInfo | null = null
|
selectedOsDrive: DiskInfo | null = null
|
||||||
selectedDataDrive: DiskInfo | null = null
|
selectedDataDrive: DiskInfo | null = null
|
||||||
preserveData: boolean | null = null
|
preserveData: boolean | null = null
|
||||||
@@ -339,22 +356,18 @@ export default class DrivesPage {
|
|||||||
loader.unsubscribe()
|
loader.unsubscribe()
|
||||||
|
|
||||||
// Show success dialog
|
// Show success dialog
|
||||||
this.dialogs
|
this.dialogSub = this.dialogs
|
||||||
.openConfirm({
|
.openAlert('StartOS has been installed successfully.', {
|
||||||
label: 'Installation Complete!',
|
label: 'Installation Complete!',
|
||||||
size: 's',
|
size: 's',
|
||||||
data: {
|
dismissible: false,
|
||||||
content: 'StartOS has been installed successfully.',
|
closeable: true,
|
||||||
yes: 'Continue to Setup',
|
data: { button: this.i18n.transform('Continue to Setup') },
|
||||||
no: 'Shutdown',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.subscribe(continueSetup => {
|
.subscribe({
|
||||||
if (continueSetup) {
|
complete: () => {
|
||||||
this.navigateToNextStep(result.attach)
|
this.navigateToNextStep(result.attach)
|
||||||
} else {
|
},
|
||||||
this.shutdownServer()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
loader.unsubscribe()
|
loader.unsubscribe()
|
||||||
@@ -372,10 +385,12 @@ export default class DrivesPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async shutdownServer() {
|
private async shutdownServer() {
|
||||||
|
this.dialogSub?.unsubscribe()
|
||||||
const loader = this.loader.open('Beginning shutdown').subscribe()
|
const loader = this.loader.open('Beginning shutdown').subscribe()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api.shutdown()
|
await this.api.shutdown()
|
||||||
|
this.shuttingDown = true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.errorService.handleError(e)
|
this.errorService.handleError(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared'
|
import {
|
||||||
|
DialogService,
|
||||||
|
DownloadHTMLService,
|
||||||
|
ErrorService,
|
||||||
|
i18nPipe,
|
||||||
|
} from '@start9labs/shared'
|
||||||
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||||
import { TuiAvatar } from '@taiga-ui/kit'
|
import { TuiAvatar } from '@taiga-ui/kit'
|
||||||
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||||
@@ -14,7 +19,9 @@ import { ApiService } from '../services/api.service'
|
|||||||
import { StateService } from '../services/state.service'
|
import { StateService } from '../services/state.service'
|
||||||
import { DocumentationComponent } from '../components/documentation.component'
|
import { DocumentationComponent } from '../components/documentation.component'
|
||||||
import { MatrixComponent } from '../components/matrix.component'
|
import { MatrixComponent } from '../components/matrix.component'
|
||||||
|
import { RemoveMediaDialog } from '../components/remove-media.dialog'
|
||||||
import { SetupCompleteRes } from '../types'
|
import { SetupCompleteRes } from '../types'
|
||||||
|
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
@@ -29,12 +36,8 @@ import { SetupCompleteRes } from '../types'
|
|||||||
@if (!stateService.kiosk) {
|
@if (!stateService.kiosk) {
|
||||||
<span tuiSubtitle>
|
<span tuiSubtitle>
|
||||||
{{
|
{{
|
||||||
stateService.setupType === 'restore'
|
'http://start.local was for setup only. It will no longer work.'
|
||||||
? ('You can unplug your backup drive' | i18n)
|
| i18n
|
||||||
: stateService.setupType === 'transfer'
|
|
||||||
? ('You can unplug your transfer drive' | i18n)
|
|
||||||
: ('http://start.local was for setup only. It will no longer work.'
|
|
||||||
| i18n)
|
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -69,14 +72,15 @@ import { SetupCompleteRes } from '../types'
|
|||||||
tuiCell="l"
|
tuiCell="l"
|
||||||
[class.disabled]="!stateService.kiosk && !downloaded"
|
[class.disabled]="!stateService.kiosk && !downloaded"
|
||||||
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
|
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
|
||||||
(click)="usbRemoved = true"
|
(click)="removeMedia()"
|
||||||
>
|
>
|
||||||
<tui-avatar appearance="secondary" src="@tui.usb" />
|
<tui-avatar appearance="secondary" src="@tui.usb" />
|
||||||
<div tuiTitle>
|
<div tuiTitle>
|
||||||
{{ 'USB Removed' | i18n }}
|
{{ 'Remove Installation Media' | i18n }}
|
||||||
<div tuiSubtitle>
|
<div tuiSubtitle>
|
||||||
{{
|
{{
|
||||||
'Remove the USB installation media from your server' | i18n
|
'Remove USB stick or other installation media from your server'
|
||||||
|
| i18n
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +188,7 @@ export default class SuccessPage implements AfterViewInit {
|
|||||||
private readonly errorService = inject(ErrorService)
|
private readonly errorService = inject(ErrorService)
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly downloadHtml = inject(DownloadHTMLService)
|
private readonly downloadHtml = inject(DownloadHTMLService)
|
||||||
|
private readonly dialogs = inject(DialogService)
|
||||||
private readonly i18n = inject(i18nPipe)
|
private readonly i18n = inject(i18nPipe)
|
||||||
|
|
||||||
readonly stateService = inject(StateService)
|
readonly stateService = inject(StateService)
|
||||||
@@ -225,6 +230,21 @@ export default class SuccessPage implements AfterViewInit {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeMedia() {
|
||||||
|
this.dialogs
|
||||||
|
.openComponent<boolean>(
|
||||||
|
new PolymorpheusComponent(RemoveMediaDialog),
|
||||||
|
{
|
||||||
|
size: 's',
|
||||||
|
dismissible: false,
|
||||||
|
closeable: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.usbRemoved = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
exitKiosk() {
|
exitKiosk() {
|
||||||
this.api.exit()
|
this.api.exit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default {
|
|||||||
102: 'Verlassen',
|
102: 'Verlassen',
|
||||||
103: 'Sind Sie sicher?',
|
103: 'Sind Sie sicher?',
|
||||||
104: 'Neues Netzwerk-Gateway',
|
104: 'Neues Netzwerk-Gateway',
|
||||||
|
107: 'Onion-Domains',
|
||||||
108: 'Öffentlich',
|
108: 'Öffentlich',
|
||||||
109: 'privat',
|
109: 'privat',
|
||||||
111: 'Keine Onion-Domains',
|
111: 'Keine Onion-Domains',
|
||||||
@@ -639,13 +640,11 @@ export default {
|
|||||||
667: 'Einrichtung wird gestartet',
|
667: 'Einrichtung wird gestartet',
|
||||||
670: 'Warten Sie 1–2 Minuten und aktualisieren Sie die Seite',
|
670: 'Warten Sie 1–2 Minuten und aktualisieren Sie die Seite',
|
||||||
672: 'Einrichtung abgeschlossen!',
|
672: 'Einrichtung abgeschlossen!',
|
||||||
673: 'Sie können Ihr Backup-Laufwerk entfernen',
|
|
||||||
674: 'Sie können Ihr Übertragungs-Laufwerk entfernen',
|
|
||||||
675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.',
|
675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.',
|
||||||
676: 'Adressinformationen herunterladen',
|
676: 'Adressinformationen herunterladen',
|
||||||
677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA',
|
677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA',
|
||||||
678: 'USB entfernt',
|
678: 'Installationsmedium entfernen',
|
||||||
679: 'Entfernen Sie das USB-Installationsmedium aus Ihrem Server',
|
679: 'Entfernen Sie den USB-Stick oder andere Installationsmedien von Ihrem Server',
|
||||||
680: 'Server neu starten',
|
680: 'Server neu starten',
|
||||||
681: 'Warten, bis der Server wieder online ist',
|
681: 'Warten, bis der Server wieder online ist',
|
||||||
682: 'Server ist wieder online',
|
682: 'Server ist wieder online',
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const ENGLISH: Record<string, number> = {
|
|||||||
'Leave': 102,
|
'Leave': 102,
|
||||||
'Are you sure?': 103,
|
'Are you sure?': 103,
|
||||||
'New gateway': 104, // as in, a network gateway
|
'New gateway': 104, // as in, a network gateway
|
||||||
|
'Tor Domains': 107,
|
||||||
'public': 108,
|
'public': 108,
|
||||||
'private': 109,
|
'private': 109,
|
||||||
'No Tor domains': 111,
|
'No Tor domains': 111,
|
||||||
@@ -639,13 +640,11 @@ export const ENGLISH: Record<string, number> = {
|
|||||||
'Starting setup': 667,
|
'Starting setup': 667,
|
||||||
'Wait 1-2 minutes and refresh the page': 670,
|
'Wait 1-2 minutes and refresh the page': 670,
|
||||||
'Setup Complete!': 672,
|
'Setup Complete!': 672,
|
||||||
'You can unplug your backup drive': 673,
|
|
||||||
'You can unplug your transfer drive': 674,
|
|
||||||
'http://start.local was for setup only. It will no longer work.': 675,
|
'http://start.local was for setup only. It will no longer work.': 675,
|
||||||
'Download Address Info': 676,
|
'Download Address Info': 676,
|
||||||
"Contains your server's permanent local address and Root CA": 677,
|
"Contains your server's permanent local address and Root CA": 677,
|
||||||
'USB Removed': 678,
|
'Remove Installation Media': 678,
|
||||||
'Remove the USB installation media from your server': 679,
|
'Remove USB stick or other installation media from your server': 679,
|
||||||
'Restart Server': 680,
|
'Restart Server': 680,
|
||||||
'Waiting for server to come back online': 681,
|
'Waiting for server to come back online': 681,
|
||||||
'Server is back online': 682,
|
'Server is back online': 682,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default {
|
|||||||
102: 'Salir',
|
102: 'Salir',
|
||||||
103: '¿Estás seguro?',
|
103: '¿Estás seguro?',
|
||||||
104: 'Nueva puerta de enlace de red',
|
104: 'Nueva puerta de enlace de red',
|
||||||
|
107: 'dominios onion',
|
||||||
108: 'público',
|
108: 'público',
|
||||||
109: 'privado',
|
109: 'privado',
|
||||||
111: 'Sin dominios onion',
|
111: 'Sin dominios onion',
|
||||||
@@ -639,13 +640,11 @@ export default {
|
|||||||
667: 'Iniciando configuración',
|
667: 'Iniciando configuración',
|
||||||
670: 'Espere 1–2 minutos y actualice la página',
|
670: 'Espere 1–2 minutos y actualice la página',
|
||||||
672: '¡Configuración completa!',
|
672: '¡Configuración completa!',
|
||||||
673: 'Puede desconectar su unidad de copia de seguridad',
|
|
||||||
674: 'Puede desconectar su unidad de transferencia',
|
|
||||||
675: 'http://start.local era solo para la configuración. Ya no funcionará.',
|
675: 'http://start.local era solo para la configuración. Ya no funcionará.',
|
||||||
676: 'Descargar información de direcciones',
|
676: 'Descargar información de direcciones',
|
||||||
677: 'Contiene la dirección local permanente de su servidor y la CA raíz',
|
677: 'Contiene la dirección local permanente de su servidor y la CA raíz',
|
||||||
678: 'USB retirado',
|
678: 'Retirar medio de instalación',
|
||||||
679: 'Retire el medio de instalación USB de su servidor',
|
679: 'Retire la memoria USB u otro medio de instalación de su servidor',
|
||||||
680: 'Reiniciar servidor',
|
680: 'Reiniciar servidor',
|
||||||
681: 'Esperando a que el servidor vuelva a estar en línea',
|
681: 'Esperando a que el servidor vuelva a estar en línea',
|
||||||
682: 'El servidor ha vuelto a estar en línea',
|
682: 'El servidor ha vuelto a estar en línea',
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default {
|
|||||||
102: 'Quitter',
|
102: 'Quitter',
|
||||||
103: 'Êtes-vous sûr ?',
|
103: 'Êtes-vous sûr ?',
|
||||||
104: 'Nouvelle passerelle réseau',
|
104: 'Nouvelle passerelle réseau',
|
||||||
|
107: 'domaine onion',
|
||||||
108: 'public',
|
108: 'public',
|
||||||
109: 'privé',
|
109: 'privé',
|
||||||
111: 'Aucune domaine onion',
|
111: 'Aucune domaine onion',
|
||||||
@@ -639,13 +640,11 @@ export default {
|
|||||||
667: 'Démarrage de la configuration',
|
667: 'Démarrage de la configuration',
|
||||||
670: 'Attendez 1 à 2 minutes puis actualisez la page',
|
670: 'Attendez 1 à 2 minutes puis actualisez la page',
|
||||||
672: 'Configuration terminée !',
|
672: 'Configuration terminée !',
|
||||||
673: 'Vous pouvez débrancher votre disque de sauvegarde',
|
|
||||||
674: 'Vous pouvez débrancher votre disque de transfert',
|
|
||||||
675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.',
|
675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.',
|
||||||
676: 'Télécharger les informations d’adresse',
|
676: 'Télécharger les informations d’adresse',
|
||||||
677: 'Contient l’adresse locale permanente de votre serveur et la CA racine',
|
677: 'Contient l\u2019adresse locale permanente de votre serveur et la CA racine',
|
||||||
678: 'USB retiré',
|
678: 'Retirer le support d\u2019installation',
|
||||||
679: 'Retirez le support d’installation USB de votre serveur',
|
679: 'Retirez la clé USB ou tout autre support d\u2019installation de votre serveur',
|
||||||
680: 'Redémarrer le serveur',
|
680: 'Redémarrer le serveur',
|
||||||
681: 'En attente du retour en ligne du serveur',
|
681: 'En attente du retour en ligne du serveur',
|
||||||
682: 'Le serveur est de nouveau en ligne',
|
682: 'Le serveur est de nouveau en ligne',
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default {
|
|||||||
102: 'Opuść',
|
102: 'Opuść',
|
||||||
103: 'Czy jesteś pewien?',
|
103: 'Czy jesteś pewien?',
|
||||||
104: 'Nowa brama sieciowa',
|
104: 'Nowa brama sieciowa',
|
||||||
|
107: 'domeny onion',
|
||||||
108: 'publiczny',
|
108: 'publiczny',
|
||||||
109: 'prywatny',
|
109: 'prywatny',
|
||||||
111: 'Brak domeny onion',
|
111: 'Brak domeny onion',
|
||||||
@@ -639,13 +640,11 @@ export default {
|
|||||||
667: 'Rozpoczynanie konfiguracji',
|
667: 'Rozpoczynanie konfiguracji',
|
||||||
670: 'Poczekaj 1–2 minuty i odśwież stronę',
|
670: 'Poczekaj 1–2 minuty i odśwież stronę',
|
||||||
672: 'Konfiguracja zakończona!',
|
672: 'Konfiguracja zakończona!',
|
||||||
673: 'Możesz odłączyć dysk kopii zapasowej',
|
|
||||||
674: 'Możesz odłączyć dysk transferowy',
|
|
||||||
675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.',
|
675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.',
|
||||||
676: 'Pobierz informacje adresowe',
|
676: 'Pobierz informacje adresowe',
|
||||||
677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)',
|
677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)',
|
||||||
678: 'USB usunięty',
|
678: 'Usuń nośnik instalacyjny',
|
||||||
679: 'Usuń instalacyjny nośnik USB z serwera',
|
679: 'Usuń pamięć USB lub inny nośnik instalacyjny z serwera',
|
||||||
680: 'Uruchom ponownie serwer',
|
680: 'Uruchom ponownie serwer',
|
||||||
681: 'Oczekiwanie na ponowne połączenie serwera',
|
681: 'Oczekiwanie na ponowne połączenie serwera',
|
||||||
682: 'Serwer jest ponownie online',
|
682: 'Serwer jest ponownie online',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'
|
import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'
|
||||||
import { i18nService } from './i18n.service'
|
import { i18nService } from './i18n.service'
|
||||||
|
import { I18N } from './i18n.providers'
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
@@ -9,8 +10,10 @@ import { T } from '@start9labs/start-sdk'
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LocalizePipe implements PipeTransform {
|
export class LocalizePipe implements PipeTransform {
|
||||||
private readonly i18nService = inject(i18nService)
|
private readonly i18nService = inject(i18nService)
|
||||||
|
private readonly i18n = inject(I18N)
|
||||||
|
|
||||||
transform(string: T.LocaleString): string {
|
transform(string: T.LocaleString): string {
|
||||||
|
this.i18n() // read signal to trigger change detection on language switch
|
||||||
return this.i18nService.localize(string)
|
return this.i18nService.localize(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type OnionForm = {
|
|||||||
selector: 'section[torDomains]',
|
selector: 'section[torDomains]',
|
||||||
template: `
|
template: `
|
||||||
<header>
|
<header>
|
||||||
Tor Domains
|
{{ 'Tor Domains' | i18n }}
|
||||||
<a
|
<a
|
||||||
tuiIconButton
|
tuiIconButton
|
||||||
docsLink
|
docsLink
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ErrorService,
|
ErrorService,
|
||||||
i18nKey,
|
i18nKey,
|
||||||
i18nPipe,
|
i18nPipe,
|
||||||
|
i18nService,
|
||||||
LoadingService,
|
LoadingService,
|
||||||
} from '@start9labs/shared'
|
} from '@start9labs/shared'
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
@@ -24,6 +25,7 @@ export class ControlsService {
|
|||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
private readonly i18n = inject(i18nPipe)
|
private readonly i18n = inject(i18nPipe)
|
||||||
|
private readonly i18nService = inject(i18nService)
|
||||||
|
|
||||||
async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
|
async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
|
||||||
const deps =
|
const deps =
|
||||||
@@ -31,7 +33,7 @@ export class ControlsService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
(unmet && !(await this.alert(deps))) ||
|
(unmet && !(await this.alert(deps))) ||
|
||||||
(alerts.start && !(await this.alert(alerts.start as i18nKey)))
|
(alerts.start && !(await this.alert(alerts.start)))
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,7 @@ export class ControlsService {
|
|||||||
|
|
||||||
async stop({ id, title, alerts }: T.Manifest) {
|
async stop({ id, title, alerts }: T.Manifest) {
|
||||||
const depMessage = `${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
|
const depMessage = `${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
|
||||||
let content = alerts.stop || ''
|
let content = alerts.stop ? this.i18nService.localize(alerts.stop) : ''
|
||||||
|
|
||||||
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
|
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
|
||||||
content = content ? `${content}.\n\n${depMessage}` : depMessage
|
content = content ? `${content}.\n\n${depMessage}` : depMessage
|
||||||
@@ -113,14 +115,14 @@ export class ControlsService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private alert(content: i18nKey): Promise<boolean> {
|
private alert(content: T.LocaleString): Promise<boolean> {
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
this.dialog
|
this.dialog
|
||||||
.openConfirm({
|
.openConfirm({
|
||||||
label: 'Warning',
|
label: 'Warning',
|
||||||
size: 's',
|
size: 's',
|
||||||
data: {
|
data: {
|
||||||
content,
|
content: this.i18nService.localize(content),
|
||||||
yes: 'Continue',
|
yes: 'Continue',
|
||||||
no: 'Cancel',
|
no: 'Cancel',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function getInstalledBaseStatus(statusInfo: T.StatusInfo): BaseStatus {
|
|||||||
(!statusInfo.started ||
|
(!statusInfo.started ||
|
||||||
Object.values(statusInfo.health)
|
Object.values(statusInfo.health)
|
||||||
.filter(h => !!h)
|
.filter(h => !!h)
|
||||||
.some(h => h.result === 'starting'))
|
.some(h => h.result === 'starting' || h.result === 'waiting'))
|
||||||
) {
|
) {
|
||||||
return 'starting'
|
return 'starting'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ErrorService,
|
ErrorService,
|
||||||
i18nKey,
|
i18nKey,
|
||||||
i18nPipe,
|
i18nPipe,
|
||||||
|
i18nService,
|
||||||
LoadingService,
|
LoadingService,
|
||||||
} from '@start9labs/shared'
|
} from '@start9labs/shared'
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
@@ -27,6 +28,7 @@ export class StandardActionsService {
|
|||||||
private readonly loader = inject(LoadingService)
|
private readonly loader = inject(LoadingService)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
private readonly i18n = inject(i18nPipe)
|
private readonly i18n = inject(i18nPipe)
|
||||||
|
private readonly i18nService = inject(i18nService)
|
||||||
|
|
||||||
async rebuild(id: string) {
|
async rebuild(id: string) {
|
||||||
const loader = this.loader.open('Rebuilding container').subscribe()
|
const loader = this.loader.open('Rebuilding container').subscribe()
|
||||||
@@ -50,11 +52,12 @@ export class StandardActionsService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let content = soft
|
let content = soft
|
||||||
? ''
|
? ''
|
||||||
: alerts.uninstall ||
|
: alerts.uninstall
|
||||||
`${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
|
? this.i18nService.localize(alerts.uninstall)
|
||||||
|
: `${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
|
||||||
|
|
||||||
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
|
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
|
||||||
content = `${content}${content ? ' ' : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
|
content = `${content ? `${content} ` : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
|
|||||||
Reference in New Issue
Block a user