diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d002a..f82951b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,9 @@ jobs: steps: - name: Install build dependencies run: | - dnf install -y git gcc clang gtk4-devel libadwaita-devel pkgconf-pkg-config \ - rust cargo clippy rustfmt + dnf install -y git gcc clang gtk4-devel libadwaita-devel libgweather-devel \ + gnome-desktop4-devel fontconfig-devel lcms2-devel libseccomp-devel \ + gettext pkgconf-pkg-config rust cargo clippy rustfmt - name: Checkout uses: actions/checkout@v4 @@ -48,8 +49,9 @@ jobs: steps: - name: Install build dependencies run: | - dnf install -y git gcc clang gtk4-devel libadwaita-devel pkgconf-pkg-config \ - rust cargo rpm-build + dnf install -y git gcc clang gtk4-devel libadwaita-devel libgweather-devel \ + gnome-desktop4-devel fontconfig-devel lcms2-devel libseccomp-devel \ + gettext pkgconf-pkg-config rust cargo rpm-build - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 810dab6..eb8b150 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /data/locale/ -/graphify-out \ No newline at end of file +/graphify-out/ +/.vagrant/ diff --git a/AGENTS.md b/AGENTS.md index 7e503c6..3d9deb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,27 +7,32 @@ distribution is named is `README.md`, as the origin story. ## Project layout +- `crates/sirius-core` — shared domain and protocol types: `InstallConfig`, + partition plans, `DistroDescriptor`, `InstallRequest`, and `Progress`. It has + no GTK, process spawning, hardware discovery, or disk I/O. - `crates/sirius-diag` — pure library: hardware probes, the `Check`/`Status` model, install gating (`run_all_checks`, `is_blocked`), and the page-toggle config (`SiriusConfig`, `PagesConfig::resolve`). No GTK, fully unit-tested. -- `crates/sirius-installer` — the GTK wizard binary `sirius`. Subcommands: `diag`, - `--dry-run`, and the hidden `run-playbook` (the privileged install entry point). -- `crates/sirius-installer/src/backend/` — the ONLY module that touches `libreadymade`, - NetworkManager, or UDisks2: `distro` (descriptor), `adapter` - (`InstallConfig` → `InstallRequest` → `Playbook`), `runner` (root-side execute), - `spawn` (pkexec + progress parse), `network` (NetworkManager client), `storage` - (lsblk discovery + UDisks2 mutations). Everything else depends on the - `backend::Progress` boundary type, not on libreadymade directly. +- `crates/sirius-backend` — the ONLY crate that touches `libreadymade`, + NetworkManager, UDisks2, pkexec, or the privileged runner. Its storage facade + keeps read-only discovery through the Rust `lsblk` crate, sysfs, and udev + separate from confirmed mutations; `sirius-diag` has its own read-only + hardware probes. +- `crates/sirius-app` — the Relm4/GTK4 frontend: window, pages, navigation, + wizard state, bootstrap, and the background install task. +- `crates/sirius-installer` — thin launcher producing the single `sirius` + executable. It dispatches `diag`, `--dry-run`, the GTK app, and the hidden + `run-playbook` entry points. - `po/` — gettext catalogs at the repo root: `LINGUAS` (enabled languages), `POTFILES` (translatable sources), `pt_BR.po`, `sirius.pot`. ## Toolchain -- Rust 2021. relm4 **0.10**, gtk4 **0.10**, libadwaita (`adw`) **0.8**, with relm4 +- Rust 2024. relm4 **0.10**, gtk4 **0.10**, libadwaita (`adw`) **0.8**, with relm4 features `["libadwaita","gnome_45"]`. Needs `libadwaita-devel` / `gtk4-devel`. -- `libreadymade` is a pinned git dependency of the luminusOS fork (`rev` in the - workspace `Cargo.toml`, `default-features = false` to drop the `uutils`/`libacl` - feature; the native `rdm` copy backend is used). +- `libreadymade` comes from the sibling `readymade` workspace through a path + dependency (`default-features = false` to drop the `uutils`/`libacl` feature; + the native `rdm` copy backend is used). - `msgfmt` (gettext) is a required build tool: `crates/sirius-installer/build.rs` compiles the `po/` catalogs with it. @@ -41,11 +46,18 @@ cargo run --bin sirius -- diag cargo run --bin sirius -- --dry-run # Full VM install test (root, scratch disk, live env): sudo -E SIRIUS_TEST_DISK=/dev/vdb cargo test --test vm_install -- --ignored vm_full_install +# Ready-made manual test VM (Silverblue runner + Luminus bootc target): +vagrant up --provider=libvirt +vagrant provision +vagrant ssh ``` ## Conventions - **Commits: never add a `Co-Authored-By` / co-author trailer.** +- **VM distro details stay in the Vagrant VM.** `Vagrantfile` generates a + Fedora-specific descriptor and installs it only in the disposable guest; + never copy those defaults into Rust code or `data/`. - **Imperative pages use a manual `SimpleComponent` impl.** Pages that build their widget tree programmatically (`diagnostics`, `network`, `storage`, `summary`) implement `SimpleComponent` by hand — `#[name=...]` inside a `set_child` block diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da78c79..b357d9f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,8 @@ # Sirius Architecture -Sirius is a distro-agnostic diagnostic operating-system installer. It is split into a pure hardware-check and configuration library crate and a Relm4/GTK4/Libadwaita wizard crate, on top of the `libreadymade` install backend. +Sirius is a distro-agnostic diagnostic operating-system installer. Its domain, +diagnostics, operating-system integrations, frontend, and executable launcher +are separate crates with one-way dependencies. ## Overview @@ -11,19 +13,22 @@ flowchart TD Relm --> Pages[PageControllers] Relm --> State[WizardState and Navigator] Pages --> State - State --> CfgModel[InstallConfig] + State --> Core[sirius-core models and protocol] Relm --> Diag[sirius-diag] - CfgModel --> Adapter[backend::adapter] - Adapter --> Spawn[backend::spawn] + Core --> Install[sirius-backend install adapter] + Install --> Spawn[sirius-backend spawn] Spawn --> Pkexec[pkexec sirius run-playbook] - Pkexec --> Runner[backend::runner as root] + Pkexec --> Runner[sirius-backend runner as root] Runner --> Ready[libreadymade Playbook] Ready --> Disk[systemd-repart + bootc] Runner --> Spawn Spawn --> Relm ``` -`sirius-installer` owns the window, the wizard pages, the collected `InstallConfig`, and the privilege split. `sirius-diag` owns hardware probes, the `Check`/`Status` model, install gating, and the page-toggle configuration. Disk writes happen only in the privileged `run-playbook` process. +`sirius-app` owns the window and wizard interaction, `sirius-core` owns shared +models and wire types, `sirius-backend` owns side effects and the privilege +split, and `sirius-installer` is only the executable dispatcher. Disk writes +happen only in the privileged `run-playbook` process. ## Crate Boundaries @@ -36,15 +41,29 @@ flowchart LR Cfg[SiriusConfig, PagesConfig] end - subgraph Inst["sirius-installer"] + subgraph App["sirius-app"] AppM[AppModel] PagesC[PageControllers] Wiz[WizardState, Navigator] - CfgM[config_model: InstallConfig, PartitionPlan] - Backend[backend boundary] end - subgraph Ready["libreadymade (pinned git dependency)"] + subgraph Core["sirius-core"] + CfgM[InstallConfig and PartitionPlan] + Protocol[InstallRequest and Progress] + Distro[DistroDescriptor] + end + + subgraph Backend["sirius-backend"] + Install[request and playbook adapter] + Spawn[spawn and runner] + Integrations[storage and network] + end + + subgraph Launcher["sirius-installer"] + Main[CLI and dispatch] + end + + subgraph Ready["libreadymade (in-tree path dependency)"] Playbook[Playbook] Prog[PlaybookProgress] end @@ -54,28 +73,41 @@ flowchart LR AppM --> Diag PagesC --> CfgM Wiz --> CfgM - CfgM --> Backend - Backend --> Playbook + AppM --> Protocol + AppM --> Install + Install --> Protocol + Install --> Distro + Install --> Playbook Playbook --> Prog - Prog --> Backend + Prog --> Spawn + Main --> AppM + Main --> Spawn ``` `sirius-diag` must not import GTK, Libadwaita, or Relm4. Probes take plain values (a path, a byte count, an `Option`) so they stay unit-testable without hardware; `SystemFacts::gather()` is the only place that touches the live machine. The same library backs the `diag` CLI subcommand and the in-wizard diagnostics page. -## The backend/ Boundary +## Backend boundary -`crates/sirius-installer/src/backend/` is the only module that touches `libreadymade`, NetworkManager, or UDisks2. Everything else in the UI depends on the `backend::Progress` boundary type, never on upstream types. +`crates/sirius-backend` is the only crate that touches `libreadymade`, +NetworkManager, UDisks2, or pkexec. It owns the installer's block-device +topology scan; `sirius-diag` separately performs read-only hardware probes. The UI consumes the +`sirius-core::Progress` protocol and backend facade, never upstream types. -- `distro.rs` — the `DistroDescriptor` (`[bootc]` image deployment settings, `[disk]` repart directory, optional `[[bento]]` link cards and `[branding]`), loaded from `/etc/sirius/distro.toml` with an in-tree `data/distro.toml` fallback for dev runs. -- `adapter.rs` — converts the UI's `InstallConfig` into a serializable `InstallRequest`, and the request into a libreadymade `Playbook` on the privileged side. +- `distro.rs` — loads the core `DistroDescriptor` from `/etc/sirius/distro.toml`, with the in-tree development fallback. +- `install.rs` — converts `InstallConfig` into `InstallRequest`, and the request into a libreadymade `Playbook` on the privileged side. - `runner.rs` — the privileged half, invoked as `sirius run-playbook`. Reads the request JSON from stdin, executes the playbook, and writes newline-delimited `Progress` JSON to stdout. - `spawn.rs` — the unprivileged half. Spawns `pkexec sirius run-playbook` (skipped when already root, e.g. a live session), pipes the request to its stdin, and parses progress lines from its stdout. -- `storage.rs` — read-only disk discovery via `lsblk --json` for the UI, plus the UDisks2 executor that applies a confirmed `PartitionPlan` inside the root runner. +- `storage/scan.rs` — read-only disk discovery via the Rust `lsblk` crate, + sysfs, udev properties, and `/proc/mounts`; it executes no system utility. +- `storage/apply.rs` — UDisks2 executor for a confirmed `PartitionPlan`, called only by the root runner. - `network.rs` — a small NetworkManager client (scan, connect, Wi-Fi device detection) used by the optional Wi-Fi page. The request crossing the pkexec boundary carries only the user's choices: target disk, encryption flags, locale/keyboard/timezone, account fields, and an optional `PartitionPlan`. What gets installed — the bootc image and the repart layout — is loaded by the root runner itself from the root-owned `/etc/sirius/distro.toml`, so the unprivileged UI cannot point the root process at an arbitrary image. The runner also re-validates the target: an existing, unmounted whole-disk block device under `/dev`. -`libreadymade` is a pinned git dependency of the luminusOS fork (`rev` in the workspace `Cargo.toml`), built with `default-features = false` to drop the `uutils` copy backend (which would require `libacl-devel`); the native `rdm` copy implementation is used instead. +`libreadymade` comes from the sibling `readymade` workspace through a path +dependency. It is built with `default-features = false` to drop the `uutils` +copy backend (which would require `libacl-devel`); the native `rdm` copy +implementation is used instead. ### Progress Reporting @@ -109,23 +141,23 @@ sequenceDiagram App->>App: can_proceed() gates the Next arrow ``` -`AppModel` (`app.rs`) is the root Relm4 component. It owns the `adw::ApplicationWindow`, a non-interactive `adw::Carousel` with one page widget per resolved page id, and overlay navigation arrows (Back/Next) rendered on a `gtk::Overlay`. `PageControllers` (`app/pages.rs`) holds the ten page controllers and routes messages to them. +`AppModel` (`app.rs`) is the root Relm4 component. It owns the `adw::ApplicationWindow`, a non-interactive `adw::Carousel` with one page widget per resolved page id, and overlay navigation arrows (Back/Next) rendered on a `gtk::Overlay`. `PageControllers` (`app/pages.rs`) holds the eleven page controllers and routes messages to them. -Navigation state lives in `WizardState` (`app/state.rs`), a GTK-free state machine over `Navigator` (`navigator.rs`), a pure cursor into the resolved page list. The resolved list comes from `sirius.toml`, is filtered to `IMPLEMENTED_PAGES` (`app.rs`: welcome, diagnostics, network, keyboard, timezone, storage, user, summary, progress, finished), and drops the `network` page when NetworkManager reports no Wi-Fi device. +Navigation state lives in `WizardState` (`app/state.rs`), a GTK-free state machine over `Navigator` (`navigator.rs`), a pure cursor into the resolved page list. The resolved list comes from `sirius.toml`, is filtered to `IMPLEMENTED_PAGES` (`app.rs`: welcome, language, diagnostics, network, keyboard, timezone, storage, user, summary, progress, finished), pins `welcome` first, and drops the `network` page when NetworkManager reports no Wi-Fi device. Pages report user choices as `PageOutput` values (`SetLocale`, `SetKeyboard`, `SetTimezone`, `SetStorage`, `SetUser`, `RequestNext`, `RequestInstall`); `WizardState::apply` folds them into the shared `InstallConfig`. **Next-gating is centralized in `WizardState::can_proceed()`**, evaluated on every render: the diagnostics page requires no blocking check, the storage page requires a selected disk (plus a valid `PartitionPlan` for manual installs), the user page requires `UserAccount::validate()`, and progress/finished never advance. Leaving the summary page always goes through the modal erase-and-install confirmation dialog; the confirmation emits `StartInstall`. -Pages that build their widget tree programmatically (currently `diagnostics`, `network`, `storage`, and `summary`) implement `SimpleComponent` by hand instead of using the `#[relm4::component]` macro — a `#[name = ...]` binding inside a `set_child` block fights the macro. +Pages that build their widget tree programmatically (currently `welcome`, `keyboard`, `diagnostics`, `network`, `storage`, and `summary`) implement `SimpleComponent` by hand instead of using the `#[relm4::component]` macro — a `#[name = ...]` binding inside a `set_child` block fights the macro. ## Install Flow ```mermaid sequenceDiagram participant UI as AppModel - participant Ad as backend::adapter - participant Sp as backend::spawn + participant Ad as sirius-backend install + participant Sp as sirius-backend spawn participant Pk as pkexec - participant Rn as backend::runner + participant Rn as sirius-backend runner participant Rm as libreadymade UI->>UI: erase-disk confirmation on summary @@ -145,13 +177,13 @@ sequenceDiagram Rn-->>Sp: Finished or Error ``` -`into_playbook` wires a `Repart` disk provisioner (layout from the descriptor's `repart_dir`) or a `Manual` provisioner (mounts produced by the UDisks2 executor), a `Bootc` filesystem provisioner (image, target imgref, signature policy, kargs/args from the descriptor), and two postinstall modules: `Language` (the locale) and `InitialSetup`, which writes `/.unconfigured` so the distribution's first-boot agent configures the account and hostname. At the pinned `libreadymade` commit there are no postinstall modules for the user account, hostname, timezone, or keyboard layout — see `docs/GAPS.md`. +`into_playbook` wires a `Repart` disk provisioner (layout from the descriptor's `repart_dir`) or a `Manual` provisioner (mounts produced by the UDisks2 executor), a `Bootc` filesystem provisioner (image, target imgref, signature policy, kargs/args from the descriptor), and two postinstall modules: `Language` (the locale) and `InitialSetup`, which writes `/.unconfigured` so the distribution's first-boot agent configures the account and hostname. The current in-tree `libreadymade` has no postinstall modules for the user account, hostname, timezone, or keyboard layout — see `docs/GAPS.md`. ## Storage Subsystem ```mermaid flowchart TD - Lsblk[lsblk --json scan] --> Snap[DiskSnapshot: partitions + free regions] + Lsblk[lsblk crate + sysfs/udev scan] --> Snap[DiskSnapshot: partitions + free regions] Snap --> Page[StoragePage state machine] Page --> Auto[Whole disk / encrypted] Page --> Manual[Manual editor dialog] @@ -165,7 +197,11 @@ flowchart TD UDisks --> Mounts[Mounts for the Manual provisioner] ``` -Disk discovery is read-only: `backend/storage.rs` runs `lsblk --json` and builds `DiskSnapshot` values (model, size, table type, partitions, free regions, in-use flag), skipping read-only, zram, and loop devices. The UI never mutates disks. +Disk discovery is read-only: `sirius-backend::storage::scan_disks` uses the +Rust `lsblk` crate with sysfs, udev properties, and `/proc/mounts` to build +`DiskSnapshot` values (model, size, table type, +partitions, free regions, in-use flag), skipping read-only, zram, and loop +devices. The UI never mutates disks. The storage page (`pages/storage.rs`) is a manual `SimpleComponent` state machine covering disk selection, the automatic (whole-disk/encrypted) path, and the manual path. Its code is split by concern: `page_view.rs` builds the page (disk selector, automatic section), `editor_view.rs` builds the modal editor (usage map and volumes list), `partition_dialog.rs` is the create/edit dialog, and `draft.rs` holds `PartitionDraft` — the pure editing model that stages create/delete/format/label operations into a `PartitionPlan`. Supported filesystems in the editor are btrfs, ext4, vfat, and swap. @@ -190,7 +226,7 @@ flowchart LR Blocked -->|clear| Gate2[wizard may advance] ``` -Each probe owns its own severity (`Pass`/`Warn`/`Fail`) — configuration never reclassifies a check. The `[diagnostics]` policy in `sirius.toml` selects which *failing* checks hard-gate the install (`require`), which ids the UI emphasizes (`warn`, advisory only), and the RAM threshold (`min_ram_gib`, default 2). The default policy requires `uefi`, `ram`, and `disk_space` (20 GiB largest disk) and warns on `secure_boot`, `network`, and `virt`. Facts come from sysfs (`/sys/firmware/efi`, efivars), `sysinfo` (usable RAM), `lsblk`, and `systemd-detect-virt`. The `sirius diag [--json]` subcommand runs the same code path and exits non-zero when blocked. +Each probe owns its own severity (`Pass`/`Warn`/`Fail`) — configuration never reclassifies a check. The `[diagnostics]` policy in `sirius.toml` selects which *failing* checks hard-gate the install (`require`), which ids the UI emphasizes (`warn`, advisory only), and the RAM threshold (`min_ram_gib`, default 2). The default policy requires `uefi`, `ram`, and `disk_space` (20 GiB largest disk) and warns on `secure_boot`, `network`, and `virt`. Facts come from sysfs (`/sys/firmware/efi`, efivars), `sysinfo` (usable RAM), the Rust `lsblk` crate, and `systemd-detect-virt`. The `sirius diag [--json]` subcommand runs the same code path and exits non-zero when blocked. ## Configuration Model And Distro-Agnosticism @@ -202,6 +238,13 @@ classDiagram bentos Vec~Bento~ branding Branding } + class Branding { + name Option~String~ + logo Option~String~ + icon Option~String~ + welcome_button Option~String~ + welcome_banner Option~String~ + } class BootcConfig { image String target_imgref Option~String~ @@ -227,14 +270,15 @@ classDiagram } DistroDescriptor --> BootcConfig DistroDescriptor --> DiskConfig + DistroDescriptor --> Branding SiriusConfig --> PagesConfig SiriusConfig --> DiagnosticsConfig ``` No distribution name, image, or hostname is hardcoded in code or `data/`; distro specifics live in configuration: -- `/etc/sirius/distro.toml` — the `DistroDescriptor`: the bootc/OCI image to deploy, the systemd-repart directory (`/usr/share/sirius/repart.d/*.conf`), up to three `[[bento]]` link cards for the progress page, and optional `[branding]` (logo path or themed icon) for the welcome page. -- `/etc/sirius/sirius.toml` — the `SiriusConfig`: page order/disables and the diagnostics policy. `PagesConfig::resolve()` starts from `order` (or the built-in default), drops unknown and disabled ids, migrates the legacy ids `disk`/`partition`/`manual_partition` to `storage`, and always keeps the mandatory pages (`storage`, `progress`, `finished`). A missing or malformed file falls back to defaults with a logged warning. +- `/etc/sirius/distro.toml` — the `DistroDescriptor`: the bootc/OCI image to deploy, the systemd-repart directory (`/usr/share/sirius/repart.d/*.conf`), up to three `[[bento]]` link cards for the progress page, and optional `[branding]` (`name`, logo/icon, opening banner, and button label template). +- `/etc/sirius/sirius.toml` — the `SiriusConfig`: page order/disables and the diagnostics policy. `PagesConfig::resolve()` starts from `order` (or the built-in default), drops unknown and disabled ids, migrates the legacy ids `disk`/`partition`/`manual_partition` to `storage`, pins `welcome` first, and always keeps the mandatory install pages (`storage`, `progress`, `finished`). A missing or malformed file falls back to defaults with a logged warning. Per-distribution behavior belongs in these files and the repart layout, never in Rust code. @@ -242,29 +286,29 @@ Per-distribution behavior belongs in these files and the repart layout, never in ```mermaid flowchart TD - Po[po/pt_BR.po] --> BuildRs[build.rs: msgfmt --check] - BuildRs --> OutDir[OUT_DIR/locale, used by dev runs] - BuildRs --> DataLocale[data/locale, packaged by generate-rpm] - DataLocale --> Usr[/usr/share/locale on installed systems] - OutDir --> Bind[bindtextdomain: sirius textdomain, UTF-8] + Po["po/pt_BR.po"] --> BuildRs["build.rs: msgfmt --check"] + BuildRs --> OutDir["OUT_DIR/locale, used by dev runs"] + BuildRs --> DataLocale["data/locale, packaged by generate-rpm"] + DataLocale --> Usr["/usr/share/locale on installed systems"] + OutDir --> Bind["bindtextdomain: sirius textdomain, UTF-8"] Usr --> Bind - Bind --> Gettext[gettextrs::gettext at render time] - Welcome[welcome page language picker] --> Lang[LANGUAGE env var] + Bind --> Gettext["gettextrs::gettext at render time"] + Language["language page open picker"] --> Lang["LANGUAGE env var"] Lang --> Gettext - Lang --> Retranslate[Retranslate broadcast to every page] + Lang --> Retranslate["Retranslate broadcast to every page"] Retranslate --> Gettext ``` Sirius uses GNU gettext via `gettext-rs` (linked against the system libintl). msgids are the English literals passed to `gettextrs::gettext()` in the UI code; catalogs live in `po/` at the repo root (`LINGUAS` lists enabled languages, currently `pt_BR`). `crates/sirius-installer/build.rs` compiles each catalog with `msgfmt --check` into `$OUT_DIR/locale` (used by dev runs through the `SIRIUS_DEV_LOCALEDIR` env) and into `data/locale` for packaging, so `msgfmt` is a required build tool. Installed systems load `/usr/share/locale`. -Runtime language switching needs no restart: the welcome page's language picker emits `SetLocale`, `WizardState` sets the `LANGUAGE` environment variable (glibc gettext consults it on every lookup), and `AppModel` broadcasts a `Retranslate` message to all ten pages, which re-render through gettext on the next `update_view`. +Runtime language switching needs no restart: the `language` page's open list emits `SetLocale`, `WizardState` sets the `LANGUAGE` environment variable (glibc gettext consults it on every lookup), and `AppModel` broadcasts a `Retranslate` message to all eleven pages, which re-render through gettext on the next `update_view`. ## Design Constraints - The UI follows GNOME HIG and Libadwaita patterns. - The UI never touches disks. Mutations run only in the `pkexec sirius run-playbook` process, after the erase-and-install confirmation. - `sirius-diag` stays free of GTK/Relm4 types; probes consume plain values. -- `backend/` remains the only consumer of libreadymade, NetworkManager, and UDisks2; UI code depends on the `Progress` boundary type. +- `sirius-backend` remains the only consumer of libreadymade, NetworkManager, and UDisks2; UI code depends on the core `Progress` boundary type. - The `InstallRequest` carries only user choices. What gets installed comes from the root-owned descriptor, never from the unprivileged side. - Stay distro-agnostic: no hardcoded distribution names, images, or hostnames in code or `data/`. - Errors should be actionable — pkexec failures explain the polkit agent requirement, and the install log keeps the runner's stderr tail. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f4c539..f3c418c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,9 +9,16 @@ Install system dependencies on Fedora or inside the project toolbox: ```sh sudo dnf install -y \ rust cargo pkgconf-pkg-config \ - gtk4-devel libadwaita-devel gettext + gtk4-devel libadwaita-devel libgweather-devel gnome-desktop4-devel gettext \ + lcms2-devel fontconfig-devel libseccomp-devel glycin-loaders bubblewrap ``` +The `lcms2`/`fontconfig`/`libseccomp` devel packages build the `glycin` crate +(SVG timezone map); `glycin-loaders` + `bubblewrap` are its sandboxed runtime +decoders. `gnome-desktop4-devel` and `fontconfig-devel` are the link targets +of the keyboard/language pages' FFI modules (`libgnome-desktop-4`, +`libfontconfig`). + `gettext` provides `msgfmt`, which `crates/sirius-installer/build.rs` requires to compile the translation catalogs — without it the build fails. Run the wizard and its entry points: @@ -47,9 +54,190 @@ sudo -E SIRIUS_TEST_DISK=/dev/vdb cargo test --test vm_install -- --ignored vm_f `SIRIUS_TEST_DISK` must be a `/dev/...` block device you are willing to erase. The test pipes an install request into the same `run-playbook` entry point the UI spawns under pkexec and asserts the run finishes. +### Vagrant test VM + +The repository's `Vagrantfile` runs Sirius directly in a ready-made Fedora +Silverblue 44 graphical VM with the libvirt provider. It does not require +building a Luminus live ISO. Sirius installs +`docker://ghcr.io/luminusos/luminusos-workstation:44` onto an isolated, +disposable qcow2 disk, so the resulting target is a real bootc system. + +There is no wrapper or separate lab command. Vagrant builds Sirius, transfers +the runtime files, unlocks a disposable writable Silverblue `/usr` overlay, and +installs the binary, polkit policy, desktop file, configuration, locale, and +repart definitions at their production paths. The real GNOME polkit agent +handles authorization; the lab does not bypass the policy. + +#### Requirements + +- Fedora 44 x86_64 with KVM and libvirt. +- Vagrant 2.3 or newer with `vagrant-libvirt`. +- Cargo/Rust, `edk2-ovmf`, and `swtpm`. +- `virt-viewer`, GNOME Boxes, Field Monitor, or another SPICE client. + +The VM deliberately uses the host's packaged OVMF firmware. The firmware path +embedded in the downloaded box is below `~/.vagrant.d/boxes` and is blocked by +SELinux when QEMU starts; Vagrant creates a correctly labelled NVRAM copy in +the VM state directory instead. + +#### Start and update + +From the Sirius repository: + +```sh +vagrant up --provider=libvirt +``` + +Open the VM's graphical display with `virt-viewer`, GNOME Boxes, or Field +Monitor. Silverblue starts and logs into the `vagrant` GNOME session +automatically; launch **Install Operating System** from the app grid. The +runner suppresses GNOME's initial setup and tour, disables idle suspension, +and keeps the screen from locking so long-running installation tests remain +visible. The `vagrant` user's password is `vagrant`, including for the real +polkit authentication dialog. + +After source changes, one native command rebuilds, synchronizes, and reinstalls +Sirius in the running VM: + +```sh +vagrant provision +``` + +Close and reopen Sirius to use the new binary. A runner reboot discards the +transient `/usr` overlay. The uploaded payload lives persistently under the +`vagrant` user's data directory, so the `always` provisioner can reinstall it +after a reload: + +```sh +vagrant reload --provision +``` + +`vagrant reload --no-provision` intentionally skips the file upload. On VMs +created by older revisions, run `vagrant provision` once without +`--provision-with`; this migrates the complete payload to its persistent +location. Selecting only the `sirius` shell provisioner cannot perform that +upload. + +Other useful standard commands: + +```sh +vagrant ssh +vagrant halt +vagrant destroy +``` + +The runner is an OSTree Silverblue deployment. The disk installed by Sirius +is a real bootc deployment and can be selected from the VM's UEFI boot menu. + +#### Repeat an installation + +The normal repeat-installation loop does not require deleting the target +qcow2. As long as the VM is running the Silverblue runner, `/dev/vdb` is an +unmounted disposable target and Sirius can overwrite its existing partition +table: + +```sh +vagrant provision +``` + +Close and reopen **Install Operating System**, select the disk with serial +`SIRIUS_TARGET`, and complete the wizard again. + +If the VM was rebooted into the installed LuminusOS system, restart it and use +the UEFI boot menu in the SPICE console to select the Silverblue runner disk. +Once the runner is active and `vagrant ssh` works, run `vagrant provision` and +reopen Sirius. + +To discard the installed system and recreate a completely blank target, +destroy and create the VM again: + +```sh +vagrant destroy -f +vagrant up --provider=libvirt +``` + +The libvirt provider owns the disposable `target.qcow2` and, in the `multiple` +profile, `extra.qcow2`, so `vagrant destroy` removes them together with the +runner. Use the same `SIRIUS_DISK_PROFILE` value on `destroy` that was used on +`up`; Vagrant evaluates the profile on every command. On a new libvirt +session, Vagrant creates its `default` storage pool under `.vagrant/disks/`. +If that session already has a `default` pool, Vagrant uses the existing pool's +path instead. + +#### VM and disk scenarios + +Choose profiles through environment variables before the first `vagrant up`: + +```sh +SIRIUS_VM_PROFILE=low-ram vagrant up +SIRIUS_VM_PROFILE=no-tpm vagrant up +SIRIUS_VM_PROFILE=secure-boot vagrant up + +SIRIUS_DISK_PROFILE=small vagrant up +SIRIUS_DISK_PROFILE=multiple vagrant up +SIRIUS_DISK_PROFILE=none vagrant up +``` + +The defaults are 4 vCPUs, 8 GiB RAM, UEFI, TPM 2.0, and one empty 64 GiB +virtio disk with serial `SIRIUS_TARGET`. `small` creates an 8 GiB target, +`multiple` adds a 48 GiB SATA disk, and `none` presents no install target. + +Profiles describe libvirt hardware and must remain the same for the life of a +VM. To switch one, destroy the runner with its current profile first; the +provider removes its disposable install disks automatically. + +Create partitioned or mounted-disk scenarios inside the disposable guest: + +```sh +vagrant ssh +sudo systemd-repart --empty=create /dev/disk/by-id/virtio-SIRIUS_TARGET +``` + +For the mounted/in-use case, format one of the created data partitions and +mount it before opening Sirius. Never run these commands against the runner +disk. + +#### Network and virtual Wi-Fi + +Wired offline/online behavior can be switched inside the runner: + +```sh +vagrant ssh -c 'sudo nmcli networking off' +vagrant ssh -c 'sudo nmcli networking on' +``` + +Virtual Wi-Fi setup is an optional named Vagrant provisioner. The first call +layers `hostapd` and `iw` into Silverblue: + +```sh +vagrant provision --provision-with wifi +vagrant reload +vagrant provision --provision-with wifi +``` + +It creates `Sirius Open`, `Sirius WPA2`, and `Sirius WPA3`. The WPA2/WPA3 +password is `sirius-test`. This tests NetworkManager and Sirius behavior with +the kernel's `mac80211_hwsim`; it does not emulate physical radio quality. + +#### Installation passes + +Exercise at least: + +1. Automatic unencrypted installation. +2. Automatic LUKS installation. +3. TPM-sealed LUKS installation. +4. Manual ESP/root partitioning. +5. Polkit cancellation and rejection. +6. Offline, small-disk, multiple-disk, and no-disk behavior. + +After installation, reboot and use the UEFI boot menu in the SPICE console to +select the disk with serial `SIRIUS_TARGET`. In the installed system, +`bootc status` verifies that the target is a bootc deployment. Select the +Silverblue runner disk in the same menu to return to Sirius development. + ## Development Aids -- `SIRIUS_START_PAGE=` — open the wizard directly on a page, e.g. `SIRIUS_START_PAGE=progress cargo run --bin sirius` animates the progress UI without installing. Page ids: `welcome`, `diagnostics`, `network`, `keyboard`, `timezone`, `storage`, `user`, `summary`, `progress`, `finished`. +- `SIRIUS_START_PAGE=` — open the wizard directly on a page, e.g. `SIRIUS_START_PAGE=progress cargo run --bin sirius` animates the progress UI without installing. Page ids: `welcome`, `language`, `diagnostics`, `network`, `keyboard`, `timezone`, `storage`, `user`, `summary`, `progress`, `finished`. - `--dry-run` — print the `InstallRequest` JSON plus the parsed distro descriptor without touching anything. ## AI-Assisted Contributions @@ -65,12 +253,20 @@ screen recordings, logs, or clear reproduction steps. ## Project Architecture -Sirius has two Rust crates: +Sirius has five focused Rust crates: +- `sirius-core` — domain models, distro schema, partition plans, and the stable + `InstallRequest`/`Progress` process protocol. It performs no system or GUI I/O. - `sirius-diag` — pure library: hardware probes, the `Check`/`Status` model, install gating (`run_all_checks`, `is_blocked`), and the page-toggle config (`SiriusConfig`, `PagesConfig::resolve`). No GTK, fully unit-tested. -- `sirius-installer` — the GTK wizard binary `sirius`, plus the `diag`, `--dry-run`, and hidden `run-playbook` (privileged install) entry points. +- `sirius-backend` — system integrations, request/playbook conversion, pkexec, + runner, NetworkManager, read-only disk discovery, and privileged UDisks2 writes. +- `sirius-app` — the Relm4/GTK wizard and its GTK-free navigation/state modules. +- `sirius-installer` — thin CLI/dispatch crate that still produces the single + installed `sirius` binary. -Inside `sirius-installer`, `src/backend/` is the only module that touches libreadymade, NetworkManager, or UDisks2; everything else depends on the `backend::Progress` boundary type. See [ARCHITECTURE.md](ARCHITECTURE.md) for diagrams and module responsibilities. +Only `sirius-backend` may touch libreadymade, NetworkManager, or UDisks2, and +only `sirius-app` may depend on GTK/Relm4. See +[ARCHITECTURE.md](ARCHITECTURE.md) for diagrams and dependency rules. ## Coding Guidelines @@ -93,18 +289,22 @@ msgfmt --check -o /dev/null po/pt_BR.po ``` - `crates/sirius-installer/build.rs` compiles every catalog in `LINGUAS` with `msgfmt --check` into `$OUT_DIR/locale` (dev runs) and `data/locale` (packaging), so `msgfmt` is a required build tool. -- Runtime language switching happens on the welcome page: the wizard sets the `LANGUAGE` environment variable and broadcasts a `Retranslate` message to every page, which re-renders through gettext. +- Runtime language switching happens on the `language` page: the wizard sets the `LANGUAGE` environment variable and broadcasts a `Retranslate` message to every page, which re-renders through gettext. ## UI Guidelines - Follow GNOME HIG and Libadwaita conventions. - Use symbolic icons from the current icon theme; bento and branding icon names referenced from `distro.toml` must exist in the live system's theme. +- Keep distro identity in `[branding]`: `name`, `logo`, `welcome_banner`, and + `welcome_button`. The button accepts `{name}`; banner and logo values are file + paths available in the live system. `/usr/share/sirius/welcome-banner.png` is + the packaged generic fallback. - Keep text concise and truthful. Do not describe a capability that is not implemented. - Leaving the summary page erases a disk: keep the destructive confirmation dialog on that path. ## Packaging -RPM metadata lives in `crates/sirius-installer/Cargo.toml` under `[package.metadata.generate-rpm]`: the binary, the polkit policy, the desktop file, the icon, `distro.toml`, `sirius.toml`, the repart layout, and the compiled locale catalogs. CI builds the RPM with `cargo generate-rpm` on every push and attaches it to GitHub releases on `v*` tags. See [INSTALL.md](INSTALL.md) for the install paths, the polkit/pkexec policy (`io.sirius.Installer.run-playbook`), and the runtime requirements on the target system. +RPM metadata lives in `crates/sirius-installer/Cargo.toml` under `[package.metadata.generate-rpm]`: the binary, the polkit policy, the desktop file, the icon, generic welcome banner, `distro.toml`, `sirius.toml`, the repart layout, and the compiled locale catalogs. CI builds the RPM with `cargo generate-rpm` on every push and attaches it to GitHub releases on `v*` tags. See [INSTALL.md](INSTALL.md) for the install paths, the polkit/pkexec policy (`io.sirius.Installer.run-playbook`), and the runtime requirements on the target system. ## Pull Request Checklist diff --git a/Cargo.lock b/Cargo.lock index 9ec07ef..b176493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,31 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-global-executor" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" +dependencies = [ + "async-channel", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -308,7 +333,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -322,6 +347,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -374,6 +405,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytesize" version = "2.3.1" @@ -389,9 +426,9 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cairo-sys-rs", - "glib", + "glib 0.21.5", "libc", ] @@ -401,7 +438,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" dependencies = [ - "glib-sys", + "glib-sys 0.21.5", "libc", "system-deps", ] @@ -671,6 +708,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "darling" version = "0.21.3" @@ -740,6 +783,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deku" version = "0.20.3" @@ -775,6 +849,21 @@ dependencies = [ "serde_core", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -870,6 +959,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -946,7 +1056,7 @@ dependencies = [ [[package]] name = "filesystem-table" version = "0.1.2" -source = "git+https://github.com/luminusOS/readymade.git?rev=c58f56daa25463c660a1488bce5e5a45f0328c2b#c58f56daa25463c660a1488bce5e5a45f0328c2b" +source = "git+https://github.com/luminusOS/readymade.git?rev=255b339430d6cd886bd22cd4517a83a52a3fa541#255b339430d6cd886bd22cd4517a83a52a3fa541" dependencies = [ "lsblk", "thiserror 2.0.18", @@ -992,6 +1102,33 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "format-bytes" version = "0.3.0" @@ -1123,6 +1260,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -1147,8 +1290,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" dependencies = [ "gdk-pixbuf-sys", - "gio", - "glib", + "gio 0.21.5", + "glib 0.21.5", "libc", ] @@ -1158,9 +1301,9 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.21.5", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", "libc", "system-deps", ] @@ -1174,8 +1317,8 @@ dependencies = [ "cairo-rs", "gdk-pixbuf", "gdk4-sys", - "gio", - "glib", + "gio 0.21.5", + "glib 0.21.5", "libc", "pango", ] @@ -1188,9 +1331,9 @@ checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.21.5", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", "libc", "pango-sys", "pkg-config", @@ -1271,8 +1414,25 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "gio-sys", - "glib", + "gio-sys 0.21.5", + "glib 0.21.5", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys 0.22.8", + "glib 0.22.8", "libc", "pin-project-lite", "smallvec", @@ -1284,8 +1444,21 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "gio-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" +dependencies = [ + "glib-sys 0.22.8", + "gobject-sys 0.22.6", "libc", "system-deps", "windows-sys", @@ -1297,16 +1470,37 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ - "bitflags", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", "futures-task", "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", + "gio-sys 0.21.5", + "glib-macros 0.21.5", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys 0.22.8", + "glib-macros 0.22.6", + "glib-sys 0.22.8", + "gobject-sys 0.22.6", "libc", "memchr", "smallvec", @@ -1325,6 +1519,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "glib-sys" version = "0.21.5" @@ -1335,19 +1541,122 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glib-sys" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glycin" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923674c1f2b1a28f69ebdc0f572672e63874e17ce0e60c96b8bfaff47a8ef4e9" +dependencies = [ + "async-fs", + "async-global-executor", + "async-io", + "async-lock", + "async-task", + "blocking", + "futures-channel", + "futures-lite", + "futures-timer", + "futures-util", + "gio 0.22.8", + "glib 0.22.8", + "glycin-common", + "glycin-utils", + "gufo-common", + "gufo-exif", + "lcms2", + "lcms2-sys", + "libc", + "libseccomp", + "memfd", + "memmap2", + "nix 0.30.1", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "yeslogic-fontconfig-sys", + "zbus", +] + +[[package]] +name = "glycin-common" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db28cec888051ccbf68f9f352d57ba2a292179863524a0769e54dbb72d152a03" +dependencies = [ + "bitflags 2.13.0", + "gufo-common", + "half", + "memmap2", + "nix 0.30.1", + "paste", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "zerocopy", + "zvariant", +] + +[[package]] +name = "glycin-utils" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49cd2451bced7416341a5e89d74f547e92b85b67e8f66c8723a7505930a50ab" +dependencies = [ + "async-lock", + "bitflags 2.13.0", + "blocking", + "env_logger", + "futures-util", + "glycin-common", + "gufo-common", + "half", + "libc", + "libseccomp", + "log", + "memmap2", + "nix 0.30.1", + "paste", + "rayon", + "serde", + "thiserror 2.0.18", + "zbus", + "zerocopy", +] + [[package]] name = "gobject-sys" version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" dependencies = [ - "glib-sys", + "glib-sys 0.21.5", + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys 0.22.8", "libc", "system-deps", ] @@ -1358,7 +1667,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3696fafb1ecdcc2ae3ce337de73e9202806068594b77d22fdf2f3573c5ec2219" dependencies = [ - "bitflags", + "bitflags 2.13.0", "crc", "simple-bytes", "uuid", @@ -1370,7 +1679,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" dependencies = [ - "glib", + "glib 0.21.5", "graphene-sys", "libc", ] @@ -1381,7 +1690,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" dependencies = [ - "glib-sys", + "glib-sys 0.21.5", "libc", "pkg-config", "system-deps", @@ -1395,7 +1704,7 @@ checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" dependencies = [ "cairo-rs", "gdk4", - "glib", + "glib 0.21.5", "graphene-rs", "gsk4-sys", "libc", @@ -1410,8 +1719,8 @@ checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" dependencies = [ "cairo-sys-rs", "gdk4-sys", - "glib-sys", - "gobject-sys", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", "graphene-sys", "libc", "pango-sys", @@ -1429,8 +1738,8 @@ dependencies = [ "futures-channel", "gdk-pixbuf", "gdk4", - "gio", - "glib", + "gio 0.21.5", + "glib 0.21.5", "graphene-rs", "gsk4", "gtk4-macros", @@ -1460,9 +1769,9 @@ dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.21.5", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", "graphene-sys", "gsk4-sys", "libc", @@ -1470,6 +1779,53 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gufo-common" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f23bc0d0e791259ce4fe703c4d988c64317e33477bc4d1d7526ab8eb9fcaab6" +dependencies = [ + "paste", + "serde", + "thiserror 2.0.18", + "zvariant", +] + +[[package]] +name = "gufo-exif" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad61fccc591119491879e7bee63ebbfbd27811b35367b4728a857119fde78843" +dependencies = [ + "gufo-common", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "gweather-sys" +version = "4.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd48d8a64d00703dc2a77efb2284c07192a1345d0162e0bc15488b55d577f0e" +dependencies = [ + "gio-sys 0.22.8", + "glib-sys 0.22.8", + "gobject-sys 0.22.6", + "libc", + "system-deps", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1635,6 +1991,42 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1687,6 +2079,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lcms2" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75877b724685dd49310bdbadbf973fc69b1d01992a6d4a861b928fc3943f87b" +dependencies = [ + "bytemuck", + "foreign-types", + "lcms2-sys", +] + +[[package]] +name = "lcms2-sys" +version = "4.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "264db0b78119c5a37d78bb41fb355daab29b3b29430b53cd92e3da51f0ab06cc" +dependencies = [ + "cc", + "dunce", + "libc", + "pkg-config", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1700,8 +2115,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb09e12bf8f73342b3315c839d0a7668cc0ccebd78490c49fec48bab15d5484b" dependencies = [ "gdk4", - "gio", - "glib", + "gio 0.21.5", + "glib 0.21.5", "gtk4", "libadwaita-sys", "libc", @@ -1715,9 +2130,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d7f94227ba87eb596fecada2491f04e357d507324142f77bf76d9e6be4a3e31" dependencies = [ "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.21.5", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", "gtk4-sys", "libc", "pango-sys", @@ -1730,6 +2145,18 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgweather" +version = "4.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dd39da07edc7e068f1b02da95d5813030343cfd94ae1f132d25695ba8e6c9f" +dependencies = [ + "gio 0.22.8", + "glib 0.22.8", + "gweather-sys", + "libc", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1764,7 +2191,7 @@ dependencies = [ [[package]] name = "libreadymade" version = "0.12.5" -source = "git+https://github.com/luminusOS/readymade.git?rev=c58f56daa25463c660a1488bce5e5a45f0328c2b#c58f56daa25463c660a1488bce5e5a45f0328c2b" +source = "git+https://github.com/luminusOS/readymade.git?rev=255b339430d6cd886bd22cd4517a83a52a3fa541#255b339430d6cd886bd22cd4517a83a52a3fa541" dependencies = [ "backhand", "bytesize", @@ -1805,6 +2232,24 @@ dependencies = [ "xattr", ] +[[package]] +name = "libseccomp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e5310a2c5b6ffbc094b5f70a2ca7b79ed36ad90e6f90994b166489a1bce3fcc" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libseccomp-sys", + "pkg-config", +] + +[[package]] +name = "libseccomp-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60276e2d41bbb68b323e566047a1bfbf952050b157d8b5cdc74c07c1bf4ca3b6" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1890,6 +2335,24 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1941,7 +2404,19 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -1953,7 +2428,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2093,8 +2568,8 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" dependencies = [ - "gio", - "glib", + "gio 0.21.5", + "glib 0.21.5", "libc", "pango-sys", ] @@ -2105,8 +2580,8 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", "libc", "system-deps", ] @@ -2183,6 +2658,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -2351,7 +2841,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -2441,7 +2931,7 @@ dependencies = [ [[package]] name = "repart" version = "0.1.0" -source = "git+https://github.com/luminusOS/readymade.git?rev=c58f56daa25463c660a1488bce5e5a45f0328c2b#c58f56daa25463c660a1488bce5e5a45f0328c2b" +source = "git+https://github.com/luminusOS/readymade.git?rev=255b339430d6cd886bd22cd4517a83a52a3fa541#255b339430d6cd886bd22cd4517a83a52a3fa541" dependencies = [ "bytesize", "const_format", @@ -2455,6 +2945,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -2482,7 +2991,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -2544,7 +3053,7 @@ dependencies = [ [[package]] name = "serde-systemd-unit" version = "0.1.0" -source = "git+https://github.com/luminusOS/readymade.git?rev=c58f56daa25463c660a1488bce5e5a45f0328c2b#c58f56daa25463c660a1488bce5e5a45f0328c2b" +source = "git+https://github.com/luminusOS/readymade.git?rev=255b339430d6cd886bd22cd4517a83a52a3fa541#255b339430d6cd886bd22cd4517a83a52a3fa541" dependencies = [ "chumsky", "itertools 0.14.0", @@ -2735,11 +3244,59 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c11532d9d241904f095185f35dcdaf930b1427a94d5b01d7002d74ba19b44cc4" +[[package]] +name = "sirius-app" +version = "1.2.0" +dependencies = [ + "async-io", + "futures-channel", + "gettext-rs", + "gio 0.22.8", + "glycin", + "libadwaita", + "libgweather", + "relm4", + "sirius-backend", + "sirius-core", + "sirius-diag", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "sirius-backend" +version = "1.2.0" +dependencies = [ + "gettext-rs", + "libc", + "libreadymade", + "lsblk", + "serde", + "serde_json", + "sirius-core", + "uuid", + "zbus", + "zvariant", +] + +[[package]] +name = "sirius-core" +version = "1.2.0" +dependencies = [ + "gettext-rs", + "serde", + "serde_json", + "toml 0.8.23", + "uuid", +] + [[package]] name = "sirius-diag" -version = "0.1.0" +version = "1.2.0" dependencies = [ "gettext-rs", + "lsblk", "serde", "sysinfo", "toml 0.8.23", @@ -2747,23 +3304,17 @@ dependencies = [ [[package]] name = "sirius-installer" -version = "0.1.0" +version = "1.2.0" dependencies = [ "clap", "gettext-rs", - "libadwaita", - "libc", - "libreadymade", - "relm4", - "serde", "serde_json", + "sirius-app", + "sirius-backend", + "sirius-core", "sirius-diag", - "toml 0.8.23", "tracing", "tracing-subscriber", - "uuid", - "zbus", - "zvariant", ] [[package]] @@ -2817,6 +3368,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -2854,13 +3411,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sys-mount" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d361f5431256ea04c657c0dce767ad95e24fb054d99f0b8a9275cb1c9ea14bfb" dependencies = [ - "bitflags", + "bitflags 2.13.0", "libc", "loopdev-3", "smart-default", @@ -3414,7 +3982,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -3767,7 +4335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -3828,6 +4396,17 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "zbus" version = "5.17.0" diff --git a/Cargo.toml b/Cargo.toml index 862328d..b317013 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,15 @@ [workspace] resolver = "2" -members = ["crates/sirius-diag", "crates/sirius-installer"] +members = [ + "crates/sirius-app", + "crates/sirius-backend", + "crates/sirius-core", + "crates/sirius-diag", + "crates/sirius-installer", +] [workspace.package] -version = "0.1.0" +version = "1.2.0" edition = "2024" license = "GPL-3.0-or-later" @@ -19,13 +25,26 @@ adw = { version = "0.8", package = "libadwaita", features = ["v1_5"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt"] } libc = "0.2" +lsblk = "0.6.1" uuid = { version = "1", features = ["v4"] } zbus = "5" zvariant = "5" -# Uses the LuminusOS fork while upstream fixes required by Sirius are pending. +libgweather = "4" +# SVG decoding for the timezone map; driven on a dedicated thread through +# async-io (glycin's native runtime), never on the glib main context. Pinned +# to 3.x because its loader protocol (COMPAT_VERSION 2) matches the +# glycin-loaders 2.x packages shipped by Fedora. +glycin = "3.1" +async-io = "2" +# Uses the LuminusOS readymade fork, pinned to the revision carrying the +# Keyboard postinstall module and the bootc finalize fixes Sirius requires. # `default-features = false` disables the `uutils` feature, which pulls in # `uu_cp`/`exacl` and forces linking against `libacl.so` (the `libacl-devel` # system package, absent on this host). It only gates one of three copy backends # (`copy_dir_uutils`); the default copy method is the native `rdm` implementation, # so dropping it does not affect the install path we need. -libreadymade = { git = "https://github.com/luminusOS/readymade.git", rev = "c58f56daa25463c660a1488bce5e5a45f0328c2b", default-features = false } +libreadymade = { git = "https://github.com/luminusOS/readymade.git", rev = "255b339430d6cd886bd22cd4517a83a52a3fa541", default-features = false } +sirius-core = { path = "crates/sirius-core" } +sirius-diag = { path = "crates/sirius-diag" } +sirius-backend = { path = "crates/sirius-backend" } +sirius-app = { path = "crates/sirius-app" } diff --git a/INSTALL.md b/INSTALL.md index 1fb6303..700e359 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,6 +8,9 @@ polkit/desktop integration, and the distribution's descriptor + repart layout. | `target/release/sirius` | `/usr/bin/sirius` | | `data/io.sirius.Installer.policy` | `/usr/share/polkit-1/actions/` | | `data/io.sirius.Installer.desktop` | `/usr/share/applications/` | +| `data/images/welcome-banner.png` | `/usr/share/sirius/welcome-banner.png` | +| `data/images/timezone-map.svg` | `/usr/share/sirius/timezone-map.svg` | +| `data/images/timezone-pin.png` | `/usr/share/sirius/timezone-pin.png` | | `data/distro.toml` (per-distribution) | `/etc/sirius/distro.toml` | | `data/repart.d/*.conf` (per-distribution) | `/usr/share/sirius/repart.d/` | | `data/sirius.toml` (page toggles, optional) | `/etc/sirius/sirius.toml` | @@ -33,8 +36,21 @@ use only kernel mount options for that filesystem. Userspace keywords like `defaults`, `auto`, or `nofail` make the mount fail with EINVAL. `distro.toml` may also declare up to three optional `[[bento]]` link cards (title/desc/link/icon) shown on the install progress page — website, help, contribute links, as in Readymade — and an -optional `[branding]` section (`logo` image path, or themed `icon` name) for the -welcome page; see the commented examples in `data/distro.toml`. Bento `icon` +optional `[branding]` section for the installer identity and opening page: + +```toml +[branding] +name = "Example OS" +logo = "/usr/share/example-os/logo.png" +icon = "system-software-install-symbolic" +welcome_button = "Install {name}" +welcome_banner = "/usr/share/example-os/installer-banner.png" +``` + +`logo` wins over `icon` on the language page. `welcome_button` supports the +`{name}` placeholder, and `welcome_banner` replaces the packaged generic +`/usr/share/sirius/welcome-banner.png`; see the examples in `data/distro.toml`. +Bento `icon` names must exist in the live system's icon theme (ship custom ones under `/usr/share/icons/hicolor/scalable/actions/`); missing names fall back to a generic link glyph. @@ -51,11 +67,15 @@ min_ram_gib = 2 The canonical page id for disk selection and automatic/manual partitioning is `storage`. Older configurations that list `disk`, `partition`, or `manual_partition` are migrated in memory to one `storage` page. The `network` -page is automatically omitted when NetworkManager reports no Wi-Fi device. +page is automatically omitted when NetworkManager reports no Wi-Fi device. The +`welcome` is always pinned first, before the `language` page. This is an +intentional page-id break: configurations that previously used `welcome` for +the language selector must rename that entry to `language`. ## Runtime requirements on the target/live system `systemd-repart`, `bootc`, `cryptsetup` (for encrypted installs), `pkexec`/polkit, -`mount`, `lsblk`, `udisks2`, and `NetworkManager`. +`mount`, `udisks2`, and `NetworkManager`. Block-device discovery is performed +in-process through the Rust `lsblk` crate and the kernel's sysfs data. The live user must be allowed to request NetworkManager scans/connections. Disk mutations never run in the UI process: the confirmed `PartitionPlan` crosses the diff --git a/README.md b/README.md index 75c519c..f00a170 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ for partitioning, bootc for the image, with optional LUKS encryption. virtualization, and network before installing, and blocks on hard failures. - **Toggleable wizard pages** — page order and which pages run are driven by `/etc/sirius/sirius.toml`; no recompile needed. +- **Distro-owned welcome** — name, logo, opening banner, and the pill action + label come from `/etc/sirius/distro.toml`, including `{name}` substitution. - **bootc install** — deploys an OCI image via systemd-repart + bootc, with optional LUKS encryption. - **Unified storage editor** — choose automatic provisioning or stage a validated @@ -41,7 +43,7 @@ for partitioning, bootc for the image, with optional LUKS encryption. - **Privilege split** — the unprivileged UI builds an install request; a `pkexec` child executes it as root and streams progress back. - **Translated UI** — English and Brazilian Portuguese, switchable live from the - welcome page. + open language list. - **Logging** — every install writes a timestamped log to `/tmp/sirius-install-*.log` and shows live progress in the UI. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..0250206 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,364 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +require "fileutils" +require "shellwords" + +ROOT = __dir__ +VAGRANT_STATE = File.join(ROOT, ".vagrant") +PAYLOAD = File.join(VAGRANT_STATE, "sirius-payload") +DISK_POOL = File.join(VAGRANT_STATE, "disks") +GUEST_PAYLOAD = "/home/vagrant/.local/share/sirius-payload" + +profile = ENV.fetch("SIRIUS_VM_PROFILE", "default") +unless %w[default low-ram no-tpm secure-boot].include?(profile) + raise "SIRIUS_VM_PROFILE must be default, low-ram, no-tpm, or secure-boot" +end + +disk_profile = ENV.fetch("SIRIUS_DISK_PROFILE", "normal") +unless %w[normal small multiple none].include?(disk_profile) + raise "SIRIUS_DISK_PROFILE must be normal, small, multiple, or none" +end + +FileUtils.mkdir_p(PAYLOAD) +FileUtils.mkdir_p(DISK_POOL) + +secure_boot = profile == "secure-boot" +firmware_suffix = secure_boot ? ".secboot" : "" +firmware_code = "/usr/share/edk2/ovmf/OVMF_CODE#{firmware_suffix}.fd" +firmware_vars_template = "/usr/share/edk2/ovmf/OVMF_VARS#{firmware_suffix}.fd" +unless File.exist?(firmware_code) && File.exist?(firmware_vars_template) + raise "edk2-ovmf firmware is required: #{firmware_code}" +end + +# The box references firmware below ~/.vagrant.d/boxes. SELinux confines QEMU +# and denies that user_home_t path even with qemu:///session. Use the host's +# packaged read-only code and a VM-local, writable copy of its matching NVRAM. +firmware_vars = File.join( + DISK_POOL, + secure_boot ? "sirius-secure-boot-vars.fd" : "sirius-vars.fd", +) +FileUtils.cp(firmware_vars_template, firmware_vars) unless File.exist?(firmware_vars) +FileUtils.chmod(0o644, firmware_vars) +system("chcon", "-t", "svirt_home_t", firmware_vars, out: File::NULL, err: File::NULL) + +stage_payload = <<~SHELL + set -euo pipefail + cd #{Shellwords.escape(ROOT)} + cargo build --bin sirius + + payload=.vagrant/sirius-payload + install -Dm755 target/debug/sirius "$payload/usr/bin/sirius" + install -Dm644 data/io.sirius.Installer.policy \ + "$payload/usr/share/polkit-1/actions/io.sirius.Installer.policy" + install -Dm644 data/io.sirius.Installer.desktop \ + "$payload/usr/share/applications/io.sirius.Installer.desktop" + install -Dm644 data/sirius.toml "$payload/etc/sirius/sirius.toml" + install -Dm644 data/images/welcome-banner.png \ + "$payload/usr/share/sirius/welcome-banner.png" + install -Dm644 data/images/timezone-map.svg \ + "$payload/usr/share/sirius/timezone-map.svg" + install -Dm644 data/images/timezone-pin.png \ + "$payload/usr/share/sirius/timezone-pin.png" + install -Dm644 data/repart.d/10-esp.conf \ + "$payload/usr/share/sirius/repart.d/10-esp.conf" + install -Dm644 data/repart.d/20-root.conf \ + "$payload/usr/share/sirius/repart.d/20-root.conf" + + if [ -f data/locale/pt_BR/LC_MESSAGES/sirius.mo ]; then + install -Dm644 data/locale/pt_BR/LC_MESSAGES/sirius.mo \ + "$payload/usr/share/locale/pt_BR/LC_MESSAGES/sirius.mo" + fi +SHELL + +install_sirius = <<~SHELL + set -euo pipefail + + payload=#{Shellwords.escape(GUEST_PAYLOAD)} + if ! test -x "$payload/usr/bin/sirius" || + ! test -f "$payload/usr/share/sirius/welcome-banner.png" || + ! test -f "$payload/usr/share/sirius/timezone-map.svg"; then + echo "The persistent Sirius payload is not available in this VM." + echo "Run 'vagrant provision' once (without --provision-with) to upload it." + exit 0 + fi + + command -v bootc >/dev/null || { + echo "This box does not provide bootc; update the Silverblue box." >&2 + exit 1 + } + + if ! test -w /usr; then + # The default development unlock is writable and discarded on reboot. + # `--transient` is intentionally not used: Fedora mounts that variant + # read-only unless every write happens in a separate mount namespace. + ostree admin unlock + fi + + install -Dm755 "$payload/usr/bin/sirius" /usr/bin/sirius + install -Dm644 \ + "$payload/usr/share/polkit-1/actions/io.sirius.Installer.policy" \ + /usr/share/polkit-1/actions/io.sirius.Installer.policy + install -Dm644 \ + "$payload/usr/share/applications/io.sirius.Installer.desktop" \ + /usr/share/applications/io.sirius.Installer.desktop + sed -i 's/^Icon=.*/Icon=system-software-install-symbolic/' \ + /usr/share/applications/io.sirius.Installer.desktop + install -Dm644 "$payload/etc/sirius/sirius.toml" \ + /etc/sirius/sirius.toml + install -Dm644 \ + "$payload/usr/share/sirius/welcome-banner.png" \ + /usr/share/sirius/welcome-banner.png + install -Dm644 \ + "$payload/usr/share/sirius/timezone-map.svg" \ + /usr/share/sirius/timezone-map.svg + install -Dm644 \ + "$payload/usr/share/sirius/timezone-pin.png" \ + /usr/share/sirius/timezone-pin.png + install -Dm644 \ + "$payload/usr/share/sirius/repart.d/10-esp.conf" \ + /usr/share/sirius/repart.d/10-esp.conf + install -Dm644 \ + "$payload/usr/share/sirius/repart.d/20-root.conf" \ + /usr/share/sirius/repart.d/20-root.conf + + if test -f \ + "$payload/usr/share/locale/pt_BR/LC_MESSAGES/sirius.mo"; then + install -Dm644 \ + "$payload/usr/share/locale/pt_BR/LC_MESSAGES/sirius.mo" \ + /usr/share/locale/pt_BR/LC_MESSAGES/sirius.mo + fi + + install -d -m755 /etc/sirius + cat >/etc/sirius/distro.toml <<'EOF' + [bootc] + image = "docker://ghcr.io/luminusos/luminusos-workstation:44" + target_imgref = "ghcr.io/luminusos/luminusos-workstation:44" + enforce_sigpolicy = false + kargs = ["console=tty0", "console=ttyS0,115200n8"] + + [disk] + repart_dir = "/usr/share/sirius/repart.d" + + [branding] + name = "LuminusOS" + icon = "system-software-install-symbolic" + welcome_button = "Install {name}" + welcome_banner = "/usr/share/sirius/welcome-banner.png" + EOF + + cat >/etc/profile.d/sirius-vagrant.sh <<'EOF' + export SIRIUS_TEST_DISK=/dev/disk/by-id/virtio-SIRIUS_TARGET + EOF + + # Keep the graphical test runner immediately usable after every boot. + # These settings are intentionally limited to the disposable Vagrant VM. + sed -i \ + -e '/^AutomaticLoginEnable=/d' \ + -e '/^AutomaticLogin=/d' \ + /etc/gdm/custom.conf + sed -i '/^[[]daemon[]]$/a AutomaticLogin=vagrant' /etc/gdm/custom.conf + sed -i '/^[[]daemon[]]$/a AutomaticLoginEnable=True' /etc/gdm/custom.conf + + install -d -o vagrant -g vagrant -m700 /home/vagrant/.config + touch /home/vagrant/.config/gnome-initial-setup-done + chown vagrant:vagrant /home/vagrant/.config/gnome-initial-setup-done + + # dconf database names become D-Bus object path components, so they must not + # contain hyphens. Clean up the name used by earlier lab revisions. + install -d -m755 /etc/dconf/profile + touch /etc/dconf/profile/user + sed -i '/^system-db:sirius-vagrant$/d' /etc/dconf/profile/user + rm -f \ + /etc/dconf/db/sirius-vagrant \ + /etc/dconf/db/sirius-vagrant.d/00-session \ + /etc/dconf/db/sirius-vagrant.d/locks/00-session + rmdir \ + /etc/dconf/db/sirius-vagrant.d/locks \ + /etc/dconf/db/sirius-vagrant.d \ + >/dev/null 2>&1 || true + + install -d -m755 \ + /etc/dconf/db/sirius_vagrant.d \ + /etc/dconf/db/sirius_vagrant.d/locks + grep -qxF 'user-db:user' /etc/dconf/profile/user || + sed -i '1i user-db:user' /etc/dconf/profile/user + grep -qxF 'system-db:sirius_vagrant' /etc/dconf/profile/user || + printf '%s\n' 'system-db:sirius_vagrant' >>/etc/dconf/profile/user + + shell_version="$(gnome-shell --version | awk '{print $NF}')" + cat >/etc/dconf/db/sirius_vagrant.d/00-session </etc/dconf/db/sirius_vagrant.d/locks/00-session <<'EOF' + /org/gnome/desktop/lockdown/disable-lock-screen + /org/gnome/desktop/screensaver/lock-enabled + /org/gnome/desktop/screensaver/idle-activation-enabled + /org/gnome/desktop/session/idle-delay + /org/gnome/settings-daemon/plugins/power/sleep-inactive-ac-type + /org/gnome/settings-daemon/plugins/power/sleep-inactive-ac-timeout + /org/gnome/settings-daemon/plugins/power/sleep-inactive-battery-type + /org/gnome/settings-daemon/plugins/power/sleep-inactive-battery-timeout + /org/gnome/shell/welcome-dialog-last-shown-version + EOF + dconf update + + update-desktop-database /usr/share/applications >/dev/null 2>&1 || true + systemctl try-restart polkit.service >/dev/null 2>&1 || true + echo "Sirius is installed. GNOME logs in automatically without its tour or screen lock." +SHELL + +wifi_setup = <<~SHELL + set -euo pipefail + + if ! rpm -q hostapd iw >/dev/null 2>&1; then + rpm-ostree install hostapd iw + echo "Wi-Fi tools staged. Run: vagrant reload, then provision wifi again." + exit 0 + fi + + modprobe mac80211_hwsim radios=3 + install -d -m700 /etc/sirius-vagrant/wifi + + cat >/etc/sirius-vagrant/wifi/open.conf <<'EOF' + interface=wlan0 + driver=nl80211 + ssid=Sirius Open + hw_mode=g + channel=1 + EOF + + cat >/etc/sirius-vagrant/wifi/wpa2.conf <<'EOF' + interface=wlan1 + driver=nl80211 + ssid=Sirius WPA2 + hw_mode=g + channel=6 + wpa=2 + wpa_key_mgmt=WPA-PSK + wpa_passphrase=sirius-test + rsn_pairwise=CCMP + EOF + + cat >/etc/sirius-vagrant/wifi/wpa3.conf <<'EOF' + interface=wlan2 + driver=nl80211 + ssid=Sirius WPA3 + hw_mode=g + channel=11 + wpa=2 + wpa_key_mgmt=SAE + sae_password=sirius-test + ieee80211w=2 + rsn_pairwise=CCMP + EOF + + for network in open wpa2 wpa3; do + systemctl stop "sirius-hostapd-${network}.service" >/dev/null 2>&1 || true + systemd-run --unit="sirius-hostapd-${network}" \ + /usr/sbin/hostapd "/etc/sirius-vagrant/wifi/${network}.conf" + done +SHELL + +Vagrant.configure("2") do |config| + config.vm.box = "gnome-shell-box/silverblue44" + config.vm.box_version = "2026.7.0" + config.vm.hostname = "sirius-vagrant" + config.vm.boot_timeout = 900 + + # Vagrant builds on the Fedora 44 host, uploads only the runtime payload, and + # installs it into a disposable writable Silverblue /usr overlay. + config.trigger.before [:up, :reload, :provision] do |trigger| + trigger.name = "Build Sirius" + trigger.info = "Building Sirius and staging the VM payload..." + trigger.run = { + inline: "bash -lc #{Shellwords.escape(stage_payload)}", + } + end + + config.vm.synced_folder ".", "/vagrant", disabled: true + + config.vm.provision "file", + source: PAYLOAD, + destination: GUEST_PAYLOAD + + config.vm.provision "shell", + name: "sirius", + run: "always", + privileged: true, + inline: install_sirius + + # Optional and intentionally explicit because the first invocation layers + # packages into Silverblue and therefore needs a reboot. + config.vm.provision "shell", + name: "wifi", + run: "never", + privileged: true, + inline: wifi_setup + + config.vm.provider :libvirt do |libvirt| + libvirt.cpus = 4 + libvirt.memory = profile == "low-ram" ? 1536 : 8192 + libvirt.machine_type = "q35" + libvirt.graphics_type = "spice" + libvirt.video_type = "virtio" + libvirt.video_accel3d = true + libvirt.video_vram = 128 + libvirt.qemu_use_agent = true + # vagrant-libvirt only auto-creates a missing pool when it is named + # "default". Point that provider-managed pool at the project-local state + # directory instead of requiring users to define a libvirt pool manually. + libvirt.storage_pool_name = "default" + libvirt.storage_pool_path = DISK_POOL + libvirt.disk_driver cache: "writeback", discard: "unmap" + libvirt.loader = firmware_code + libvirt.nvram = firmware_vars + + unless profile == "no-tpm" + libvirt.tpm_model = "tpm-crb" + libvirt.tpm_type = "emulator" + libvirt.tpm_version = "2.0" + end + + unless disk_profile == "none" + size = disk_profile == "small" ? "8G" : "64G" + libvirt.storage :file, + path: "target.qcow2", + size: size, + type: "qcow2", + bus: "virtio", + cache: "writeback", + discard: "unmap", + serial: "SIRIUS_TARGET" + end + + if disk_profile == "multiple" + libvirt.storage :file, + path: "extra.qcow2", + size: "48G", + type: "qcow2", + bus: "sata", + cache: "writeback", + discard: "unmap", + serial: "SIRIUS_EXTRA" + end + end +end diff --git a/crates/sirius-app/Cargo.toml b/crates/sirius-app/Cargo.toml new file mode 100644 index 0000000..9a1f427 --- /dev/null +++ b/crates/sirius-app/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sirius-app" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Relm4, GTK4 and Libadwaita frontend for Sirius" + +[dependencies] +sirius-core = { workspace = true } +sirius-backend = { workspace = true } +sirius-diag = { workspace = true } +relm4 = { workspace = true } +gettext-rs = { workspace = true } +adw = { workspace = true } +libgweather = { workspace = true } +glycin = { workspace = true } +async-io = { workspace = true } +futures-channel = "0.3" +# glycin 3.x takes its loader input from gio 0.22 while the UI stack is on +# gtk-rs 0.10 (gio 0.21); the alias keeps both in the tree without mixing types. +glycin-gio = { package = "gio", version = "0.22" } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tracing-subscriber = { workspace = true } diff --git a/crates/sirius-installer/src/app.rs b/crates/sirius-app/src/app.rs similarity index 76% rename from crates/sirius-installer/src/app.rs rename to crates/sirius-app/src/app.rs index 8d453b7..43c8a9e 100644 --- a/crates/sirius-installer/src/app.rs +++ b/crates/sirius-app/src/app.rs @@ -1,19 +1,17 @@ -//! Root wizard component. Owns the window, the navigation stack, and InstallConfig. +//! Root wizard component. Owns the window and coordinates the wizard. +mod bootstrap; +mod install_task; mod pages; mod state; -use self::pages::{IMPLEMENTED_PAGES, PageControllers}; +use self::pages::PageControllers; use self::state::{StateEffect, WizardState}; use crate::pages::PageOutput; -use crate::pages::diagnostics::DiagnosticsInit; use crate::pages::progress::ProgressMsg; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; -use sirius_diag::config::CONFIG_PATH; -use sirius_diag::{SiriusConfig, SystemFacts, is_blocked, run_all_checks_with_config}; -use std::path::Path; pub struct AppModel { state: WizardState, @@ -32,7 +30,7 @@ pub enum AppMsg { /// User confirmed the erase-and-install dialog: advance past summary and start. ConfirmInstall, StartInstall, - Progress(crate::backend::Progress), + Progress(sirius_core::Progress), } #[relm4::component(pub)] @@ -50,6 +48,8 @@ impl SimpleComponent for AppModel { #[wrap(Some)] set_content = &adw::ToolbarView { add_top_bar = &adw::HeaderBar { + set_show_end_title_buttons: false, + pack_start = >k::Button { set_icon_name: "utilities-terminal-symbolic", add_css_class: "flat", @@ -95,7 +95,10 @@ impl SimpleComponent for AppModel { set_valign: gtk::Align::Center, set_margin_end: 20, #[watch] - set_visible: !matches!(model.state.current_page(), "summary" | "progress" | "finished"), + set_visible: !matches!( + model.state.current_page(), + "welcome" | "summary" | "progress" | "finished" + ), #[watch] set_sensitive: model.state.can_proceed(), connect_clicked => AppMsg::Next, @@ -111,46 +114,21 @@ impl SimpleComponent for AppModel { sender: ComponentSender, ) -> ComponentParts { crate::style::load(); - let (cfg, warning) = SiriusConfig::load_or_default(Path::new(CONFIG_PATH)); - if let Some(w) = warning { - tracing::warn!("{w}"); - } - let has_wifi = crate::backend::network::has_wifi_device(); - let pages: Vec = cfg - .pages - .resolve() - .into_iter() - .filter(|p| IMPLEMENTED_PAGES.contains(&p.as_str())) - .filter(|p| p != "network" || has_wifi) - .collect(); - let pages_order = pages.clone(); - let diag_config = cfg.diagnostics.clone(); - - let diagnostics_blocked = { - let facts = SystemFacts::gather(); - let checks = run_all_checks_with_config(&facts, &cfg.diagnostics); - is_blocked(&checks, &cfg.diagnostics.require) - }; - - // Distro branding + link cards; absence is fine (star icon, no cards). - let (bentos, branding) = crate::backend::distro::DistroDescriptor::load() - .map(|d| (d.bentos, d.branding)) - .unwrap_or_default(); + let bootstrap = bootstrap::load(); + let pages_order = bootstrap.page_ids.clone(); let page_controllers = PageControllers::launch( &sender, pages_order.clone(), - DiagnosticsInit { - config: diag_config, - }, - bentos, - branding, + bootstrap.diagnostics, + bootstrap.bentos, + bootstrap.branding, ); let state = WizardState::new( - pages, - diagnostics_blocked, - std::path::Path::new("/sys/firmware/efi").exists(), + bootstrap.page_ids, + bootstrap.diagnostics_blocked, + bootstrap.uefi, ); let mut model = AppModel { state, @@ -244,34 +222,17 @@ impl SimpleComponent for AppModel { } AppMsg::StartInstall => { self.pages.progress(ProgressMsg::Start); - // The distro descriptor (image, repart layout) is loaded by the - // privileged runner itself; the request carries only user choices. - match crate::backend::adapter::build_request(self.state.config()) { - Ok(req) => { - let exe = std::env::current_exe() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|_| "/usr/bin/sirius".into()); - let s = sender.clone(); - std::thread::spawn(move || { - let result = crate::backend::spawn::run_install(&req, &exe, |p| { - s.input(AppMsg::Progress(p)); - }); - if let Err(e) = result { - s.input(AppMsg::Progress(crate::backend::Progress::Error { - message: format!("failed to launch installer: {e}"), - })); - } - }); - } - Err(e) => { - self.pages.progress(ProgressMsg::Failed { - message: format!("cannot start install: {e}"), - }); - } + let progress_sender = sender.clone(); + if let Err(error) = install_task::start(self.state.config(), move |progress| { + progress_sender.input(AppMsg::Progress(progress)); + }) { + self.pages.progress(ProgressMsg::Failed { + message: format!("cannot start install: {error}"), + }); } } AppMsg::Progress(p) => { - use crate::backend::Progress; + use sirius_core::Progress; match p { Progress::Step { fraction, message } => { self.pages.progress(ProgressMsg::Update { @@ -305,16 +266,15 @@ impl AppModel { /// Modal "this will erase the disk" gate before leaving the summary page. fn confirm_install(&self, sender: &ComponentSender) { let config = self.state.config(); - let mut body = gettext( - if matches!( - config.install_type, - Some(crate::config_model::InstallType::Manual) - ) { - "The staged partition changes will now be written to disk and Sirius will be installed. Formatted or deleted data cannot be recovered." - } else { - "All data on the selected disk will be permanently erased and the system will be installed. This cannot be undone." - }, - ); + let mut body = if matches!(config.install_type, Some(sirius_core::InstallType::Manual)) { + gettext( + "The staged partition changes will now be written to disk and Sirius will be installed. Formatted or deleted data cannot be recovered.", + ) + } else { + gettext( + "All data on the selected disk will be permanently erased and the system will be installed. This cannot be undone.", + ) + }; if let Some(disk) = &config.destination_disk { let disk = config .destination_disk_name diff --git a/crates/sirius-app/src/app/bootstrap.rs b/crates/sirius-app/src/app/bootstrap.rs new file mode 100644 index 0000000..83300ec --- /dev/null +++ b/crates/sirius-app/src/app/bootstrap.rs @@ -0,0 +1,54 @@ +//! Load and resolve everything needed to construct the wizard. + +use super::pages::IMPLEMENTED_PAGES; +use crate::pages::diagnostics::DiagnosticsInit; +use sirius_core::{Bento, Branding}; +use sirius_diag::config::CONFIG_PATH; +use sirius_diag::{SiriusConfig, SystemFacts, is_blocked, run_all_checks_with_config}; +use std::path::Path; + +pub(super) struct Bootstrap { + pub page_ids: Vec, + pub diagnostics: DiagnosticsInit, + pub diagnostics_blocked: bool, + pub uefi: bool, + pub bentos: Vec, + pub branding: Branding, +} + +pub(super) fn load() -> Bootstrap { + let (config, warning) = SiriusConfig::load_or_default(Path::new(CONFIG_PATH)); + if let Some(warning) = warning { + tracing::warn!("{warning}"); + } + + let has_wifi = sirius_backend::network::has_wifi_device(); + let page_ids = config + .pages + .resolve() + .into_iter() + .filter(|page| IMPLEMENTED_PAGES.contains(&page.as_str())) + .filter(|page| page != "network" || has_wifi) + .collect(); + + let facts = SystemFacts::gather(); + let checks = run_all_checks_with_config(&facts, &config.diagnostics); + let diagnostics_blocked = is_blocked(&checks, &config.diagnostics.require); + + // Branding and link cards are optional. Their absence keeps the generic + // Sirius icon and an empty progress-card area. + let (bentos, branding) = sirius_backend::distro::load() + .map(|descriptor| (descriptor.bentos, descriptor.branding)) + .unwrap_or_default(); + + Bootstrap { + page_ids, + diagnostics: DiagnosticsInit { + config: config.diagnostics, + }, + diagnostics_blocked, + uefi: Path::new("/sys/firmware/efi").exists(), + bentos, + branding, + } +} diff --git a/crates/sirius-app/src/app/install_task.rs b/crates/sirius-app/src/app/install_task.rs new file mode 100644 index 0000000..026cce0 --- /dev/null +++ b/crates/sirius-app/src/app/install_task.rs @@ -0,0 +1,26 @@ +//! Background bridge between the wizard and the privileged backend. + +use sirius_core::{InstallConfig, Progress}; + +pub(super) fn start( + config: &InstallConfig, + mut on_progress: impl FnMut(Progress) + Send + 'static, +) -> Result<(), String> { + // The request contains only user choices. The privileged runner loads the + // image and repart layout from its root-owned descriptor. + let request = sirius_backend::install::build_request(config)?; + let executable = std::env::current_exe() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "/usr/bin/sirius".into()); + + std::thread::spawn(move || { + if let Err(error) = + sirius_backend::spawn::run_install(&request, &executable, &mut on_progress) + { + on_progress(Progress::Error { + message: format!("failed to launch installer: {error}"), + }); + } + }); + Ok(()) +} diff --git a/crates/sirius-installer/src/app/pages.rs b/crates/sirius-app/src/app/pages.rs similarity index 93% rename from crates/sirius-installer/src/app/pages.rs rename to crates/sirius-app/src/app/pages.rs index 4556c1f..2da1290 100644 --- a/crates/sirius-installer/src/app/pages.rs +++ b/crates/sirius-app/src/app/pages.rs @@ -1,11 +1,10 @@ //! Lifecycle and message routing for wizard page controllers. use super::{AppModel, AppMsg}; -use crate::backend::distro::{Bento, Branding}; -use crate::config_model::InstallConfig; use crate::pages::diagnostics::{DiagnosticsInit, DiagnosticsMsg, DiagnosticsPage}; use crate::pages::finished::{FinishedMsg, FinishedPage}; use crate::pages::keyboard::{KeyboardMsg, KeyboardPage}; +use crate::pages::language::{LanguageMsg, LanguagePage}; use crate::pages::network::{NetworkMsg, NetworkPage}; use crate::pages::progress::{ProgressMsg, ProgressPage}; use crate::pages::storage::{StorageMsg, StoragePage}; @@ -16,6 +15,8 @@ use crate::pages::welcome::{WelcomeMsg, WelcomePage}; use relm4::gtk::prelude::*; use relm4::prelude::*; use relm4::{ComponentController, ComponentSender, Controller, gtk}; +use sirius_core::InstallConfig; +use sirius_core::{Bento, Branding}; /// One row per wizard page: `id: PageType [MsgType]`. The single list /// generates the controller struct, the id → widget lookup, the retranslate @@ -52,6 +53,7 @@ macro_rules! wizard_pages { wizard_pages! { welcome: WelcomePage [WelcomeMsg], + language: LanguagePage [LanguageMsg], diagnostics: DiagnosticsPage [DiagnosticsMsg], network: NetworkPage [NetworkMsg], keyboard: KeyboardPage [KeyboardMsg], @@ -74,6 +76,9 @@ impl PageControllers { let output = sender.input_sender(); Self { welcome: WelcomePage::builder() + .launch(branding.clone()) + .forward(output, AppMsg::Page), + language: LanguagePage::builder() .launch(branding) .forward(output, AppMsg::Page), diagnostics: DiagnosticsPage::builder() diff --git a/crates/sirius-installer/src/app/state.rs b/crates/sirius-app/src/app/state.rs similarity index 94% rename from crates/sirius-installer/src/app/state.rs rename to crates/sirius-app/src/app/state.rs index 3354792..72d77d4 100644 --- a/crates/sirius-installer/src/app/state.rs +++ b/crates/sirius-app/src/app/state.rs @@ -4,9 +4,9 @@ //! choices; `WizardState` owns navigation and decides whether the current page //! may advance. -use crate::config_model::{InstallConfig, InstallType}; use crate::navigator::Navigator; use crate::pages::{PageOutput, StorageSelection}; +use sirius_core::{InstallConfig, InstallType}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StateEffect { @@ -22,7 +22,7 @@ pub struct WizardState { diagnostics_blocked: bool, uefi: bool, /// UI locale currently fed to gettext via LANGUAGE. Starts empty so the - /// welcome page's initial SetLocale pins LANGUAGE (and thus the UI) to its + /// language page's initial SetLocale pins LANGUAGE (and thus the UI) to its /// default selection instead of whatever the environment advertises. ui_locale: String, } @@ -89,10 +89,7 @@ impl WizardState { self.config.locale = Some(locale.clone()); if changed { self.ui_locale = locale.clone(); - // glibc gettext consults LANGUAGE on every lookup, so setting - // it here plus re-rendering the pages switches the UI language. - // SAFETY: single-threaded GTK main loop; no concurrent env reads. - unsafe { std::env::set_var("LANGUAGE", locale) }; + crate::set_ui_language(&locale); StateEffect::LanguageChanged } else { StateEffect::None @@ -153,7 +150,7 @@ impl WizardState { #[cfg(test)] mod tests { use super::*; - use crate::config_model::{ + use sirius_core::{ MountAssignment, PartitionOperation, PartitionPlan, PartitionRef, UserAccount, }; diff --git a/crates/sirius-app/src/i18n.rs b/crates/sirius-app/src/i18n.rs new file mode 100644 index 0000000..092e8df --- /dev/null +++ b/crates/sirius-app/src/i18n.rs @@ -0,0 +1,22 @@ +//! Runtime language selection for the graphical application. + +use std::ffi::c_int; + +unsafe extern "C" { + #[link_name = "_nl_msg_cat_cntr"] + static mut GETTEXT_CATALOG_GENERATION: c_int; +} + +/// Select the language used by subsequent GNU gettext lookups. +/// +/// GNU gettext caches the last loaded catalog. Updating `LANGUAGE` alone can +/// therefore leave the previous translation active; advancing its catalog +/// generation makes the new preference visible immediately. +pub fn set_ui_language(locale: &str) { + // SAFETY: Sirius changes the process-wide gettext language only from the + // single-threaded GTK main loop, before installation workers are started. + unsafe { + std::env::set_var("LANGUAGE", locale); + GETTEXT_CATALOG_GENERATION = GETTEXT_CATALOG_GENERATION.wrapping_add(1); + } +} diff --git a/crates/sirius-app/src/lib.rs b/crates/sirius-app/src/lib.rs new file mode 100644 index 0000000..f711a59 --- /dev/null +++ b/crates/sirius-app/src/lib.rs @@ -0,0 +1,21 @@ +//! Sirius graphical application. +//! +//! This crate owns only presentation and wizard interaction. Hardware and +//! installation side effects are delegated to `sirius-diag` and +//! `sirius-backend`. + +mod app; +mod i18n; +mod navigator; +mod pages; +mod style; + +use relm4::RelmApp; + +pub use i18n::set_ui_language; + +/// Launch the GTK4/Libadwaita installer wizard. +pub fn run() { + let app = RelmApp::new("io.sirius.Installer"); + app.run::(()); +} diff --git a/crates/sirius-installer/src/navigator.rs b/crates/sirius-app/src/navigator.rs similarity index 100% rename from crates/sirius-installer/src/navigator.rs rename to crates/sirius-app/src/navigator.rs diff --git a/crates/sirius-app/src/pages/choice_list.rs b/crates/sirius-app/src/pages/choice_list.rs new file mode 100644 index 0000000..fe60c8d --- /dev/null +++ b/crates/sirius-app/src/pages/choice_list.rs @@ -0,0 +1,16 @@ +//! Shared selected-state marker for open, single-choice lists. + +use relm4::adw::prelude::*; +use relm4::gtk; + +/// A quiet selected-state marker for rows which are themselves clickable. +/// +/// Opacity keeps every row's trailing space identical while avoiding the +/// form-control appearance of a radio button. +pub(crate) fn selected_indicator(selected: bool) -> gtk::Image { + let indicator = gtk::Image::from_icon_name("object-select-symbolic"); + indicator.set_valign(gtk::Align::Center); + indicator.set_opacity(if selected { 1.0 } else { 0.0 }); + indicator.set_can_target(false); + indicator +} diff --git a/crates/sirius-installer/src/pages/diagnostics.rs b/crates/sirius-app/src/pages/diagnostics.rs similarity index 74% rename from crates/sirius-installer/src/pages/diagnostics.rs rename to crates/sirius-app/src/pages/diagnostics.rs index b009568..ea8c345 100644 --- a/crates/sirius-installer/src/pages/diagnostics.rs +++ b/crates/sirius-app/src/pages/diagnostics.rs @@ -15,7 +15,9 @@ pub struct DiagnosticsInit { pub config: DiagnosticsConfig, } -pub struct DiagnosticsPage {} +pub struct DiagnosticsPage { + config: DiagnosticsConfig, +} #[derive(Debug)] pub enum DiagnosticsMsg { @@ -46,30 +48,9 @@ impl SimpleComponent for DiagnosticsPage { ) -> ComponentParts { apply_header(&root); - let group = adw::PreferencesGroup::new(); - - // The root computes gating once; this page only renders the report. - let facts = SystemFacts::gather(); - let checks = run_all_checks_with_config(&facts, &init.config); - - for c in &checks { - let row = adw::ActionRow::new(); - row.set_title(&c.label); - row.set_subtitle(&c.detail); - let (icon, css) = match c.status { - Status::Pass => ("object-select-symbolic", "success"), - Status::Warn => ("dialog-warning-symbolic", "warning"), - Status::Fail => ("dialog-error-symbolic", "error"), - }; - let img = gtk::Image::from_icon_name(icon); - img.add_css_class(css); - row.add_suffix(&img); - group.add(&row); - } - - root.set_child(Some(&group)); - - let model = DiagnosticsPage {}; + let model = DiagnosticsPage { + config: init.config, + }; let widgets = DiagnosticsPageWidgets { root }; ComponentParts { model, widgets } @@ -83,6 +64,28 @@ impl SimpleComponent for DiagnosticsPage { fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender) { apply_header(&widgets.root); + + // Check labels and details are translated when the report is built. + // Rebuild it after a runtime language change instead of retaining the + // English strings gathered before the language page was shown. + let group = adw::PreferencesGroup::new(); + let facts = SystemFacts::gather(); + let checks = run_all_checks_with_config(&facts, &self.config); + for check in &checks { + let row = adw::ActionRow::new(); + row.set_title(&check.label); + row.set_subtitle(&check.detail); + let (icon, css) = match check.status { + Status::Pass => ("object-select-symbolic", "success"), + Status::Warn => ("dialog-warning-symbolic", "warning"), + Status::Fail => ("dialog-error-symbolic", "error"), + }; + let image = gtk::Image::from_icon_name(icon); + image.add_css_class(css); + row.add_suffix(&image); + group.add(&row); + } + widgets.root.set_child(Some(&group)); } } diff --git a/crates/sirius-installer/src/pages/finished.rs b/crates/sirius-app/src/pages/finished.rs similarity index 82% rename from crates/sirius-installer/src/pages/finished.rs rename to crates/sirius-app/src/pages/finished.rs index 99332c5..bd67788 100644 --- a/crates/sirius-installer/src/pages/finished.rs +++ b/crates/sirius-app/src/pages/finished.rs @@ -55,14 +55,8 @@ impl SimpleComponent for FinishedPage { fn update(&mut self, msg: Self::Input, _sender: ComponentSender) { match msg { FinishedMsg::Reboot => { - // Reboot the machine. On a dev box without privileges this will fail; log and - // leave the user to reboot manually. - match std::process::Command::new("systemctl") - .arg("reboot") - .status() - { - Ok(_) => {} - Err(e) => tracing::error!("reboot failed: {e}"), + if let Err(error) = sirius_backend::system::reboot() { + tracing::error!("reboot failed: {error}"); } } FinishedMsg::Retranslate => {} diff --git a/crates/sirius-app/src/pages/keyboard.rs b/crates/sirius-app/src/pages/keyboard.rs new file mode 100644 index 0000000..4efb85d --- /dev/null +++ b/crates/sirius-app/src/pages/keyboard.rs @@ -0,0 +1,345 @@ +//! Keyboard chooser mirroring GNOME Initial Setup's `CcInputChooser`: a +//! search entry above a boxed list with the locale's layouts pinned, a +//! "More…" row revealing every other XKB layout, and a checkmark on the +//! active one. Re-activating the selected row confirms the choice and moves +//! on, like `confirm_choice`. Layout data comes from GNOME Desktop (see +//! `xkb`). + +mod xkb; + +use self::xkb::Layout; +use super::PageOutput; +use gettextrs::gettext; +use relm4::adw::prelude::*; +use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; + +pub struct KeyboardPage { + root: adw::StatusPage, + heading: gtk::Label, + search: gtk::SearchEntry, + list: gtk::ListBox, + no_results: gtk::Label, + test_entry: gtk::Entry, + input_settings: gtk::gio::Settings, + layouts: Vec, + /// Locale-relevant layout ids shown before the "More…" row is expanded. + initial_ids: Vec, + /// Layout indices currently shown as rows, in row order. + visible: Vec, + /// Whether a "More…" row is appended after the visible layouts. + more_row: bool, + selected: usize, + showing_extra: bool, + user_selected: bool, +} + +#[derive(Debug)] +pub enum KeyboardMsg { + SearchChanged(String), + RowActivated(usize), + Retranslate, +} + +pub struct KeyboardPageWidgets; + +impl SimpleComponent for KeyboardPage { + type Init = (); + type Input = KeyboardMsg; + type Output = PageOutput; + type Root = adw::StatusPage; + type Widgets = KeyboardPageWidgets; + + fn init_root() -> Self::Root { + adw::StatusPage::new() + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + apply_header(&root); + let layouts = xkb::load(); + let initial_ids = xkb::initial_layout_ids(¤t_locale()); + let selected = recommended_layout(&layouts, &initial_ids); + let (content, heading, search, list, no_results, test_entry) = keyboard_content(&sender); + root.set_child(Some(&content)); + + let mut model = KeyboardPage { + root: root.clone(), + heading, + search, + list, + no_results, + test_entry, + input_settings: gtk::gio::Settings::new("org.gnome.desktop.input-sources"), + layouts, + initial_ids, + visible: Vec::new(), + more_row: false, + selected, + showing_extra: false, + user_selected: false, + }; + model.rebuild_list(""); + model.emit_selection(&sender); + + ComponentParts { + model, + widgets: KeyboardPageWidgets, + } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender) { + match msg { + KeyboardMsg::SearchChanged(query) => self.rebuild_list(&query), + KeyboardMsg::RowActivated(index) => { + if self.more_row && index == self.visible.len() { + self.showing_extra = true; + self.rebuild_list(&self.search.text()); + } else if let Some(layout_index) = self.visible.get(index).copied() { + if layout_index == self.selected { + // Re-activating the selected row confirms the choice + // (CcInputChooser::confirm_choice). + sender.output(PageOutput::RequestNext).ok(); + } else { + self.selected = layout_index; + self.user_selected = true; + self.emit_selection(&sender); + self.rebuild_list(&self.search.text()); + self.test_entry.grab_focus(); + } + } + } + KeyboardMsg::Retranslate => { + apply_header(&self.root); + self.heading + .set_label(&gettext("Select your keyboard layout")); + self.search + .set_placeholder_text(Some(&gettext("Search keyboard layouts"))); + self.no_results.set_label(&gettext("No inputs found")); + self.test_entry + .set_placeholder_text(Some(&gettext("Type here to test your layout"))); + + let selected_id = self.layouts[self.selected].id.clone(); + self.layouts = xkb::load(); + self.initial_ids = xkb::initial_layout_ids(¤t_locale()); + self.selected = if self.user_selected { + self.layouts + .iter() + .position(|layout| layout.id == selected_id) + .unwrap_or_else(|| recommended_layout(&self.layouts, &self.initial_ids)) + } else { + recommended_layout(&self.layouts, &self.initial_ids) + }; + self.rebuild_list(&self.search.text()); + self.emit_selection(&sender); + } + } + } +} + +impl KeyboardPage { + fn is_initial(&self, index: usize) -> bool { + self.initial_ids.contains(&self.layouts[index].id) + } + + fn rebuild_list(&mut self, query: &str) { + while let Some(child) = self.list.first_child() { + self.list.remove(&child); + } + + let query = xkb::normalize(query); + let searching = !query.is_empty(); + // Mirror sort_inputs: locale-relevant layouts first, then the extras, + // each group alphabetical (layouts are loaded pre-sorted by name). + let mut order: Vec = (0..self.layouts.len()).collect(); + order.sort_by_key(|index| !self.is_initial(*index)); + + self.visible.clear(); + self.more_row = false; + for index in order { + let layout = &self.layouts[index]; + let show = if searching { + layout.search_text.contains(&query) + } else { + self.is_initial(index) || self.showing_extra + }; + if !show { + continue; + } + + let row = adw::ActionRow::new(); + row.set_title(&layout.name); + row.set_activatable(true); + row.add_suffix(&super::choice_list::selected_indicator( + index == self.selected, + )); + self.list.append(&row); + self.visible.push(index); + } + + let has_extra = self + .layouts + .iter() + .enumerate() + .any(|(index, _)| !self.is_initial(index)); + if !searching && !self.showing_extra && has_extra { + let arrow = gtk::Image::from_icon_name("view-more-symbolic"); + arrow.add_css_class("dim-label"); + arrow.set_hexpand(true); + arrow.set_halign(gtk::Align::Center); + arrow.set_margin_top(12); + arrow.set_margin_bottom(12); + let row = gtk::ListBoxRow::new(); + row.set_activatable(true); + row.set_tooltip_text(Some(&gettext("More…"))); + row.set_child(Some(&arrow)); + self.list.append(&row); + self.more_row = true; + } + } + + fn emit_selection(&self, sender: &ComponentSender) { + let id = self.layouts[self.selected].id.clone(); + // Match GNOME Initial Setup: update the live session source as soon as + // it is selected so the test field really uses that layout. + let _ = self + .input_settings + .set("sources", vec![("xkb".to_string(), id.clone())]); + let _ = self.input_settings.set_uint("current", 0); + sender.output(PageOutput::SetKeyboard(id)).ok(); + } +} + +fn keyboard_content( + sender: &ComponentSender, +) -> ( + gtk::Box, + gtk::Label, + gtk::SearchEntry, + gtk::ListBox, + gtk::Label, + gtk::Entry, +) { + let content = gtk::Box::new(gtk::Orientation::Horizontal, 72); + content.set_width_request(760); + content.set_halign(gtk::Align::Center); + content.set_valign(gtk::Align::Center); + + let illustration = gtk::Image::from_icon_name("input-keyboard-symbolic"); + illustration.set_pixel_size(160); + illustration.set_width_request(260); + content.append(&illustration); + + let choices = gtk::Box::new(gtk::Orientation::Vertical, 12); + choices.set_hexpand(true); + let heading = gtk::Label::new(Some(&gettext("Select your keyboard layout"))); + heading.add_css_class("title-2"); + heading.set_halign(gtk::Align::Start); + choices.append(&heading); + + let search = gtk::SearchEntry::new(); + search.set_placeholder_text(Some(&gettext("Search keyboard layouts"))); + { + let sender = sender.clone(); + search.connect_search_changed(move |entry| { + sender.input(KeyboardMsg::SearchChanged(entry.text().to_string())); + }); + } + choices.append(&search); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::None); + list.add_css_class("boxed-list"); + list.set_valign(gtk::Align::Start); + let no_results = gtk::Label::new(Some(&gettext("No inputs found"))); + no_results.add_css_class("dim-label"); + no_results.set_margin_top(12); + no_results.set_margin_bottom(12); + no_results.set_sensitive(false); + list.set_placeholder(Some(&no_results)); + { + let sender = sender.clone(); + list.connect_row_activated(move |_, row| { + sender.input(KeyboardMsg::RowActivated(row.index() as usize)); + }); + } + let scroll = gtk::ScrolledWindow::new(); + scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + scroll.set_min_content_height(190); + scroll.set_max_content_height(250); + scroll.set_child(Some(&list)); + choices.append(&scroll); + + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some(&gettext("Type here to test your layout"))); + choices.append(&entry); + content.append(&choices); + (content, heading, search, list, no_results, entry) +} + +fn apply_header(root: &adw::StatusPage) { + super::status_header( + root, + &gettext("Keyboard layout"), + &gettext("Select the keyboard layout you want to use."), + ); +} + +/// The current UI locale, from `LANGUAGE`'s first entry or `LANG`. +fn current_locale() -> String { + std::env::var("LANGUAGE") + .ok() + .and_then(|value| value.split(':').next().map(str::to_owned)) + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var("LANG").ok()) + .filter(|value| !value.is_empty() && value != "C" && value != "POSIX") + .unwrap_or_else(|| "en_US".into()) +} + +/// The locale's default layout when known, otherwise the first entry. +fn recommended_layout(layouts: &[Layout], initial_ids: &[String]) -> usize { + initial_ids + .iter() + .find_map(|id| layouts.iter().position(|layout| &layout.id == id)) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn portuguese_recommends_brazilian_xkb() { + let layouts = xkb::load(); + let initial_ids = xkb::initial_layout_ids("pt_BR"); + assert!(initial_ids.contains(&"br".to_string())); + assert_eq!(layouts[recommended_layout(&layouts, &initial_ids)].id, "br"); + } + + #[test] + fn initial_ids_cover_the_locale_language_and_country() { + let initial_ids = xkb::initial_layout_ids("pt_BR.UTF-8"); + assert!(initial_ids.contains(&"br".to_string())); + // Only a handful of rows should sit in front of the More… row. + assert!(initial_ids.len() < 20, "got {initial_ids:?}"); + } + + #[test] + fn variant_ids_remain_canonical() { + let layouts = xkb::load(); + let variant = layouts + .iter() + .find(|layout| layout.xkb_variant.is_some()) + .unwrap(); + assert_eq!( + variant.id, + format!( + "{}+{}", + variant.xkb_layout, + variant.xkb_variant.as_deref().unwrap() + ) + ); + } +} diff --git a/crates/sirius-app/src/pages/keyboard/xkb.rs b/crates/sirius-app/src/pages/keyboard/xkb.rs new file mode 100644 index 0000000..8f1786a --- /dev/null +++ b/crates/sirius-app/src/pages/keyboard/xkb.rs @@ -0,0 +1,307 @@ +//! Safe ownership boundary around GNOME Desktop's `GnomeXkbInfo`. +//! +//! GNOME Initial Setup uses this API for the keyboard chooser. Keeping the FFI +//! here lets the page consume the same localized layout names and canonical +//! `layout[+variant]` identifiers without leaking C ownership details into UI +//! code. + +use std::ffi::{CStr, CString, c_char}; +use std::ptr; + +use relm4::gtk; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Layout { + pub id: String, + pub name: String, + pub short_name: String, + pub xkb_layout: String, + pub xkb_variant: Option, + pub search_text: String, +} + +#[repr(C)] +struct GnomeXkbInfo { + _private: [u8; 0], +} + +#[link(name = "gnome-desktop-4")] +unsafe extern "C" { + fn gnome_xkb_info_new() -> *mut GnomeXkbInfo; + fn gnome_xkb_info_get_all_layouts(info: *mut GnomeXkbInfo) -> *mut gtk::glib::ffi::GList; + fn gnome_xkb_info_get_layout_info( + info: *mut GnomeXkbInfo, + id: *const c_char, + display_name: *mut *const c_char, + short_name: *mut *const c_char, + xkb_layout: *mut *const c_char, + xkb_variant: *mut *const c_char, + ) -> gtk::glib::ffi::gboolean; + fn gnome_xkb_info_get_layouts_for_language( + info: *mut GnomeXkbInfo, + language_code: *const c_char, + ) -> *mut gtk::glib::ffi::GList; + fn gnome_xkb_info_get_layouts_for_country( + info: *mut GnomeXkbInfo, + country_code: *const c_char, + ) -> *mut gtk::glib::ffi::GList; + fn gnome_get_input_source_from_locale( + locale: *const c_char, + source_type: *mut *const c_char, + id: *mut *const c_char, + ) -> gtk::glib::ffi::gboolean; +} + +/// Enumerate every XKB layout and variant known to GNOME Desktop. +pub(super) fn load() -> Vec { + // SAFETY: `GnomeXkbInfo` owns the strings referenced by the returned list. + // We copy every string before freeing the list and unref the object last. + unsafe { + let info = gnome_xkb_info_new(); + if info.is_null() { + return fallback(); + } + + let list = gnome_xkb_info_get_all_layouts(info); + let mut node = list; + let mut layouts = Vec::new(); + while !node.is_null() { + let id_pointer = (*node).data.cast::(); + if !id_pointer.is_null() + && let Some(layout) = layout_info(info, id_pointer) + { + layouts.push(layout); + } + node = (*node).next; + } + + gtk::glib::ffi::g_list_free(list); + gtk::glib::gobject_ffi::g_object_unref(info.cast::()); + + layouts.sort_by_cached_key(|layout| layout.name.to_lowercase()); + layouts.dedup_by(|a, b| a.id == b.id); + if layouts.is_empty() { + fallback() + } else { + layouts + } + } +} + +unsafe fn layout_info(info: *mut GnomeXkbInfo, id_pointer: *const c_char) -> Option { + let mut display_name = ptr::null(); + let mut short_name = ptr::null(); + let mut xkb_layout = ptr::null(); + let mut xkb_variant = ptr::null(); + + // SAFETY: all pointers are valid for the lifetime of `info`; output + // pointers are initialized by GNOME Desktop on success. + let found = unsafe { + gnome_xkb_info_get_layout_info( + info, + id_pointer, + &mut display_name, + &mut short_name, + &mut xkb_layout, + &mut xkb_variant, + ) + }; + if found == gtk::glib::ffi::GFALSE || display_name.is_null() || xkb_layout.is_null() { + return None; + } + + // SAFETY: GNOME Desktop returns NUL-terminated strings owned by `info`. + let id = unsafe { copy(id_pointer) }; + // SAFETY: checked for null above. + let name = unsafe { copy(display_name) }; + // SAFETY: optional pointers are copied only when non-null. + let short_name = unsafe { copy_optional(short_name) }.unwrap_or_default(); + // SAFETY: checked for null above. + let xkb_layout = unsafe { copy(xkb_layout) }; + // SAFETY: optional pointers are copied only when non-null. + let xkb_variant = unsafe { copy_optional(xkb_variant) }.filter(|value| !value.is_empty()); + let search_text = normalize(&format!( + "{name} {short_name} {id} {xkb_layout} {}", + xkb_variant.as_deref().unwrap_or_default() + )); + + Some(Layout { + id, + name, + short_name, + xkb_layout, + xkb_variant, + search_text, + }) +} + +unsafe fn copy(pointer: *const c_char) -> String { + // SAFETY: caller guarantees a valid NUL-terminated pointer. + unsafe { CStr::from_ptr(pointer) } + .to_string_lossy() + .into_owned() +} + +unsafe fn copy_optional(pointer: *const c_char) -> Option { + (!pointer.is_null()).then(|| { + // SAFETY: non-null pointers returned by GNOME Desktop are + // NUL-terminated. + unsafe { copy(pointer) } + }) +} + +/// Initial layout ids for a locale, mirroring `CcInputChooser`'s +/// `get_locale_infos`: the locale's default input source first, then the +/// layouts GNOME Desktop associates with its language and its country. These +/// are the rows visible before the chooser's "More…" row is expanded. +pub(super) fn initial_layout_ids(locale: &str) -> Vec { + // GNOME Desktop lazily initializes its iso-codes tables without locking, + // so first use must be serialized across threads. + let _guard = crate::pages::GNOME_DESKTOP_LOCK.lock().unwrap(); + // SAFETY: `GnomeXkbInfo` owns the strings referenced by the returned + // lists. We copy every id before freeing the lists and unref the object + // last; the input-source out pointers are borrowed and never freed. + unsafe { + let info = gnome_xkb_info_new(); + if info.is_null() { + return Vec::new(); + } + + let mut ids = Vec::new(); + if let Ok(c_locale) = CString::new(with_codeset(locale)) { + let mut source_type = ptr::null(); + let mut source_id = ptr::null(); + if gnome_get_input_source_from_locale( + c_locale.as_ptr(), + &mut source_type, + &mut source_id, + ) != gtk::glib::ffi::GFALSE + && !source_type.is_null() + && !source_id.is_null() + && copy(source_type) == "xkb" + { + ids.push(copy(source_id)); + } + } + + let (language, country) = split_locale(locale); + if let Some(language) = language + && let Ok(c_language) = CString::new(language) + { + collect_layout_ids( + gnome_xkb_info_get_layouts_for_language(info, c_language.as_ptr()), + &mut ids, + ); + } + if let Some(country) = country + && let Ok(c_country) = CString::new(country) + { + collect_layout_ids( + gnome_xkb_info_get_layouts_for_country(info, c_country.as_ptr()), + &mut ids, + ); + } + + gtk::glib::gobject_ffi::g_object_unref(info.cast::()); + ids + } +} + +/// Copy every layout id out of a GList owned by `GnomeXkbInfo`, deduplicating +/// while preserving order, and free the list. +/// +/// # SAFETY: `list` must be a GList of borrowed `const gchar*` layout ids. +unsafe fn collect_layout_ids(list: *mut gtk::glib::ffi::GList, ids: &mut Vec) { + // SAFETY: caller guarantees the list element type; elements are borrowed. + unsafe { + let mut node = list; + while !node.is_null() { + let id_pointer = (*node).data.cast::(); + if !id_pointer.is_null() { + let id = copy(id_pointer); + if !ids.contains(&id) { + ids.push(id); + } + } + node = (*node).next; + } + gtk::glib::ffi::g_list_free(list); + } +} + +/// `pt_BR.UTF-8` → (`pt`, `BR`); modifiers and codesets are dropped. +fn split_locale(locale: &str) -> (Option, Option) { + let base = locale.split(['.', '@']).next().unwrap_or(locale); + let mut parts = base.split('_'); + let language = parts.next().filter(|value| !value.is_empty()); + let country = parts.next().filter(|value| !value.is_empty()); + ( + language.map(str::to_owned), + country.map(|value| value.to_uppercase()), + ) +} + +/// `gnome_get_input_source_from_locale` expects full locale names. +fn with_codeset(locale: &str) -> String { + if locale.contains('.') { + locale.to_string() + } else { + format!("{locale}.UTF-8") + } +} + +pub(super) fn normalize(value: &str) -> String { + value + .to_lowercase() + .chars() + .map(|character| match character { + 'á' | 'à' | 'â' | 'ã' | 'ä' => 'a', + 'é' | 'è' | 'ê' | 'ë' => 'e', + 'í' | 'ì' | 'î' | 'ï' => 'i', + 'ó' | 'ò' | 'ô' | 'õ' | 'ö' => 'o', + 'ú' | 'ù' | 'û' | 'ü' => 'u', + 'ç' => 'c', + other => other, + }) + .collect::() + .replace(['_', '+', '-', '(', ')', ','], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn fallback() -> Vec { + [ + ("us", "English (US)", "en"), + ("gb", "English (UK)", "en"), + ("br", "Português (Brasil)", "pt"), + ] + .into_iter() + .map(|(id, name, short_name)| Layout { + id: id.into(), + name: name.into(), + short_name: short_name.into(), + xkb_layout: id.into(), + xkb_variant: None, + search_text: normalize(&format!("{id} {name} {short_name}")), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gnome_xkb_info_exposes_layouts_and_variants() { + let layouts = load(); + assert!(layouts.iter().any(|layout| layout.id == "us")); + assert!(layouts.iter().any(|layout| layout.id == "br")); + assert!(layouts.iter().any(|layout| layout.xkb_variant.is_some())); + } + + #[test] + fn search_is_case_and_accent_insensitive() { + assert_eq!(normalize("Português (Brasil)"), "portugues brasil"); + } +} diff --git a/crates/sirius-app/src/pages/language.rs b/crates/sirius-app/src/pages/language.rs new file mode 100644 index 0000000..8f3a177 --- /dev/null +++ b/crates/sirius-app/src/pages/language.rs @@ -0,0 +1,343 @@ +//! Language picker mirroring GNOME Initial Setup's `CcLanguageChooser`: a +//! search entry above a boxed list with the common languages pinned, a +//! "More…" row revealing every other locale, and a checkmark on the active +//! one. Locale data comes from GNOME Desktop (see `locale`). + +mod locale; + +use super::PageOutput; +use gettextrs::gettext; +use relm4::adw::prelude::*; +use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; +use sirius_core::Branding; +use std::path::Path; + +pub struct LanguagePage { + root: adw::StatusPage, + heading: gtk::Label, + search: gtk::SearchEntry, + list: gtk::ListBox, + no_results: gtk::Label, + locales: Vec, + /// Locale indices currently shown as rows, in row order. + visible: Vec, + /// Whether a "More…" row is appended after the visible locales. + more_row: bool, + selected: usize, + showing_extra: bool, +} + +#[derive(Debug)] +pub enum LanguageMsg { + SearchChanged(String), + RowActivated(usize), + Retranslate, +} + +pub struct LanguagePageWidgets; + +impl SimpleComponent for LanguagePage { + type Init = Branding; + type Input = LanguageMsg; + type Output = PageOutput; + type Root = adw::StatusPage; + type Widgets = LanguagePageWidgets; + + fn init_root() -> Self::Root { + adw::StatusPage::new() + } + + fn init( + branding: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + apply_header(&root); + + let locales = locale::load(); + let selected = locale::current_locale() + .and_then(|current| locales.iter().position(|entry| entry.id == current)) + .or_else(|| locales.iter().position(|entry| entry.id == "en_US")) + .unwrap_or(0); + + let (content, heading, search, list, no_results) = language_content(&branding, &sender); + root.set_child(Some(&content)); + + let mut model = LanguagePage { + root: root.clone(), + heading, + search, + list, + no_results, + locales, + visible: Vec::new(), + more_row: false, + selected, + showing_extra: false, + }; + model.rebuild_list(""); + model.emit_selection(&sender); + + ComponentParts { + model, + widgets: LanguagePageWidgets, + } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender) { + match msg { + LanguageMsg::SearchChanged(query) => self.rebuild_list(&query), + LanguageMsg::RowActivated(index) => { + if self.more_row && index == self.visible.len() { + self.showing_extra = true; + self.rebuild_list(&self.search.text()); + } else if let Some(locale_index) = self.visible.get(index).copied() { + self.selected = locale_index; + self.emit_selection(&sender); + self.rebuild_list(&self.search.text()); + } + } + LanguageMsg::Retranslate => { + apply_header(&self.root); + self.heading.set_label(&gettext("Choose your language")); + self.no_results.set_label(&gettext("No languages found")); + // The native and country names are stable, but the + // current-language names behind search_text follow the UI + // language, so reload before rebuilding. + let selected_id = self.locales[self.selected].id.clone(); + self.locales = locale::load(); + self.selected = self + .locales + .iter() + .position(|entry| entry.id == selected_id) + .unwrap_or(0); + self.rebuild_list(""); + } + } + } +} + +impl LanguagePage { + fn rebuild_list(&mut self, query: &str) { + while let Some(child) = self.list.first_child() { + self.list.remove(&child); + } + + let (visible, more_row) = visible_indices(&self.locales, query, self.showing_extra); + self.more_row = more_row; + self.visible = visible; + + for index in self.visible.clone() { + let entry = &self.locales[index]; + let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12); + row_box.set_margin_top(12); + row_box.set_margin_bottom(12); + row_box.set_margin_start(12); + row_box.set_margin_end(12); + + let name = gtk::Label::new(Some(&entry.name_native)); + name.set_xalign(0.0); + name.set_ellipsize(gtk::pango::EllipsizeMode::End); + name.set_max_width_chars(30); + row_box.append(&name); + + row_box.append(&super::choice_list::selected_indicator( + index == self.selected, + )); + + if let Some(country) = &entry.country_native { + let label = gtk::Label::new(Some(country)); + label.add_css_class("dim-label"); + label.set_xalign(0.0); + label.set_ellipsize(gtk::pango::EllipsizeMode::End); + label.set_max_width_chars(30); + label.set_hexpand(true); + label.set_halign(gtk::Align::End); + row_box.append(&label); + } + + let row = gtk::ListBoxRow::new(); + row.set_activatable(true); + row.set_child(Some(&row_box)); + self.list.append(&row); + } + + if self.more_row { + let arrow = gtk::Image::from_icon_name("view-more-symbolic"); + arrow.add_css_class("dim-label"); + arrow.set_hexpand(true); + arrow.set_halign(gtk::Align::Center); + arrow.set_margin_top(12); + arrow.set_margin_bottom(12); + let row = gtk::ListBoxRow::new(); + row.set_activatable(true); + row.set_tooltip_text(Some(&gettext("More…"))); + row.set_child(Some(&arrow)); + self.list.append(&row); + } + } + + fn emit_selection(&self, sender: &ComponentSender) { + sender + .output(PageOutput::SetLocale( + self.locales[self.selected].id.clone(), + )) + .ok(); + } +} + +/// Row set shown for a query, mirroring `language_visible`: while searching, +/// every matching locale (initial or extra) shows and the "More…" row hides; +/// otherwise only the pinned entries show until the extras are expanded. +/// Returns the visible locale indices plus whether a "More…" row follows. +fn visible_indices( + locales: &[locale::LocaleEntry], + query: &str, + showing_extra: bool, +) -> (Vec, bool) { + let query = locale::normalize(query); + let searching = !query.is_empty(); + let visible = locales + .iter() + .enumerate() + .filter(|(_, entry)| { + if searching { + entry.matches(&query) + } else { + entry.is_initial || showing_extra + } + }) + .map(|(index, _)| index) + .collect(); + let more_row = !searching && !showing_extra && locales.iter().any(|entry| !entry.is_initial); + (visible, more_row) +} + +fn apply_header(root: &adw::StatusPage) { + super::status_header( + root, + &gettext("Language"), + &gettext("Choose the language used during installation."), + ); +} + +fn language_content( + branding: &Branding, + sender: &ComponentSender, +) -> ( + gtk::Box, + gtk::Label, + gtk::SearchEntry, + gtk::ListBox, + gtk::Label, +) { + let content = gtk::Box::new(gtk::Orientation::Horizontal, 72); + content.set_width_request(760); + content.set_halign(gtk::Align::Center); + content.set_valign(gtk::Align::Center); + content.append(&branding_view(branding)); + + let choices = gtk::Box::new(gtk::Orientation::Vertical, 12); + choices.set_hexpand(true); + let heading = gtk::Label::new(Some(&gettext("Choose your language"))); + heading.add_css_class("title-2"); + heading.set_halign(gtk::Align::Start); + choices.append(&heading); + + let search = gtk::SearchEntry::new(); + search.set_hexpand(true); + { + let sender = sender.clone(); + search.connect_search_changed(move |entry| { + sender.input(LanguageMsg::SearchChanged(entry.text().to_string())); + }); + } + choices.append(&search); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::None); + list.add_css_class("boxed-list"); + list.set_valign(gtk::Align::Start); + let no_results = gtk::Label::new(Some(&gettext("No languages found"))); + no_results.add_css_class("dim-label"); + no_results.set_margin_top(12); + no_results.set_margin_bottom(12); + no_results.set_sensitive(false); + list.set_placeholder(Some(&no_results)); + { + let sender = sender.clone(); + list.connect_row_activated(move |_, row| { + sender.input(LanguageMsg::RowActivated(row.index() as usize)); + }); + } + + let scroll = gtk::ScrolledWindow::new(); + scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + scroll.set_min_content_height(250); + scroll.set_max_content_height(300); + scroll.set_child(Some(&list)); + choices.append(&scroll); + content.append(&choices); + (content, heading, search, list, no_results) +} + +fn branding_view(branding: &Branding) -> gtk::Box { + let column = gtk::Box::new(gtk::Orientation::Vertical, 16); + column.set_width_request(260); + column.set_halign(gtk::Align::Center); + column.set_valign(gtk::Align::Center); + + if let Some(path) = branding + .logo + .as_deref() + .filter(|path| Path::new(path).is_file()) + { + let picture = gtk::Picture::for_filename(path); + picture.set_width_request(220); + picture.set_height_request(160); + picture.set_content_fit(gtk::ContentFit::Contain); + column.append(&picture); + } else { + let image = + gtk::Image::from_icon_name(branding.icon.as_deref().unwrap_or("starred-symbolic")); + image.set_pixel_size(128); + column.append(&image); + } + if let Some(name) = branding.name.as_deref() { + let label = gtk::Label::new(Some(name)); + label.add_css_class("title-1"); + column.append(&label); + } + column +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn without_a_query_only_pinned_languages_show_behind_more() { + let locales = locale::load(); + let (visible, more_row) = visible_indices(&locales, "", false); + assert!(!visible.is_empty()); + assert!(visible.iter().all(|index| locales[*index].is_initial)); + assert!(more_row, "the More… row must gate the extra locales"); + + let (expanded, more_row) = visible_indices(&locales, "", true); + assert!(expanded.len() > visible.len()); + assert!(!more_row); + } + + #[test] + fn searching_reaches_extra_locales_and_hides_more() { + let locales = locale::load(); + let extra = locales + .iter() + .find(|entry| !entry.is_initial) + .expect("extra locales must exist"); + let term = locale::normalize(extra.name_native.split_whitespace().next().unwrap()); + let (visible, more_row) = visible_indices(&locales, &term, false); + assert!(visible.iter().any(|index| locales[*index].id == extra.id)); + assert!(!more_row); + } +} diff --git a/crates/sirius-app/src/pages/language/locale.rs b/crates/sirius-app/src/pages/language/locale.rs new file mode 100644 index 0000000..c2dc7eb --- /dev/null +++ b/crates/sirius-app/src/pages/language/locale.rs @@ -0,0 +1,409 @@ +//! Locale enumeration and display names via GNOME Desktop. +//! +//! This is the data source behind GNOME Initial Setup's `CcLanguageChooser`: +//! every locale known to the system, named natively, in the current UI +//! language, and in English. Keeping the FFI here lets the language page +//! consume those names without leaking C ownership details into UI code. +//! `has_font` ports `cc_common_language_has_font` (fontconfig) so rows never +//! show up as empty boxes. + +use std::ffi::{CStr, CString, c_char, c_int, c_void}; +use std::ptr; + +use relm4::gtk; + +/// Languages shown before the "More…" row, mirroring +/// `cc_common_language_get_initial_languages` with `pt_BR` added (Sirius is a +/// Brazilian product, so Portuguese must be visible without expanding). +const INITIAL_LOCALES: &[&str] = &[ + "en_US", "pt_BR", "de_DE", "fr_FR", "es_ES", "zh_CN", "ja_JP", "ru_RU", "ar_EG", +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct LocaleEntry { + /// Locale id without codeset, e.g. `pt_BR` — the `SetLocale` wire format. + pub id: String, + /// Name in the locale's own language, e.g. `Português (Brasil)`. + pub name_native: String, + /// Native name of the country, shown dimmed on the trailing edge. + pub country_native: Option, + /// Initial (pinned) entry, visible before the "More…" row is expanded. + pub is_initial: bool, + search_text: String, +} + +impl LocaleEntry { + pub(super) fn matches(&self, query: &str) -> bool { + query + .split_whitespace() + .all(|term| self.search_text.contains(term)) + } +} + +#[repr(C)] +struct FcPattern { + _private: [u8; 0], +} + +#[repr(C)] +struct FcObjectSet { + _private: [u8; 0], +} + +#[repr(C)] +struct FcCharSet { + _private: [u8; 0], +} + +#[repr(C)] +struct FcFontSet { + nfont: c_int, + sfont: c_int, + fonts: *mut *mut FcPattern, +} + +#[link(name = "gnome-desktop-4")] +unsafe extern "C" { + fn gnome_get_all_locales() -> *mut *mut c_char; + fn gnome_normalize_locale(locale: *const c_char) -> *mut c_char; + fn gnome_parse_locale( + locale: *const c_char, + language: *mut *mut c_char, + country: *mut *mut c_char, + codeset: *mut *mut c_char, + modifier: *mut *mut c_char, + ) -> gtk::glib::ffi::gboolean; + fn gnome_get_language_from_locale( + locale: *const c_char, + translation: *const c_char, + ) -> *mut c_char; + fn gnome_get_language_from_code(code: *const c_char, translation: *const c_char) + -> *mut c_char; + fn gnome_get_country_from_code(code: *const c_char, translation: *const c_char) -> *mut c_char; +} + +#[link(name = "fontconfig")] +unsafe extern "C" { + fn FcInit() -> c_int; + fn FcLangGetCharSet(language: *const u8) -> *const FcCharSet; + fn FcPatternCreate() -> *mut FcPattern; + fn FcPatternAddString( + pattern: *mut FcPattern, + object: *const c_char, + value: *const u8, + ) -> c_int; + fn FcPatternDestroy(pattern: *mut FcPattern); + fn FcObjectSetCreate() -> *mut FcObjectSet; + fn FcObjectSetDestroy(object_set: *mut FcObjectSet); + fn FcFontList( + config: *mut c_void, + pattern: *mut FcPattern, + object_set: *mut FcObjectSet, + ) -> *mut FcFontSet; + fn FcFontSetDestroy(font_set: *mut FcFontSet); +} + +/// Enumerate every displayable locale, initial entries first (mirroring +/// `sort_languages`: pinned languages, then the extras, each group ordered by +/// its native name). +pub(super) fn load() -> Vec { + // GNOME Desktop lazily initializes its iso-codes tables without locking, + // so first use must be serialized across threads. + let _guard = crate::pages::GNOME_DESKTOP_LOCK.lock().unwrap(); + let mut entries = Vec::new(); + for full_id in all_locales() { + let Some(entry) = locale_entry(&full_id) else { + continue; + }; + entries.push(entry); + } + entries.sort_by_cached_key(|entry| { + ( + !entry.is_initial, + entry.name_native.to_lowercase(), + entry.id.clone(), + ) + }); + entries.dedup_by(|a, b| a.id == b.id); + entries +} + +fn locale_entry(full_id: &str) -> Option { + let (language, country) = parse_locale(full_id)?; + if !has_font(&language) { + return None; + } + + let name_native = language_from_locale(full_id, Some(full_id)) + .or_else(|| language_from_code(&language, None))?; + let name_current = language_from_locale(full_id, None).unwrap_or_default(); + let name_english = language_from_locale(full_id, Some("C")).unwrap_or_default(); + let country_native = country.and_then(|code| { + country_from_code(&code, Some(full_id)).or_else(|| country_from_code(&code, None)) + }); + let id = strip_codeset(full_id); + let search_text = normalize(&format!( + "{name_native} {name_current} {name_english} {} {id}", + country_native.as_deref().unwrap_or_default() + )); + + Some(LocaleEntry { + is_initial: INITIAL_LOCALES.contains(&id.as_str()), + id, + name_native, + country_native, + search_text, + }) +} + +/// The current UI locale in `SetLocale` wire format (no codeset), taken from +/// `LANGUAGE`'s first entry or `LANG`, e.g. `pt_BR`. +pub(super) fn current_locale() -> Option { + let value = std::env::var("LANGUAGE") + .ok() + .and_then(|value| value.split(':').next().map(str::to_owned)) + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var("LANG").ok()) + .filter(|value| !value.is_empty() && value != "C" && value != "POSIX")?; + Some(strip_codeset(&normalize_locale(&value).unwrap_or(value))) +} + +fn strip_codeset(locale: &str) -> String { + locale.split('.').next().unwrap_or(locale).to_string() +} + +fn all_locales() -> Vec { + // SAFETY: `gnome_get_all_locales` returns a newly allocated GStrv which we + // copy out of and release with `g_strfreev`. + unsafe { + let strv = gnome_get_all_locales(); + if strv.is_null() { + return Vec::new(); + } + let mut locales = Vec::new(); + let mut cursor = strv; + while !(*cursor).is_null() { + // SAFETY: non-null GStrv element, NUL-terminated. Elements are + // owned by the strv and released by `g_strfreev` below. + locales.push(copy(*cursor)); + cursor = cursor.add(1); + } + gtk::glib::ffi::g_strfreev(strv.cast()); + locales + } +} + +fn normalize_locale(locale: &str) -> Option { + let c_locale = CString::new(locale).ok()?; + // SAFETY: valid input; result is a newly allocated string freed with g_free. + unsafe { + let result = gnome_normalize_locale(c_locale.as_ptr()); + (!result.is_null()).then(|| copy_free(result)) + } +} + +fn parse_locale(locale: &str) -> Option<(String, Option)> { + let c_locale = CString::new(locale).ok()?; + let mut language = ptr::null_mut(); + let mut country = ptr::null_mut(); + // SAFETY: output pointers are initialized by GNOME Desktop on success and + // are newly allocated strings released with g_free. + let parsed = unsafe { + gnome_parse_locale( + c_locale.as_ptr(), + &mut language, + &mut country, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if parsed == gtk::glib::ffi::GFALSE || language.is_null() { + return None; + } + // SAFETY: checked for null above. + let language = unsafe { copy_free(language) }; + // SAFETY: optional pointer copied only when non-null. + let country = (!country.is_null()).then(|| unsafe { copy_free(country) }); + Some((language, country)) +} + +fn language_from_locale(locale: &str, translation: Option<&str>) -> Option { + let c_locale = CString::new(locale).ok()?; + let c_translation = translation.and_then(|value| CString::new(value).ok()); + // SAFETY: valid inputs; result is a newly allocated string freed with g_free. + unsafe { + let result = gnome_get_language_from_locale( + c_locale.as_ptr(), + c_translation + .as_ref() + .map_or(ptr::null(), |value| value.as_ptr()), + ); + (!result.is_null()).then(|| copy_free(result)) + } +} + +fn language_from_code(code: &str, translation: Option<&str>) -> Option { + let c_code = CString::new(code).ok()?; + let c_translation = translation.and_then(|value| CString::new(value).ok()); + // SAFETY: valid inputs; result is a newly allocated string freed with g_free. + unsafe { + let result = gnome_get_language_from_code( + c_code.as_ptr(), + c_translation + .as_ref() + .map_or(ptr::null(), |value| value.as_ptr()), + ); + (!result.is_null()).then(|| copy_free(result)) + } +} + +fn country_from_code(code: &str, translation: Option<&str>) -> Option { + let c_code = CString::new(code).ok()?; + let c_translation = translation.and_then(|value| CString::new(value).ok()); + // SAFETY: valid inputs; result is a newly allocated string freed with g_free. + unsafe { + let result = gnome_get_country_from_code( + c_code.as_ptr(), + c_translation + .as_ref() + .map_or(ptr::null(), |value| value.as_ptr()), + ); + (!result.is_null()).then(|| copy_free(result)) + } +} + +/// Port of `cc_common_language_has_font`: when fontconfig does not know the +/// language we assume it renders; otherwise some installed font must cover +/// its charset. +fn has_font(language: &str) -> bool { + let Ok(c_language) = CString::new(language) else { + return false; + }; + // SAFETY: every created object is destroyed before returning; the charset + // pointer is borrowed from fontconfig and never freed. FcInit is + // idempotent and thread-safe, and required before first use when no GUI + // toolkit initialized fontconfig already (e.g. in tests). + unsafe { + FcInit(); + let charset = FcLangGetCharSet(c_language.as_ptr().cast()); + if charset.is_null() { + return true; + } + let pattern = FcPatternCreate(); + if pattern.is_null() { + return false; + } + let added = FcPatternAddString(pattern, c"lang".as_ptr(), c_language.as_ptr().cast()); + let object_set = FcObjectSetCreate(); + let font_set = if added != 0 && !object_set.is_null() { + FcFontList(ptr::null_mut(), pattern, object_set) + } else { + ptr::null_mut() + }; + let displayable = !font_set.is_null() && (*font_set).nfont > 0; + if !font_set.is_null() { + FcFontSetDestroy(font_set); + } + if !object_set.is_null() { + FcObjectSetDestroy(object_set); + } + FcPatternDestroy(pattern); + displayable + } +} + +/// Copy a newly allocated C string and release it with `g_free`. +/// +/// # SAFETY: `pointer` must be a valid NUL-terminated `g_malloc` string. +unsafe fn copy_free(pointer: *mut c_char) -> String { + // SAFETY: caller guarantees a valid NUL-terminated pointer. + let value = unsafe { copy(pointer) }; + // SAFETY: the pointer came from g_malloc. + unsafe { gtk::glib::ffi::g_free(pointer.cast()) }; + value +} + +/// Copy a borrowed C string without freeing it. +/// +/// # SAFETY: `pointer` must be a valid NUL-terminated string. +unsafe fn copy(pointer: *const c_char) -> String { + // SAFETY: caller guarantees a valid NUL-terminated pointer. + unsafe { CStr::from_ptr(pointer) } + .to_string_lossy() + .into_owned() +} + +pub(super) fn normalize(value: &str) -> String { + value + .to_lowercase() + .chars() + .map(|character| match character { + 'á' | 'à' | 'â' | 'ã' | 'ä' => 'a', + 'é' | 'è' | 'ê' | 'ë' => 'e', + 'í' | 'ì' | 'î' | 'ï' => 'i', + 'ó' | 'ò' | 'ô' | 'õ' | 'ö' => 'o', + 'ú' | 'ù' | 'û' | 'ü' => 'u', + 'ç' => 'c', + other => other, + }) + .collect::() + .replace(['_', '.', '-', '(', ')', ','], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enumerates_the_system_locales() { + let entries = load(); + assert!(entries.iter().any(|entry| entry.id == "en_US")); + assert!(entries.iter().any(|entry| entry.id == "pt_BR")); + let portuguese = entries.iter().find(|entry| entry.id == "pt_BR").unwrap(); + assert!(portuguese.name_native.contains("Português")); + assert!(portuguese.is_initial); + assert_eq!(portuguese.country_native.as_deref(), Some("Brasil")); + } + + #[test] + fn initial_entries_come_first() { + let entries = load(); + let first_extra = entries.iter().position(|entry| !entry.is_initial); + if let Some(first_extra) = first_extra { + assert!(entries[..first_extra].iter().all(|entry| entry.is_initial)); + } + assert!(entries.iter().filter(|entry| entry.is_initial).count() <= 10); + } + + #[test] + fn search_matches_native_current_and_english_names() { + let entries = load(); + let japanese = entries.iter().find(|entry| entry.id == "ja_JP").unwrap(); + assert!(japanese.matches("jap")); + assert!(japanese.matches("japanese")); + // Entries hidden behind the "More…" row must exist and stay searchable. + let extra = entries + .iter() + .find(|entry| !entry.is_initial) + .expect("the chooser must have entries behind the More… row"); + let term = extra.name_native.split_whitespace().next().unwrap(); + assert!(extra.matches(&normalize(term))); + } + + #[test] + fn latin_american_locales_keep_their_country() { + let entries = load(); + if let Some(mexico) = entries.iter().find(|entry| entry.id == "es_MX") { + assert_eq!(mexico.country_native.as_deref(), Some("México")); + } + } + + #[test] + fn font_coverage_check_accepts_latin() { + assert!(has_font("en")); + assert!(has_font("pt")); + } +} diff --git a/crates/sirius-installer/src/pages/mod.rs b/crates/sirius-app/src/pages/mod.rs similarity index 81% rename from crates/sirius-installer/src/pages/mod.rs rename to crates/sirius-app/src/pages/mod.rs index 08a6294..24d7c9c 100644 --- a/crates/sirius-installer/src/pages/mod.rs +++ b/crates/sirius-app/src/pages/mod.rs @@ -1,9 +1,12 @@ //! Wizard pages. Each page is a Relm4 SimpleComponent that emits `PageOutput` //! up to AppModel, which folds the change into InstallConfig. +mod choice_list; + pub mod diagnostics; pub mod finished; pub mod keyboard; +pub mod language; pub mod network; pub mod progress; pub mod storage; @@ -12,8 +15,13 @@ pub mod timezone; pub mod user; pub mod welcome; -use crate::config_model::{InstallType, PartitionPlan, UserAccount}; use relm4::adw; +use sirius_core::{InstallType, PartitionPlan, UserAccount}; + +/// GNOME Desktop lazily initializes its iso-codes/xkb tables without locking; +/// the locale and keyboard FFI boundaries take this lock around entry points +/// that trigger that one-time setup, so concurrent first use cannot race it. +pub(crate) static GNOME_DESKTOP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// Set a status page's translated header. Pages call this both in `init` and /// on every `update_view`: gettext resolves at call time, so re-applying on diff --git a/crates/sirius-installer/src/pages/network.rs b/crates/sirius-app/src/pages/network.rs similarity index 86% rename from crates/sirius-installer/src/pages/network.rs rename to crates/sirius-app/src/pages/network.rs index da78c2c..a286844 100644 --- a/crates/sirius-installer/src/pages/network.rs +++ b/crates/sirius-app/src/pages/network.rs @@ -1,10 +1,10 @@ //! Optional Wi-Fi selection and connection page backed by NetworkManager. use super::PageOutput; -use crate::backend::network::{WifiNetwork, WifiSecurity, connect_wifi, scan_wifi}; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; +use sirius_backend::network::{WifiNetwork, WifiSecurity, connect_wifi, scan_wifi}; pub struct NetworkPage { root: adw::StatusPage, @@ -51,7 +51,6 @@ impl SimpleComponent for NetworkPage { connecting: None, error: None, }; - root.set_icon_name(Some("network-wireless-symbolic")); apply_header(&root); scan_in_background(sender.clone()); ComponentParts { @@ -119,8 +118,23 @@ impl SimpleComponent for NetworkPage { fn update_view(&self, widgets: &mut Self::Widgets, sender: ComponentSender) { apply_header(&widgets.root); - let content = gtk::Box::new(gtk::Orientation::Vertical, 14); - content.set_width_request(650); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 72); + content.set_width_request(760); + content.set_halign(gtk::Align::Center); + content.set_valign(gtk::Align::Center); + + let illustration = gtk::Image::from_icon_name("network-wireless-symbolic"); + illustration.set_pixel_size(160); + illustration.set_width_request(260); + content.append(&illustration); + + let choices = gtk::Box::new(gtk::Orientation::Vertical, 12); + choices.set_hexpand(true); + let heading = gtk::Label::new(Some(&gettext("Choose a Wi-Fi network"))); + heading.add_css_class("title-2"); + heading.set_halign(gtk::Align::Start); + choices.append(&heading); + let group = adw::PreferencesGroup::new(); group.set_title(&gettext("Available Wi-Fi networks")); if self.loading { @@ -156,18 +170,24 @@ impl SimpleComponent for NetworkPage { } group.add(&row); } - content.append(&group); + let scroll = gtk::ScrolledWindow::new(); + scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + scroll.set_min_content_height(250); + scroll.set_max_content_height(300); + scroll.set_child(Some(&group)); + choices.append(&scroll); if let Some(error) = &self.error { let label = gtk::Label::new(Some(error)); label.add_css_class("error"); label.set_wrap(true); - content.append(&label); + choices.append(&label); } let refresh = gtk::Button::with_label(&gettext("Scan again")); refresh.set_halign(gtk::Align::Center); refresh.set_sensitive(!self.loading && self.connecting.is_none()); refresh.connect_clicked(move |_| sender.input(NetworkMsg::Refresh)); - content.append(&refresh); + choices.append(&refresh); + content.append(&choices); widgets.root.set_child(Some(&content)); } } @@ -179,7 +199,7 @@ fn scan_in_background(sender: ComponentSender) { fn apply_header(root: &adw::StatusPage) { super::status_header( root, - &gettext("Network"), + &gettext("Internet connection"), &gettext("Connect to a network. A connection is optional but recommended."), ); } diff --git a/crates/sirius-installer/src/pages/progress.rs b/crates/sirius-app/src/pages/progress.rs similarity index 94% rename from crates/sirius-installer/src/pages/progress.rs rename to crates/sirius-app/src/pages/progress.rs index 8876d66..5fb2e98 100644 --- a/crates/sirius-installer/src/pages/progress.rs +++ b/crates/sirius-app/src/pages/progress.rs @@ -5,10 +5,10 @@ mod bento; use super::PageOutput; -use crate::backend::distro::Bento; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; +use sirius_core::Bento; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ProgressPhase { @@ -101,15 +101,6 @@ impl SimpleComponent for ProgressPage { set_orientation: gtk::Orientation::Vertical, set_spacing: 18, - // Visible activity while the install runs; hidden on - // failure (the error icon takes over) and when done. - #[name = "spinner"] - gtk::Spinner { - set_halign: gtk::Align::Center, - #[watch] - set_visible: matches!(model.phase, ProgressPhase::Running { .. }), - }, - // Bentos stay in the centered StatusPage content. #[name = "bento_box"] gtk::Box { @@ -195,10 +186,6 @@ impl SimpleComponent for ProgressPage { let widgets = view_output!(); bento::append_cards(&widgets.bento_box, &bentos); - // Started once; visibility (driven by the phase) decides whether it - // actually animates on screen. - widgets.spinner.start(); - if bentos.is_empty() { // No cards above: show the log permanently. widgets.log_revealer.set_reveal_child(true); diff --git a/crates/sirius-installer/src/pages/progress/bento.rs b/crates/sirius-app/src/pages/progress/bento.rs similarity index 99% rename from crates/sirius-installer/src/pages/progress/bento.rs rename to crates/sirius-app/src/pages/progress/bento.rs index a179dcb..da2f8f2 100644 --- a/crates/sirius-installer/src/pages/progress/bento.rs +++ b/crates/sirius-app/src/pages/progress/bento.rs @@ -1,11 +1,11 @@ //! Optional distro link cards shown during installation. -use crate::backend::distro::Bento; use relm4::adw::prelude::*; use relm4::gtk; use relm4::gtk::gio; use relm4::gtk::gio::prelude::AppInfoExt; use relm4::gtk::glib; +use sirius_core::Bento; use std::process::{Command, Stdio}; use std::thread; diff --git a/crates/sirius-installer/src/pages/storage.rs b/crates/sirius-app/src/pages/storage.rs similarity index 99% rename from crates/sirius-installer/src/pages/storage.rs rename to crates/sirius-app/src/pages/storage.rs index a2447e3..f6f39a0 100644 --- a/crates/sirius-installer/src/pages/storage.rs +++ b/crates/sirius-app/src/pages/storage.rs @@ -8,13 +8,13 @@ mod page_view; mod partition_dialog; use super::{PageOutput, StorageSelection}; -use crate::backend::storage::{DiskSnapshot, scan_disks}; -use crate::config_model::{InstallType, PartitionPlan}; use draft::{PartitionDraft, PartitionSpec}; use gettextrs::gettext; use partition_dialog::{DialogTarget, EditSource}; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; +use sirius_backend::storage::{DiskSnapshot, scan_disks}; +use sirius_core::{InstallType, PartitionPlan}; pub struct StoragePage { root: gtk::ScrolledWindow, diff --git a/crates/sirius-installer/src/pages/storage/draft.rs b/crates/sirius-app/src/pages/storage/draft.rs similarity index 58% rename from crates/sirius-installer/src/pages/storage/draft.rs rename to crates/sirius-app/src/pages/storage/draft.rs index b553856..c010503 100644 --- a/crates/sirius-installer/src/pages/storage/draft.rs +++ b/crates/sirius-app/src/pages/storage/draft.rs @@ -4,8 +4,9 @@ //! staged here and become an installer `PartitionPlan` only when the user //! applies the modal. -use crate::backend::storage::{DiskSnapshot, FreeRegion}; -use crate::config_model::{MountAssignment, PartitionOperation, PartitionPlan, PartitionRef}; +use gettextrs::gettext; +use sirius_backend::storage::{DiskSnapshot, FreeRegion}; +use sirius_core::{MountAssignment, PartitionOperation, PartitionPlan, PartitionRef}; const GIB: f64 = 1024.0 * 1024.0 * 1024.0; const MIN_FREE_BYTES: u64 = 512 * 1024 * 1024; @@ -21,16 +22,17 @@ pub struct PartitionSpec { impl PartitionSpec { fn validate(&self, allow_resize: bool) -> Result<(), String> { if allow_resize && (!self.size_gib.is_finite() || self.size_gib < 0.5) { - return Err("partition size must be at least 0.5 GiB".into()); + return Err(gettext("partition size must be at least 0.5 GiB")); } if !matches!(self.filesystem.as_str(), "btrfs" | "ext4" | "vfat" | "swap") { - return Err(format!("unsupported filesystem: {}", self.filesystem)); + return Err(gettext("unsupported filesystem: {filesystem}") + .replace("{filesystem}", &self.filesystem)); } if !self.mount_point.is_empty() && !self.mount_point.starts_with('/') { - return Err("mount point must be empty or an absolute path".into()); + return Err(gettext("mount point must be empty or an absolute path")); } if self.filesystem == "swap" && !self.mount_point.is_empty() { - return Err("swap partitions cannot have a mount point".into()); + return Err(gettext("swap partitions cannot have a mount point")); } Ok(()) } @@ -57,7 +59,9 @@ impl PartitionDraft { pub fn new(disk: &DiskSnapshot, plan: Option<&PartitionPlan>) -> Result { let plan = plan.cloned().unwrap_or_else(|| Self::empty_plan(disk)); if plan.disk_path != disk.path || plan.disk_size_bytes != disk.size_bytes { - return Err("partition plan no longer matches the selected disk".into()); + return Err(gettext( + "partition plan no longer matches the selected disk", + )); } Ok(Self { disk: disk.clone(), @@ -97,10 +101,10 @@ impl PartitionDraft { spec.validate(true)?; let free = self .remaining_region(region) - .ok_or_else(|| "the selected free region is no longer available".to_string())?; + .ok_or_else(|| gettext("the selected free region is no longer available"))?; let requested = (spec.size_gib * GIB) as u64; if requested > free.size_bytes { - return Err("partition size exceeds the available space".into()); + return Err(gettext("partition size exceeds the available space")); } let id = uuid::Uuid::new_v4().to_string(); @@ -145,9 +149,9 @@ impl PartitionDraft { .disk .partitions .get(index) - .ok_or_else(|| "partition no longer exists".to_string())?; + .ok_or_else(|| gettext("partition no longer exists"))?; if !partition.mountpoints.is_empty() { - return Err("mounted partitions cannot be deleted".into()); + return Err(gettext("mounted partitions cannot be deleted")); } let target = self.existing_ref(index)?; self.plan @@ -175,12 +179,12 @@ impl PartitionDraft { } if current == id => Some((*offset_bytes, *size_bytes)), _ => None, }) - .ok_or_else(|| "planned partition no longer exists".to_string())?; + .ok_or_else(|| gettext("planned partition no longer exists"))?; let requested = (spec.size_gib * GIB) as u64; let max_size = self.max_planned_size(id, offset_bytes, current_size); if requested > max_size { - return Err("partition size exceeds the available space".into()); + return Err(gettext("partition size exceeds the available space")); } let target = PartitionRef::Planned { id: id.to_string() }; @@ -303,7 +307,7 @@ impl PartitionDraft { !matches!(operation, PartitionOperation::Create { id: current, .. } if current == id) }); if before == self.plan.operations.len() { - return Err("planned partition no longer exists".into()); + return Err(gettext("planned partition no longer exists")); } self.plan.mounts.retain(|mount| { !matches!(&mount.target, PartitionRef::Planned { id: current } if current == id) @@ -321,7 +325,7 @@ impl PartitionDraft { size_bytes: partition.size_bytes, part_uuid: (!partition.part_uuid.is_empty()).then(|| partition.part_uuid.clone()), }) - .ok_or_else(|| "partition no longer exists".into()) + .ok_or_else(|| gettext("partition no longer exists")) } fn add_mount(&mut self, target: PartitionRef, spec: &PartitionSpec) { @@ -381,245 +385,4 @@ fn gpt_type(spec: &PartitionSpec) -> &'static str { } #[cfg(test)] -mod tests { - use super::*; - use crate::backend::storage::PartitionSnapshot; - - const GIB_BYTES: u64 = 1024 * 1024 * 1024; - - fn disk() -> DiskSnapshot { - DiskSnapshot { - path: "/dev/sda".into(), - model: "Test disk".into(), - size_bytes: 64 * GIB_BYTES, - table_type: "GPT".into(), - read_only: false, - in_use: false, - partitions: vec![PartitionSnapshot { - path: "/dev/sda1".into(), - start_bytes: GIB_BYTES, - size_bytes: 4 * GIB_BYTES, - filesystem: "ext4".into(), - label: "old".into(), - mountpoints: Vec::new(), - gpt_type: String::new(), - part_uuid: "uuid".into(), - }], - free_regions: vec![FreeRegion { - offset_bytes: 5 * GIB_BYTES, - size_bytes: 59 * GIB_BYTES, - }], - } - } - - fn root_spec() -> PartitionSpec { - PartitionSpec { - size_gib: 30.0, - filesystem: "btrfs".into(), - mount_point: "/".into(), - label: "root".into(), - } - } - - #[test] - fn create_consumes_free_space_and_adds_mount() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - draft.create(0, root_spec()).unwrap(); - assert_eq!(draft.plan.mounts.len(), 1); - assert_eq!( - draft.remaining_region(0).unwrap().size_bytes, - 29 * GIB_BYTES - ); - } - - #[test] - fn oversized_create_is_rejected_without_mutating_the_plan() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - let mut spec = root_spec(); - spec.size_gib = 60.0; - assert!(draft.create(0, spec).is_err()); - assert!(draft.plan.operations.is_empty()); - } - - #[test] - fn edit_replaces_previous_format_and_mount() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - draft.edit_existing(0, root_spec()).unwrap(); - let mut spec = root_spec(); - spec.filesystem = "ext4".into(); - draft.edit_existing(0, spec).unwrap(); - assert_eq!(draft.plan.operations.len(), 1); - assert_eq!(draft.plan.mounts.len(), 1); - assert!(matches!( - &draft.plan.operations[0], - PartitionOperation::Format { filesystem, .. } if filesystem == "ext4" - )); - } - - #[test] - fn existing_and_planned_partitions_can_be_deleted() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - draft.delete_existing(0).unwrap(); - assert!(matches!( - draft.plan.operations.last(), - Some(PartitionOperation::Delete { .. }) - )); - - draft.create(0, root_spec()).unwrap(); - let id = draft - .plan - .operations - .iter() - .find_map(|operation| match operation { - PartitionOperation::Create { id, .. } => Some(id.clone()), - _ => None, - }) - .unwrap(); - draft.delete_planned(&id).unwrap(); - assert!(!draft.plan.operations.iter().any(|operation| { - matches!(operation, PartitionOperation::Create { id: current, .. } if current == &id) - })); - } - - fn planned_id(draft: &PartitionDraft) -> String { - draft - .plan - .operations - .iter() - .find_map(|operation| match operation { - PartitionOperation::Create { id, .. } => Some(id.clone()), - _ => None, - }) - .unwrap() - } - - #[test] - fn edit_planned_shrink_frees_up_remaining_region() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - draft.create(0, root_spec()).unwrap(); - let id = planned_id(&draft); - assert_eq!( - draft.remaining_region(0).unwrap().size_bytes, - 29 * GIB_BYTES - ); - - let mut spec = root_spec(); - spec.size_gib = 20.0; - draft.edit_planned(&id, spec).unwrap(); - - assert_eq!( - draft.remaining_region(0).unwrap().size_bytes, - 39 * GIB_BYTES - ); - assert!(matches!( - &draft.plan.operations[0], - PartitionOperation::Create { size_bytes, .. } if *size_bytes == 20 * GIB_BYTES - )); - assert_eq!(draft.plan.mounts.len(), 1); - } - - #[test] - fn edit_planned_grow_into_free_space_succeeds() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - draft.create(0, root_spec()).unwrap(); - let id = planned_id(&draft); - - let mut spec = root_spec(); - spec.size_gib = 59.0; // grow to fill the entire free region - draft.edit_planned(&id, spec).unwrap(); - - assert!(draft.remaining_region(0).is_none()); - assert!(matches!( - &draft.plan.operations[0], - PartitionOperation::Create { size_bytes, .. } if *size_bytes == 59 * GIB_BYTES - )); - } - - #[test] - fn edit_planned_grow_beyond_available_space_is_rejected_without_mutating_the_plan() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - draft.create(0, root_spec()).unwrap(); - let id = planned_id(&draft); - let before = draft.plan.clone(); - - let mut spec = root_spec(); - spec.size_gib = 60.0; // exceeds the 59 GiB free region - assert!(draft.edit_planned(&id, spec).is_err()); - assert_eq!(draft.plan.operations, before.operations); - assert_eq!(draft.plan.mounts, before.mounts); - } - - #[test] - fn edit_planned_on_missing_id_returns_not_found_error() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - let err = draft - .edit_planned("does-not-exist", root_spec()) - .unwrap_err(); - assert_eq!(err, "planned partition no longer exists"); - } - - #[test] - fn edit_planned_growth_is_bounded_by_a_later_planned_create() { - let disk = disk(); - let mut draft = PartitionDraft::new(&disk, None).unwrap(); - draft.create(0, root_spec()).unwrap(); - let first_id = planned_id(&draft); - - let mut home_spec = root_spec(); - home_spec.size_gib = 10.0; - home_spec.mount_point = "/home".into(); - home_spec.label = "home".into(); - draft.create(0, home_spec).unwrap(); - - // The first partition is immediately followed by the second, so it - // cannot grow at all without overlapping it. - let mut grow = root_spec(); - grow.size_gib = 31.0; - assert!(draft.edit_planned(&first_id, grow).is_err()); - - // Deleting the blocking partition frees up room to grow again. - let second_id = draft - .plan - .operations - .iter() - .find_map(|operation| match operation { - PartitionOperation::Create { id, size_bytes, .. } - if *size_bytes == 10 * GIB_BYTES => - { - Some(id.clone()) - } - _ => None, - }) - .unwrap(); - draft.delete_planned(&second_id).unwrap(); - - let mut grow_more = root_spec(); - grow_more.size_gib = 40.0; - draft.edit_planned(&first_id, grow_more).unwrap(); - assert!(matches!( - draft.plan.operations.iter().find(|operation| matches!( - operation, - PartitionOperation::Create { id, .. } if id == &first_id - )), - Some(PartitionOperation::Create { size_bytes, .. }) if *size_bytes == 40 * GIB_BYTES - )); - } - - #[test] - fn into_plan_commits_only_the_owned_draft() { - let disk = disk(); - let committed = PartitionDraft::empty_plan(&disk); - let mut draft = PartitionDraft::new(&disk, Some(&committed)).unwrap(); - draft.create(0, root_spec()).unwrap(); - assert!(committed.operations.is_empty()); - assert!(!draft.into_plan().operations.is_empty()); - } -} +mod tests; diff --git a/crates/sirius-app/src/pages/storage/draft/tests.rs b/crates/sirius-app/src/pages/storage/draft/tests.rs new file mode 100644 index 0000000..71c4c9d --- /dev/null +++ b/crates/sirius-app/src/pages/storage/draft/tests.rs @@ -0,0 +1,238 @@ +use super::*; +use sirius_backend::storage::PartitionSnapshot; + +const GIB_BYTES: u64 = 1024 * 1024 * 1024; + +fn disk() -> DiskSnapshot { + DiskSnapshot { + path: "/dev/sda".into(), + model: "Test disk".into(), + size_bytes: 64 * GIB_BYTES, + table_type: "GPT".into(), + read_only: false, + in_use: false, + partitions: vec![PartitionSnapshot { + path: "/dev/sda1".into(), + start_bytes: GIB_BYTES, + size_bytes: 4 * GIB_BYTES, + filesystem: "ext4".into(), + label: "old".into(), + mountpoints: Vec::new(), + gpt_type: String::new(), + part_uuid: "uuid".into(), + }], + free_regions: vec![FreeRegion { + offset_bytes: 5 * GIB_BYTES, + size_bytes: 59 * GIB_BYTES, + }], + } +} + +fn root_spec() -> PartitionSpec { + PartitionSpec { + size_gib: 30.0, + filesystem: "btrfs".into(), + mount_point: "/".into(), + label: "root".into(), + } +} + +#[test] +fn create_consumes_free_space_and_adds_mount() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + draft.create(0, root_spec()).unwrap(); + assert_eq!(draft.plan.mounts.len(), 1); + assert_eq!( + draft.remaining_region(0).unwrap().size_bytes, + 29 * GIB_BYTES + ); +} + +#[test] +fn oversized_create_is_rejected_without_mutating_the_plan() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + let mut spec = root_spec(); + spec.size_gib = 60.0; + assert!(draft.create(0, spec).is_err()); + assert!(draft.plan.operations.is_empty()); +} + +#[test] +fn edit_replaces_previous_format_and_mount() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + draft.edit_existing(0, root_spec()).unwrap(); + let mut spec = root_spec(); + spec.filesystem = "ext4".into(); + draft.edit_existing(0, spec).unwrap(); + assert_eq!(draft.plan.operations.len(), 1); + assert_eq!(draft.plan.mounts.len(), 1); + assert!(matches!( + &draft.plan.operations[0], + PartitionOperation::Format { filesystem, .. } if filesystem == "ext4" + )); +} + +#[test] +fn existing_and_planned_partitions_can_be_deleted() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + draft.delete_existing(0).unwrap(); + assert!(matches!( + draft.plan.operations.last(), + Some(PartitionOperation::Delete { .. }) + )); + + draft.create(0, root_spec()).unwrap(); + let id = draft + .plan + .operations + .iter() + .find_map(|operation| match operation { + PartitionOperation::Create { id, .. } => Some(id.clone()), + _ => None, + }) + .unwrap(); + draft.delete_planned(&id).unwrap(); + assert!(!draft.plan.operations.iter().any(|operation| { + matches!(operation, PartitionOperation::Create { id: current, .. } if current == &id) + })); +} + +fn planned_id(draft: &PartitionDraft) -> String { + draft + .plan + .operations + .iter() + .find_map(|operation| match operation { + PartitionOperation::Create { id, .. } => Some(id.clone()), + _ => None, + }) + .unwrap() +} + +#[test] +fn edit_planned_shrink_frees_up_remaining_region() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + draft.create(0, root_spec()).unwrap(); + let id = planned_id(&draft); + assert_eq!( + draft.remaining_region(0).unwrap().size_bytes, + 29 * GIB_BYTES + ); + + let mut spec = root_spec(); + spec.size_gib = 20.0; + draft.edit_planned(&id, spec).unwrap(); + + assert_eq!( + draft.remaining_region(0).unwrap().size_bytes, + 39 * GIB_BYTES + ); + assert!(matches!( + &draft.plan.operations[0], + PartitionOperation::Create { size_bytes, .. } if *size_bytes == 20 * GIB_BYTES + )); + assert_eq!(draft.plan.mounts.len(), 1); +} + +#[test] +fn edit_planned_grow_into_free_space_succeeds() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + draft.create(0, root_spec()).unwrap(); + let id = planned_id(&draft); + + let mut spec = root_spec(); + spec.size_gib = 59.0; // grow to fill the entire free region + draft.edit_planned(&id, spec).unwrap(); + + assert!(draft.remaining_region(0).is_none()); + assert!(matches!( + &draft.plan.operations[0], + PartitionOperation::Create { size_bytes, .. } if *size_bytes == 59 * GIB_BYTES + )); +} + +#[test] +fn edit_planned_grow_beyond_available_space_is_rejected_without_mutating_the_plan() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + draft.create(0, root_spec()).unwrap(); + let id = planned_id(&draft); + let before = draft.plan.clone(); + + let mut spec = root_spec(); + spec.size_gib = 60.0; // exceeds the 59 GiB free region + assert!(draft.edit_planned(&id, spec).is_err()); + assert_eq!(draft.plan.operations, before.operations); + assert_eq!(draft.plan.mounts, before.mounts); +} + +#[test] +fn edit_planned_on_missing_id_returns_not_found_error() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + let err = draft + .edit_planned("does-not-exist", root_spec()) + .unwrap_err(); + assert_eq!(err, "planned partition no longer exists"); +} + +#[test] +fn edit_planned_growth_is_bounded_by_a_later_planned_create() { + let disk = disk(); + let mut draft = PartitionDraft::new(&disk, None).unwrap(); + draft.create(0, root_spec()).unwrap(); + let first_id = planned_id(&draft); + + let mut home_spec = root_spec(); + home_spec.size_gib = 10.0; + home_spec.mount_point = "/home".into(); + home_spec.label = "home".into(); + draft.create(0, home_spec).unwrap(); + + // The first partition is immediately followed by the second, so it + // cannot grow at all without overlapping it. + let mut grow = root_spec(); + grow.size_gib = 31.0; + assert!(draft.edit_planned(&first_id, grow).is_err()); + + // Deleting the blocking partition frees up room to grow again. + let second_id = draft + .plan + .operations + .iter() + .find_map(|operation| match operation { + PartitionOperation::Create { id, size_bytes, .. } if *size_bytes == 10 * GIB_BYTES => { + Some(id.clone()) + } + _ => None, + }) + .unwrap(); + draft.delete_planned(&second_id).unwrap(); + + let mut grow_more = root_spec(); + grow_more.size_gib = 40.0; + draft.edit_planned(&first_id, grow_more).unwrap(); + assert!(matches!( + draft.plan.operations.iter().find(|operation| matches!( + operation, + PartitionOperation::Create { id, .. } if id == &first_id + )), + Some(PartitionOperation::Create { size_bytes, .. }) if *size_bytes == 40 * GIB_BYTES + )); +} + +#[test] +fn into_plan_commits_only_the_owned_draft() { + let disk = disk(); + let committed = PartitionDraft::empty_plan(&disk); + let mut draft = PartitionDraft::new(&disk, Some(&committed)).unwrap(); + draft.create(0, root_spec()).unwrap(); + assert!(committed.operations.is_empty()); + assert!(!draft.into_plan().operations.is_empty()); +} diff --git a/crates/sirius-installer/src/pages/storage/editor_view.rs b/crates/sirius-app/src/pages/storage/editor_view.rs similarity index 99% rename from crates/sirius-installer/src/pages/storage/editor_view.rs rename to crates/sirius-app/src/pages/storage/editor_view.rs index 208a44f..d49eb7d 100644 --- a/crates/sirius-installer/src/pages/storage/editor_view.rs +++ b/crates/sirius-app/src/pages/storage/editor_view.rs @@ -4,11 +4,11 @@ use super::draft::{PartitionDraft, remaining_region}; use super::{StorageMsg, StoragePage}; -use crate::backend::storage::{DiskSnapshot, PartitionSnapshot, format_size}; -use crate::config_model::{PartitionOperation, PartitionPlan, PartitionRef}; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentSender, adw, gtk}; +use sirius_backend::storage::{DiskSnapshot, PartitionSnapshot, format_size}; +use sirius_core::{PartitionOperation, PartitionPlan, PartitionRef}; /// Build the editor dialog's content. `draft` drives both the segment map /// and the volumes list, and whether the "Discard changes" button appears; diff --git a/crates/sirius-installer/src/pages/storage/page_view.rs b/crates/sirius-app/src/pages/storage/page_view.rs similarity index 82% rename from crates/sirius-installer/src/pages/storage/page_view.rs rename to crates/sirius-app/src/pages/storage/page_view.rs index d23ae3e..6994287 100644 --- a/crates/sirius-installer/src/pages/storage/page_view.rs +++ b/crates/sirius-app/src/pages/storage/page_view.rs @@ -3,10 +3,10 @@ use super::draft::PartitionDraft; use super::{StorageMsg, StoragePage}; -use crate::backend::storage::{DiskSnapshot, format_size}; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentSender, adw, gtk}; +use sirius_backend::storage::{DiskSnapshot, format_size}; pub(super) struct PageView<'a> { pub disks: &'a [DiskSnapshot], @@ -132,8 +132,6 @@ fn disk_selector( return group; } - let mut leader: Option = None; - let mut solo: Option<(adw::ActionRow, gtk::CheckButton)> = None; for &index in &available { let disk = &disks[index]; let row = adw::ActionRow::new(); @@ -145,46 +143,23 @@ fn disk_selector( disk.table_type )); row.add_prefix(>k::Image::from_icon_name("drive-harddisk-symbolic")); + row.add_suffix(&super::super::choice_list::selected_indicator( + selected == Some(index), + )); + row.set_activatable(true); - let radio = gtk::CheckButton::new(); - radio.set_group(leader.as_ref()); - radio.set_active(selected == Some(index)); let page_sender = sender.clone(); - radio.connect_toggled(move |button| { - if button.is_active() { - page_sender.input(StorageMsg::Selected(index)); - } + row.connect_activated(move |_| { + page_sender.input(StorageMsg::Selected(index)); }); - row.add_suffix(&radio); - row.set_activatable_widget(Some(&radio)); - if leader.is_none() { - leader = Some(radio.clone()); - } - solo = Some((row.clone(), radio)); group.add(&row); } - // GTK only draws the round radio indicator once a check button is - // actually grouped with another one; a lone entry renders as a square - // checkbox otherwise. A single-disk system is the common case, so pair - // the sole radio with an invisible anchor purely to get the radio look. - if available.len() == 1 - && let Some((row, radio)) = solo - { - let anchor = gtk::CheckButton::new(); - anchor.set_visible(false); - anchor.set_group(Some(&radio)); - row.add_suffix(&anchor); - } - group } -/// Partitioning-mode picker: one radio row per mode. The HIG favors -/// visible, mutually exclusive radio options for a choice between two -/// flows — a switch reads as "turn a setting on or off", which made the -/// old row ambiguous about what "off" even meant (manual mode was never -/// named on screen). Same radio-row pattern as the disk selector above. +/// Partitioning-mode picker: each mode is a clickable row, with a check mark +/// identifying the active flow. fn mode_selector( state: &PageView<'_>, sender: &ComponentSender, @@ -192,7 +167,6 @@ fn mode_selector( let group = adw::PreferencesGroup::new(); group.set_title(&gettext("Partitioning")); - let mut leader: Option = None; for (title, desc, manual) in [ ( gettext("Automatic partitioning"), @@ -208,21 +182,15 @@ fn mode_selector( let row = adw::ActionRow::new(); row.set_title(&title); row.set_subtitle(&desc); + row.add_suffix(&super::super::choice_list::selected_indicator( + state.manual == manual, + )); + row.set_activatable(true); - let radio = gtk::CheckButton::new(); - radio.set_group(leader.as_ref()); - radio.set_active(state.manual == manual); let page_sender = sender.clone(); - radio.connect_toggled(move |button| { - if button.is_active() { - page_sender.input(StorageMsg::SetManual(manual)); - } + row.connect_activated(move |_| { + page_sender.input(StorageMsg::SetManual(manual)); }); - row.add_suffix(&radio); - row.set_activatable_widget(Some(&radio)); - if leader.is_none() { - leader = Some(radio); - } group.add(&row); } @@ -288,7 +256,7 @@ fn automatic_section(state: &PageView<'_>, sender: &ComponentSender if state.encrypt && (!state.encryption_passphrase.is_empty() || !state.encryption_passphrase_confirm.is_empty()) - && let Err(error) = crate::config_model::validate_encryption_passphrase( + && let Err(error) = sirius_core::validate_encryption_passphrase( state.encryption_passphrase, state.encryption_passphrase_confirm, ) diff --git a/crates/sirius-installer/src/pages/storage/partition_dialog.rs b/crates/sirius-app/src/pages/storage/partition_dialog.rs similarity index 99% rename from crates/sirius-installer/src/pages/storage/partition_dialog.rs rename to crates/sirius-app/src/pages/storage/partition_dialog.rs index 2fd27e7..aeb232e 100644 --- a/crates/sirius-installer/src/pages/storage/partition_dialog.rs +++ b/crates/sirius-app/src/pages/storage/partition_dialog.rs @@ -2,10 +2,10 @@ use super::draft::PartitionSpec; use super::{StorageMsg, StoragePage}; -use crate::backend::storage::{FreeRegion, PartitionSnapshot}; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentSender, adw, gtk}; +use sirius_backend::storage::{FreeRegion, PartitionSnapshot}; const GIB: f64 = 1024.0 * 1024.0 * 1024.0; diff --git a/crates/sirius-installer/src/pages/summary.rs b/crates/sirius-app/src/pages/summary.rs similarity index 93% rename from crates/sirius-installer/src/pages/summary.rs rename to crates/sirius-app/src/pages/summary.rs index d92077c..f6b5980 100644 --- a/crates/sirius-installer/src/pages/summary.rs +++ b/crates/sirius-app/src/pages/summary.rs @@ -2,10 +2,10 @@ //! as a boxed list (manual `SimpleComponent` — rows are rebuilt imperatively). use super::PageOutput; -use crate::config_model::InstallConfig; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw}; +use sirius_core::InstallConfig; /// Build the (label, value) pairs shown on the summary page. Rows belonging /// to a wizard page that is disabled in `sirius.toml` are omitted — the recap @@ -14,7 +14,7 @@ pub fn summary_rows(cfg: &InstallConfig, pages: &[String]) -> Vec<(String, Strin let on = |page: &str| pages.iter().any(|p| p == page); let dash = "—".to_string(); let mut rows = Vec::new(); - if on("welcome") { + if on("language") { rows.push(( gettext("Language"), cfg.locale.clone().unwrap_or_else(|| dash.clone()), @@ -40,10 +40,7 @@ pub fn summary_rows(cfg: &InstallConfig, pages: &[String]) -> Vec<(String, Strin .or_else(|| cfg.destination_disk.clone()) .unwrap_or_else(|| dash.clone()), )); - let encryption = if matches!( - cfg.install_type, - Some(crate::config_model::InstallType::Manual) - ) { + let encryption = if matches!(cfg.install_type, Some(sirius_core::InstallType::Manual)) { gettext("manual layout") } else if cfg.encrypt { gettext("enabled") @@ -156,7 +153,7 @@ mod tests { use super::*; fn all_pages() -> Vec { - ["welcome", "keyboard", "timezone", "storage", "user"] + ["language", "keyboard", "timezone", "storage", "user"] .map(String::from) .to_vec() } @@ -165,7 +162,7 @@ mod tests { fn summary_lists_disk_and_user() { let cfg = InstallConfig { destination_disk: Some("/dev/sda".into()), - user: crate::config_model::UserAccount { + user: sirius_core::UserAccount { full_name: "Ada".into(), username: "ada".into(), ..Default::default() @@ -194,7 +191,7 @@ mod tests { #[test] fn summary_omits_rows_for_disabled_pages() { // Mirror of a live sirius.toml with keyboard/timezone/user disabled. - let pages: Vec = ["welcome", "diagnostics", "network", "storage"] + let pages: Vec = ["welcome", "language", "diagnostics", "network", "storage"] .map(String::from) .to_vec(); let cfg = InstallConfig { diff --git a/crates/sirius-app/src/pages/timezone.rs b/crates/sirius-app/src/pages/timezone.rs new file mode 100644 index 0000000..3836711 --- /dev/null +++ b/crates/sirius-app/src/pages/timezone.rs @@ -0,0 +1,1288 @@ +//! Time-zone page based on GNOME Initial Setup's city search + interactive map. + +use super::PageOutput; +use gettextrs::gettext; +use libgweather as gweather; +use relm4::adw::prelude::*; +use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; +use std::cell::Cell; +use std::path::Path; +use std::rc::Rc; + +const ZONE_TABLES: &[&str] = &[ + "/usr/share/zoneinfo/zone1970.tab", + "/usr/share/zoneinfo/zone.tab", +]; +const DEFAULT_ZONE: &str = "America/Sao_Paulo"; +const MAX_RESULTS: usize = 8; + +// Vector world map generated locally from Natural Earth data +// (tools/generate-timezone-map.py) and rendered through glycin; the pin is +// gnome-initial-setup's pin.png (pages/timezone/data). +const FALLBACK_MAP_SVG: &str = "/usr/share/sirius/timezone-map.svg"; +const DEV_MAP_SVG: &str = "data/images/timezone-map.svg"; +const FALLBACK_PIN: &str = "/usr/share/sirius/timezone-pin.png"; +const DEV_PIN: &str = "data/images/timezone-pin.png"; + +// Projection constants matching data/images/timezone-map.svg (and the PNG +// rendered from it): Miller cylindrical, cropped to 81°N..59°S, no longitude +// offset. The pin hot point mirrors gnome-initial-setup's pin.png. +const PIN_HOT_POINT_X: f64 = 8.0; +const PIN_HOT_POINT_Y: f64 = 15.0; +const LONGITUDE_OFFSET: f64 = 0.0; +const TOP_LATITUDE: f64 = 81.0; +const BOTTOM_LATITUDE: f64 = -59.0; +const MILLER_FULL_RANGE: f64 = 4.606_825_086_76; + +// Native render size of the vector map (2x the gnome-initial-setup bg.png). +const MAP_TEXTURE_WIDTH: u32 = 1600; +const MAP_TEXTURE_HEIGHT: u32 = 818; + +#[derive(Clone, Debug, PartialEq)] +struct Location { + zone: String, + name: String, + region: String, + latitude: f64, + longitude: f64, + search_text: String, +} + +impl Location { + fn city(&self) -> &str { + &self.name + } +} + +pub struct TimezonePage { + root: adw::StatusPage, + search: gtk::SearchEntry, + results: gtk::ListBox, + results_popover: gtk::Popover, + map: gtk::Picture, + pin: gtk::Picture, + band: gtk::Box, + locations: Vec, + filtered: Vec, + selected: usize, + selected_point: Rc>, + band_meridian: Rc>, +} + +#[derive(Debug)] +pub enum TimezoneMsg { + SearchChanged(String), + ResultActivated(usize), + MapClicked { x: f64, y: f64 }, + Retranslate, +} + +pub struct TimezonePageWidgets; + +impl SimpleComponent for TimezonePage { + type Init = (); + type Input = TimezoneMsg; + type Output = PageOutput; + type Root = adw::StatusPage; + type Widgets = TimezonePageWidgets; + + fn init_root() -> Self::Root { + adw::StatusPage::new() + } + + fn init( + _init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + apply_header(&root); + + let locations = load_locations(); + let selected = initial_location(&locations); + let selected_location = &locations[selected]; + let selected_point = Rc::new(Cell::new(( + selected_location.longitude, + selected_location.latitude, + ))); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_width_request(680); + content.set_halign(gtk::Align::Center); + content.set_valign(gtk::Align::Center); + + // GtkSearchEntry with a suggestions popover, mirroring GNOME Maps' + // place search (icon in the entry, results in an opaque popover + // below, keyboard navigation without leaving the entry). + let search = gtk::SearchEntry::new(); + search.set_width_request(420); + search.set_halign(gtk::Align::Center); + search.set_placeholder_text(Some(&gettext("Search for a city"))); + search.set_tooltip_text(Some(&gettext( + "Search for a nearby city to select its time zone", + ))); + { + let sender = sender.clone(); + search.connect_search_changed(move |entry| { + sender.input(TimezoneMsg::SearchChanged(entry.text().to_string())); + }); + } + content.append(&search); + + // Suggestions are ordinary GtkListBox rows. The popover and entry use + // the same explicit width, so the list opens directly below and covers + // the complete input instead of measuring itself from row contents. + // Like GNOME Maps' place popover, nothing inside the popover can take + // focus: keyboard focus never leaves the entry while typing, and the + // entry's arrow keys move the highlighted row (SelectionMode::Single + // is used purely as a visual cursor). The list is a direct child of + // the popover contents, so libadwaita keeps it transparent and the + // popover color stays uniform — no scrolled window in between. + let results = gtk::ListBox::new(); + results.set_selection_mode(gtk::SelectionMode::Single); + results.set_can_focus(false); + results.set_focus_on_click(false); + results.set_hexpand(true); + { + let sender = sender.clone(); + results.connect_row_activated(move |_, row| { + sender.input(TimezoneMsg::ResultActivated(row.index() as usize)); + }); + } + + let results_popover = gtk::Popover::new(); + results_popover.set_has_arrow(false); + results_popover.set_position(gtk::PositionType::Bottom); + results_popover.set_width_request(420); + results_popover.set_can_focus(false); + results_popover.add_css_class("timezone-suggestions"); + results_popover.set_child(Some(&results)); + results_popover.set_parent(&search); + + // Enter activates the highlighted row, or the first suggestion when + // the cursor was never moved. + { + let sender = sender.clone(); + let results = results.clone(); + search.connect_activate(move |_| { + let index = results + .selected_row() + .map(|row| row.index() as usize) + .unwrap_or(0); + sender.input(TimezoneMsg::ResultActivated(index)); + }); + } + + // Arrow keys drive the suggestion cursor from the entry; Escape + // dismisses the popover (PlaceEntry's _onKeyPressed in GNOME Maps). + { + let results = results.clone(); + let results_popover = results_popover.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, _| { + handle_cursor_key(&results, &results_popover, key) + }); + search.add_controller(keys); + } + // If focus ever lands inside the popover, keep the same keys working + // and forward everything else to the entry (GNOME Maps adds a + // bubble-phase key controller to its search popover for this). + { + let cursor_results = results.clone(); + let cursor_popover = results_popover.clone(); + let entry = search.clone(); + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Bubble); + keys.connect_key_pressed(move |controller, key, _, _| { + let propagation = handle_cursor_key(&cursor_results, &cursor_popover, key); + if propagation == gtk::glib::Propagation::Proceed { + controller.forward(&entry); + gtk::glib::Propagation::Stop + } else { + propagation + } + }); + results_popover.add_controller(keys); + } + + let map = gtk::Picture::new(); + if let Some(path) = existing_asset(FALLBACK_MAP_SVG, DEV_MAP_SVG) { + load_svg_map(&map, path); + } + // At 680×348 the map keeps its native aspect ratio while the complete + // page fits in the default 960×640 window without StatusPage scrolling. + map.set_width_request(680); + map.set_height_request(348); + map.set_content_fit(gtk::ContentFit::Fill); + map.set_can_shrink(true); + map.set_hexpand(true); + map.set_overflow(gtk::Overflow::Hidden); + map.set_cursor_from_name(Some("pointer")); + map.set_tooltip_text(Some(&gettext( + "Select the nearest city by clicking the map", + ))); + map.add_css_class("timezone-map"); + { + let sender = sender.clone(); + let click = gtk::GestureClick::new(); + click.set_button(1); + click.connect_pressed(move |_, _, x, y| { + sender.input(TimezoneMsg::MapClicked { x, y }); + }); + map.add_controller(click); + } + + let pin_asset = existing_asset(FALLBACK_PIN, DEV_PIN); + let pin = gtk::Picture::new(); + if let Some(path) = pin_asset { + pin.set_filename(Some(path)); + } + pin.set_halign(gtk::Align::Start); + pin.set_valign(gtk::Align::Start); + // The pin shows the selected city as a tooltip, so it must be + // hoverable; it sits exactly on the selected city, so the handful of + // pixels it keeps away from the map's click gesture cost nothing. + pin.set_can_target(true); + pin.set_has_tooltip(true); + pin.set_visible(pin_asset.is_some()); + + let band = gtk::Box::new(gtk::Orientation::Vertical, 0); + band.add_css_class("timezone-band"); + band.set_halign(gtk::Align::Start); + band.set_valign(gtk::Align::Fill); + band.set_can_target(false); + let band_meridian = Rc::new(Cell::new(zone_meridian(&selected_location.zone))); + + // Widgets have no "width" property to watch; tick callbacks are the + // GTK4 way to react to allocation changes (first map + resizes). + { + let pin = pin.clone(); + let band = band.clone(); + let selected_point = selected_point.clone(); + let band_meridian = band_meridian.clone(); + map.add_tick_callback(move |map, _| { + let width = f64::from(map.width()); + let height = f64::from(map.height()); + if width > 1.0 && height > 1.0 { + position_pin(&pin, selected_point.get(), width, height); + position_band(&band, band_meridian.get(), width); + } + gtk::glib::ControlFlow::Continue + }); + } + + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&map)); + overlay.add_overlay(&band); + overlay.add_overlay(&pin); + let frame = gtk::Frame::new(None); + frame.add_css_class("timezone-map-frame"); + frame.set_child(Some(&overlay)); + content.append(&frame); + root.set_child(Some(&content)); + + let model = TimezonePage { + root: root.clone(), + search, + results, + results_popover, + map, + pin, + band, + locations, + filtered: Vec::new(), + selected, + selected_point, + band_meridian, + }; + model.refresh_selection(&sender); + + ComponentParts { + model, + widgets: TimezonePageWidgets, + } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender) { + match msg { + TimezoneMsg::SearchChanged(query) => self.refresh_results(&query), + TimezoneMsg::ResultActivated(row) => { + if let Some(index) = self.filtered.get(row).copied() { + self.select(index, &sender); + self.search.set_text(""); + self.search.grab_focus(); + } + } + TimezoneMsg::MapClicked { x, y } => { + let width = f64::from(self.map.width()).max(1.0); + let height = f64::from(self.map.height()).max(1.0); + let index = nearest_location(&self.locations, x, y, width, height); + self.select(index, &sender); + } + TimezoneMsg::Retranslate => { + apply_header(&self.root); + self.search + .set_placeholder_text(Some(&gettext("Search for a city"))); + self.search.set_tooltip_text(Some(&gettext( + "Search for a nearby city to select its time zone", + ))); + self.map.set_tooltip_text(Some(&gettext( + "Select the nearest city by clicking the map", + ))); + self.refresh_selection(&sender); + } + } + } +} + +impl TimezonePage { + fn refresh_results(&mut self, query: &str) { + while let Some(child) = self.results.first_child() { + self.results.remove(&child); + } + self.filtered.clear(); + + let query = normalize(query); + if query.is_empty() { + self.results_popover.popdown(); + return; + } + + let mut matches = self + .locations + .iter() + .enumerate() + .filter(|(_, location)| matches_query(&location.search_text, &query)) + .map(|(index, _)| index) + .collect::>(); + matches.sort_by_key(|index| search_rank(&self.locations[*index], &query)); + self.filtered.extend(matches.into_iter().take(MAX_RESULTS)); + + for index in &self.filtered { + let location = &self.locations[*index]; + let labels = gtk::Box::new(gtk::Orientation::Vertical, 2); + labels.set_margin_start(12); + labels.set_margin_end(12); + labels.set_margin_top(8); + labels.set_margin_bottom(8); + + let title = gtk::Label::new(Some(location.city())); + title.set_halign(gtk::Align::Start); + title.set_xalign(0.0); + title.set_ellipsize(gtk::pango::EllipsizeMode::End); + labels.append(&title); + + let subtitle = + gtk::Label::new(Some(&format!("{} · {}", location.region, location.zone))); + subtitle.set_halign(gtk::Align::Start); + subtitle.set_xalign(0.0); + subtitle.set_ellipsize(gtk::pango::EllipsizeMode::End); + subtitle.add_css_class("caption"); + subtitle.add_css_class("dim-label"); + labels.append(&subtitle); + + let row = gtk::ListBoxRow::new(); + row.set_activatable(true); + row.set_focusable(false); + row.set_child(Some(&labels)); + self.results.append(&row); + } + + if self.filtered.is_empty() { + self.results_popover.popdown(); + } else { + self.results_popover.popup(); + } + } + + fn select(&mut self, index: usize, sender: &ComponentSender) { + self.selected = index.min(self.locations.len().saturating_sub(1)); + let location = &self.locations[self.selected]; + self.selected_point + .set((location.longitude, location.latitude)); + self.band_meridian.set(zone_meridian(&location.zone)); + let width = f64::from(self.map.width()).max(1.0); + let height = f64::from(self.map.height()).max(1.0); + position_pin(&self.pin, self.selected_point.get(), width, height); + position_band(&self.band, self.band_meridian.get(), width); + self.refresh_selection(sender); + } + + fn refresh_selection(&self, sender: &ComponentSender) { + let location = &self.locations[self.selected]; + self.pin.set_tooltip_text(Some(&format!( + "{}\n{}", + location.city(), + timezone_detail(&location.zone) + ))); + sender + .output(PageOutput::SetTimezone(location.zone.clone())) + .ok(); + } +} + +impl Drop for TimezonePage { + fn drop(&mut self) { + self.results_popover.unparent(); + } +} + +fn apply_header(root: &adw::StatusPage) { + super::status_header( + root, + &gettext("Time Zone"), + &gettext("Search for a city or select a location on the map to set your time zone."), + ); +} + +fn load_locations() -> Vec { + // Primary source: libgweather's location database (the same one behind + // gnome-initial-setup's search). Only CITY nodes with an IANA zone belong + // in the chooser; countries, administrative regions and weather stations + // may inherit a zone too, but are not valid city search results. The tzdb + // tables stay as a fallback for systems without libgweather data. + let mut locations = gweather_locations(); + if locations.is_empty() { + locations = ZONE_TABLES + .iter() + .find_map(|path| std::fs::read_to_string(path).ok()) + .map(|table| parse_zone_table(&table)) + .unwrap_or_default(); + } + + if locations.is_empty() { + vec![ + fallback("America/Sao_Paulo", -23.55, -46.63), + fallback("America/New_York", 40.71, -74.01), + fallback("Europe/London", 51.51, -0.13), + fallback("Asia/Tokyo", 35.68, 139.69), + fallback("UTC", 0.0, 0.0), + ] + } else { + if !locations.iter().any(|location| location.zone == "UTC") { + locations.push(fallback("UTC", 0.0, 0.0)); + } + locations + } +} + +fn gweather_locations() -> Vec { + let mut locations = Vec::new(); + if let Some(world) = gweather::Location::world() { + collect_gweather(&world, &mut locations); + } + locations +} + +fn collect_gweather(parent: &gweather::Location, locations: &mut Vec) { + let mut child = parent.next_child(None); + while let Some(location) = child { + if location.level() == gweather::LocationLevel::City + && location.has_coords() + && location.has_timezone() + && let Some(zone) = location.timezone_str() + { + let (latitude, longitude) = location.coords(); + let name = location + .name() + .or_else(|| location.english_name()) + .map(|name| name.to_string()) + .unwrap_or_else(|| zone_city(&zone)); + let english_name = location.english_name().unwrap_or_default(); + let sort_name = location.sort_name().unwrap_or_default(); + let english_sort_name = location.english_sort_name().unwrap_or_default(); + let country = location.country_name().unwrap_or_default(); + locations.push(Location { + search_text: normalize(&format!( + "{name} {english_name} {sort_name} {english_sort_name} {country}" + )), + zone: zone.to_string(), + name, + region: country.to_string(), + latitude, + longitude, + }); + } + collect_gweather(&location, locations); + child = parent.next_child(Some(location)); + } +} + +fn parse_zone_table(table: &str) -> Vec { + table + .lines() + .filter(|line| !line.starts_with('#') && !line.trim().is_empty()) + .filter_map(|line| { + let mut fields = line.split('\t'); + let countries = fields.next()?; + let coordinates = fields.next()?; + let zone = fields.next()?.to_string(); + let comment = fields.next().unwrap_or_default(); + let (latitude, longitude) = parse_coordinates(coordinates)?; + let city = zone_city(&zone); + Some(Location { + search_text: normalize(&format!("{city} {zone} {countries} {comment}")), + name: city, + region: if comment.is_empty() { + countries.to_string() + } else { + comment.to_string() + }, + zone, + latitude, + longitude, + }) + }) + .collect() +} + +fn zone_city(zone: &str) -> String { + zone.rsplit('/').next().unwrap_or(zone).replace('_', " ") +} + +fn fallback(zone: &str, latitude: f64, longitude: f64) -> Location { + Location { + zone: zone.into(), + name: zone_city(zone), + region: zone + .split_once('/') + .map(|(region, _)| region.replace('_', " ")) + .unwrap_or_else(|| zone.to_string()), + latitude, + longitude, + search_text: normalize(&zone.replace(['/', '_'], " ")), + } +} + +fn parse_coordinates(value: &str) -> Option<(f64, f64)> { + let split = value + .char_indices() + .skip(1) + .find(|(_, character)| matches!(character, '+' | '-'))? + .0; + Some(( + parse_coordinate(&value[..split], 2)?, + parse_coordinate(&value[split..], 3)?, + )) +} + +fn parse_coordinate(value: &str, degree_digits: usize) -> Option { + let sign = match value.as_bytes().first()? { + b'+' => 1.0, + b'-' => -1.0, + _ => return None, + }; + let digits = value.get(1..)?; + let degrees: f64 = digits.get(..degree_digits)?.parse().ok()?; + let minutes: f64 = digits.get(degree_digits..degree_digits + 2)?.parse().ok()?; + let seconds = digits + .get(degree_digits + 2..) + .filter(|value| !value.is_empty()) + .map(str::parse::) + .transpose() + .ok()? + .unwrap_or(0.0); + Some(sign * (degrees + minutes / 60.0 + seconds / 3600.0)) +} + +fn initial_location(locations: &[Location]) -> usize { + current_timezone() + .and_then(|zone| zone_match(locations, &zone)) + .or_else(|| zone_match(locations, DEFAULT_ZONE)) + .unwrap_or(0) +} + +/// Best location for an auto-detected zone: the city nearest to the zone's +/// representative point from the tzdb table (the zone's main city, e.g. the +/// city of Sao Paulo for America/Sao_Paulo). Picking the first database entry +/// for the zone lands the pin thousands of kilometres off — the first +/// libgweather city in America/Sao_Paulo document order is Tarauaca, in Acre. +fn zone_match(locations: &[Location], zone: &str) -> Option { + let reference = zone_reference_coords(zone); + locations + .iter() + .enumerate() + .filter(|(_, location)| location.zone == zone) + .min_by(|(_, a), (_, b)| { + squared_distance(a, reference).total_cmp(&squared_distance(b, reference)) + }) + .map(|(index, _)| index) +} + +fn squared_distance(location: &Location, reference: Option<(f64, f64)>) -> f64 { + match reference { + Some((latitude, longitude)) => { + (location.latitude - latitude).powi(2) + (location.longitude - longitude).powi(2) + } + // Without a reference point every candidate ties and min_by keeps the + // first match, preserving the old behaviour. + None => 0.0, + } +} + +/// Representative coordinates of a zone from the tzdb tables (ISO 6709 +/// latitude + longitude of the zone's reference city). +fn zone_reference_coords(zone: &str) -> Option<(f64, f64)> { + ZONE_TABLES + .iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .find_map(|table| { + table.lines().find_map(|line| { + if line.starts_with('#') { + return None; + } + let mut fields = line.split('\t'); + fields.next()?; // country codes + let coordinates = fields.next()?; + if fields.next()? == zone { + parse_coordinates(coordinates) + } else { + None + } + }) + }) +} + +fn current_timezone() -> Option { + let target = std::fs::canonicalize("/etc/localtime").ok()?; + target + .strip_prefix(Path::new("/usr/share/zoneinfo")) + .ok() + .map(|path| path.to_string_lossy().into_owned()) +} + +fn nearest_location( + locations: &[Location], + x: f64, + y: f64, + map_width: f64, + map_height: f64, +) -> usize { + locations + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + pixel_distance(a, x, y, map_width, map_height) + .total_cmp(&pixel_distance(b, x, y, map_width, map_height)) + }) + .map(|(index, _)| index) + .unwrap_or(0) +} + +fn pixel_distance(location: &Location, x: f64, y: f64, map_width: f64, map_height: f64) -> f64 { + let dx = longitude_to_x(location.longitude, map_width) - x; + let dy = latitude_to_y(location.latitude, map_height) - y; + dx * dx + dy * dy +} + +fn normalize(value: &str) -> String { + value + .to_lowercase() + .chars() + .map(|character| match character { + 'á' | 'à' | 'â' | 'ã' | 'ä' => 'a', + 'é' | 'è' | 'ê' | 'ë' => 'e', + 'í' | 'ì' | 'î' | 'ï' => 'i', + 'ó' | 'ò' | 'ô' | 'õ' | 'ö' => 'o', + 'ú' | 'ù' | 'û' | 'ü' => 'u', + 'ç' => 'c', + other => other, + }) + .collect::() + .replace(['_', '/', '-', ','], " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn matches_query(search_text: &str, query: &str) -> bool { + let words = search_text.split_whitespace().collect::>(); + query + .split_whitespace() + .all(|term| words.iter().any(|word| word.starts_with(term))) +} + +fn search_rank(location: &Location, query: &str) -> (u8, String) { + let city = normalize(location.city()); + let rank = if city == query { + 0 + } else if city.starts_with(query) { + 1 + } else if city.split_whitespace().any(|word| word.starts_with(query)) { + 2 + } else { + 3 + }; + (rank, city) +} + +fn timezone_detail(zone: &str) -> String { + let Some(timezone) = gtk::glib::TimeZone::from_identifier(Some(zone)) else { + return zone.to_string(); + }; + let Ok(now) = gtk::glib::DateTime::now(&timezone) else { + return zone.to_string(); + }; + let abbreviation = now.format("%Z").ok(); + let offset = now.format("%:::z").ok(); + let time = now.format("%R").ok(); + match (abbreviation, offset, time) { + (Some(abbreviation), Some(offset), Some(time)) => { + format!("{zone} · {abbreviation} (UTC{offset}) · {time}") + } + _ => zone.to_string(), + } +} + +fn existing_asset<'a>(installed: &'a str, dev: &'a str) -> Option<&'a str> { + if Path::new(installed).is_file() { + Some(installed) + } else if Path::new(dev).is_file() { + Some(dev) + } else { + None + } +} + +/// Render the vector map on a dedicated thread and swap it into the picture +/// once ready. glycin's whole runtime (zbus, sandboxed loaders, subprocess +/// watches) is async-io based; driving it from the glib main context can +/// deadlock, so it runs under `async_io::block_on` off the UI thread and the +/// finished texture hops back through a oneshot channel awaited on the main +/// context. +fn load_svg_map(map: >k::Picture, path: &str) { + let (sender, receiver) = futures_channel::oneshot::channel::(); + gtk::glib::MainContext::default().spawn_local({ + let map = map.clone(); + async move { + if let Ok(texture) = receiver.await { + map.set_paintable(Some(&texture)); + } + } + }); + let path = path.to_string(); + std::thread::spawn(move || { + let Some((texture, _)) = render_map_texture(&path) else { + tracing::warn!("glycin could not render the timezone map SVG"); + return; + }; + let _ = sender.send(texture); + }); +} + +/// Blocking: decode the SVG at native size and repack the frame bytes into a +/// gdk texture plus the raw pixels. Call off the UI thread. +/// +/// glycin 2.x cannot hand us a `gdk::Texture` directly (its `gdk4` feature +/// tracks an older gtk4-rs than relm4 0.10), hence the manual repack. +fn render_map_texture(path: &str) -> Option<(gtk::gdk::MemoryTexture, Vec)> { + let image = async_io::block_on(glycin::Loader::new(glycin_gio::File::for_path(path)).load()) + .map_err(|err| tracing::warn!("glycin failed to load the timezone map: {err}")) + .ok()?; + let frame = + async_io::block_on(image.specific_frame( + glycin::FrameRequest::new().scale(MAP_TEXTURE_WIDTH, MAP_TEXTURE_HEIGHT), + )) + .map_err(|err| tracing::warn!("glycin failed to decode the timezone map: {err}")) + .ok()?; + let format = gdk_memory_format(frame.memory_format()) + .ok_or_else(|| tracing::warn!("glycin returned an unsupported pixel format")) + .ok()?; + let texture = gtk::gdk::MemoryTexture::new( + frame.width() as i32, + frame.height() as i32, + format, + >k::glib::Bytes::from(frame.buf_slice()), + frame.stride() as usize, + ); + Some((texture, frame.buf_slice().to_vec())) +} + +fn gdk_memory_format(format: glycin::MemoryFormat) -> Option { + use glycin::MemoryFormat as Glycin; + use gtk::gdk::MemoryFormat as Gdk; + Some(match format { + Glycin::B8g8r8a8Premultiplied => Gdk::B8g8r8a8Premultiplied, + Glycin::A8r8g8b8Premultiplied => Gdk::A8r8g8b8Premultiplied, + Glycin::R8g8b8a8Premultiplied => Gdk::R8g8b8a8Premultiplied, + Glycin::B8g8r8a8 => Gdk::B8g8r8a8, + Glycin::A8r8g8b8 => Gdk::A8r8g8b8, + Glycin::R8g8b8a8 => Gdk::R8g8b8a8, + Glycin::R8g8b8 => Gdk::R8g8b8, + Glycin::B8g8r8 => Gdk::B8g8r8, + _ => return None, + }) +} + +// Pixel-space projection used by gnome-initial-setup's cc-timezone-map.c. +fn longitude_to_x(longitude: f64, map_width: f64) -> f64 { + map_width * (180.0 + longitude) / 360.0 + map_width * LONGITUDE_OFFSET / 180.0 +} + +fn miller(latitude: f64) -> f64 { + 1.25 * (std::f64::consts::FRAC_PI_4 + 0.4 * latitude.to_radians()) + .tan() + .ln() +} + +fn latitude_to_y(latitude: f64, map_height: f64) -> f64 { + let top_offset = MILLER_FULL_RANGE * (TOP_LATITUDE / 180.0); + let map_range = (miller(BOTTOM_LATITUDE) - top_offset).abs(); + (miller(latitude) - top_offset).abs() / map_range * map_height +} + +fn position_pin( + pin: >k::Picture, + (longitude, latitude): (f64, f64), + map_width: f64, + map_height: f64, +) { + let x = longitude_to_x(longitude, map_width) + .floor() + .clamp(0.0, map_width); + let y = latitude_to_y(latitude, map_height) + .floor() + .clamp(0.0, map_height); + let start = (x - PIN_HOT_POINT_X).round().max(0.0) as i32; + let top = (y - PIN_HOT_POINT_Y).round().max(0.0) as i32; + if pin.margin_start() != start { + pin.set_margin_start(start); + } + if pin.margin_top() != top { + pin.set_margin_top(top); + } +} + +/// Keyboard handling shared by the search entry and the popover, mirroring +/// `PlaceEntry._onKeyPressed` in GNOME Maps: Escape dismisses the list and +/// Up/Down move the highlighted row (popping the list back up on first Down) +/// — all without moving keyboard focus out of the entry. +fn handle_cursor_key( + results: >k::ListBox, + popover: >k::Popover, + key: gtk::gdk::Key, +) -> gtk::glib::Propagation { + use gtk::gdk::Key; + match key { + Key::Escape => { + results.unselect_all(); + popover.popdown(); + gtk::glib::Propagation::Stop + } + Key::Up | Key::KP_Up | Key::Down | Key::KP_Down => { + let direction: i32 = if matches!(key, Key::Up | Key::KP_Up) { + -1 + } else { + 1 + }; + let mut count = 0; + while results.row_at_index(count).is_some() { + count += 1; + } + if count == 0 { + return gtk::glib::Propagation::Proceed; + } + if !popover.is_visible() { + if direction > 0 { + popover.popup(); + if let Some(row) = results.row_at_index(0) { + results.select_row(Some(&row)); + } + } + return gtk::glib::Propagation::Stop; + } + let wrap_from = if direction > 0 { -1 } else { count }; + let current = results + .selected_row() + .map(|row| row.index()) + .unwrap_or(wrap_from); + let next = (current + direction).clamp(0, count - 1); + if let Some(row) = results.row_at_index(next) { + results.select_row(Some(&row)); + } + gtk::glib::Propagation::Stop + } + _ => gtk::glib::Propagation::Proceed, + } +} + +// The selected zone's UTC offset maps to a 15°-wide meridian band, like the +// strip GNOME's Date & Time panel highlights for the active time zone. +fn zone_meridian(zone: &str) -> f64 { + gtk::glib::TimeZone::from_identifier(Some(zone)) + .and_then(|timezone| gtk::glib::DateTime::now(&timezone).ok()) + .map(|now| now.utc_offset().as_microseconds() as f64 / 3_600_000_000.0 * 15.0) + .unwrap_or(0.0) +} + +fn position_band(band: >k::Box, meridian: f64, map_width: f64) { + let x1 = longitude_to_x(meridian - 7.5, map_width).clamp(0.0, map_width); + let x2 = longitude_to_x(meridian + 7.5, map_width).clamp(0.0, map_width); + let start = x1.round() as i32; + let width = (x2 - x1).round().max(1.0) as i32; + if band.margin_start() != start { + band.set_margin_start(start); + } + if band.width_request() != width { + band.set_width_request(width); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use relm4::{Component, ComponentController}; + + #[test] + fn parses_iso_6709_coordinates_with_and_without_seconds() { + let (latitude, longitude) = parse_coordinates("-2332-04637").unwrap(); + assert!((latitude - -23.533_333).abs() < 0.000_001); + assert!((longitude - -46.616_667).abs() < 0.000_001); + + let (latitude, longitude) = parse_coordinates("+404251-0740023").unwrap(); + assert!((latitude - 40.714_167).abs() < 0.000_001); + assert!((longitude - -74.006_389).abs() < 0.000_001); + } + + #[test] + fn parses_the_system_zone_table_format() { + let locations = parse_zone_table( + "BR\t-2332-04637\tAmerica/Sao_Paulo\tBrazil southeast\n\ + GB\t+513030-0000731\tEurope/London\n", + ); + assert_eq!(locations.len(), 2); + assert_eq!(locations[0].zone, "America/Sao_Paulo"); + assert_eq!(locations[0].name, "Sao Paulo"); + assert_eq!(locations[0].region, "Brazil southeast"); + assert_eq!(locations[1].name, "London"); + assert_eq!(locations[1].region, "GB"); + } + + #[test] + fn city_search_matches_names_and_regions_but_not_timezone_ids() { + let location = Location { + zone: "America/Denver".into(), + name: "Boulder".into(), + region: "United States".into(), + latitude: 40.01, + longitude: -105.27, + search_text: normalize("Boulder United States"), + }; + + assert!(matches_query(&location.search_text, "boul")); + assert!(matches_query(&location.search_text, "boul uni")); + assert!(!matches_query(&location.search_text, "denver")); + } + + #[test] + fn auto_detected_zone_prefers_the_city_nearest_the_zone_reference() { + // Both cities share America/Sao_Paulo. Tarauaca comes first in the + // libgweather database, but the zone's tzdb reference point is the + // city of Sao Paulo, so the nearest city must win. + let locations = vec![ + Location { + zone: "America/Sao_Paulo".into(), + name: "Tarauacá".into(), + region: "Brazil".into(), + latitude: -8.166_667, + longitude: -70.766_667, + search_text: normalize("Tarauaca Brazil"), + }, + Location { + zone: "America/Sao_Paulo".into(), + name: "Guarulhos".into(), + region: "Brazil".into(), + latitude: -23.466_667, + longitude: -46.533_333, + search_text: normalize("Guarulhos Brazil"), + }, + ]; + let index = zone_match(&locations, "America/Sao_Paulo").unwrap(); + if zone_reference_coords("America/Sao_Paulo").is_some() { + assert_eq!(locations[index].name, "Guarulhos"); + } else { + // Without a tzdb table the first match is kept. + assert_eq!(locations[index].name, "Tarauacá"); + } + } + + #[test] + fn zone_reference_coordinates_come_from_the_tzdb_table() { + let Some((latitude, longitude)) = zone_reference_coords("America/Sao_Paulo") else { + eprintln!("skipping: no tzdb table available"); + return; + }; + assert!((latitude - -23.533_333).abs() < 0.001); + assert!((longitude - -46.616_667).abs() < 0.001); + } + + #[test] + fn libgweather_results_are_zoned_cities_only() { + let locations = gweather_locations(); + assert!( + !locations.is_empty(), + "libgweather must provide city data on supported systems" + ); + // The database currently contains thousands more weather stations, + // countries and administrative regions than cities. A regression to + // collecting every zoned node makes this bound fail conspicuously. + assert!( + locations.len() < 6_000, + "only city nodes should be collected, got {} locations", + locations.len() + ); + } + + #[test] + fn finds_the_nearest_city_on_the_map() { + let locations = vec![ + fallback("America/Sao_Paulo", -23.55, -46.63), + fallback("Europe/London", 51.51, -0.13), + ]; + let (width, height) = (800.0, 409.0); + let click = |longitude, latitude| { + nearest_location( + &locations, + longitude_to_x(longitude, width), + latitude_to_y(latitude, height), + width, + height, + ) + }; + assert_eq!(click(-44.0, -22.0), 0); + assert_eq!(click(1.0, 50.0), 1); + } + + #[test] + fn projects_known_cities_inside_the_map_bounds() { + let (width, height) = (800.0, 409.0); + for (longitude, latitude) in [(-46.63, -23.55), (-0.13, 51.51), (139.69, 35.68)] { + let x = longitude_to_x(longitude, width); + let y = latitude_to_y(latitude, height); + assert!((0.0..=width).contains(&x), "x out of bounds: {x}"); + assert!((0.0..=height).contains(&y), "y out of bounds: {y}"); + } + // With no longitude offset baked into the artwork, the horizontal + // center of the image is the prime meridian. + assert!((longitude_to_x(0.0, width) - width / 2.0).abs() < 1.0); + } + + // Interactive test: needs a display (skipped on headless CI). Drives the + // real component to catch wiring bugs that pure-logic tests cannot. + #[test] + fn positions_pin_and_opens_full_width_suggestions_below_input() { + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + eprintln!("skipping interactive test: no display available"); + return; + } + gtk::init().expect("gtk init"); + adw::init().expect("adw init"); + + let controller = TimezonePage::builder().launch(()); + let carousel = adw::Carousel::new(); + controller.widget().set_margin_start(72); + controller.widget().set_margin_end(72); + carousel.append(controller.widget()); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.set_content(Some(&carousel)); + let window = adw::Window::new(); + window.set_content(Some(&toolbar)); + window.set_default_size(960, 640); + window.present(); + + let pump = |millis: u64| { + let context = gtk::glib::MainContext::default(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(millis); + while std::time::Instant::now() < deadline { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }; + pump(500); + + // Clone widget handles out in short borrows: holding model() across a + // pump deadlocks relm4's update loop (RefCell already borrowed). + let (search, map, location) = { + let model = controller.model(); + assert!(model.map.width() > 100, "map must be allocated"); + ( + model.search.clone(), + model.map.clone(), + model.locations[model.selected].clone(), + ) + }; + + assert!( + !has_mapped_scrollbar(controller.widget().upcast_ref()), + "the timezone page must not show a scrollbar at the default window size" + ); + + let (width, height) = (f64::from(map.width()), f64::from(map.height())); + let pin_x = longitude_to_x(location.longitude, width) + .floor() + .clamp(0.0, width); + let pin_y = latitude_to_y(location.latitude, height) + .floor() + .clamp(0.0, height); + let expected_pin = ( + (pin_x - PIN_HOT_POINT_X).round().max(0.0) as i32, + (pin_y - PIN_HOT_POINT_Y).round().max(0.0) as i32, + ); + let pin_margins = { + let model = controller.model(); + (model.pin.margin_start(), model.pin.margin_top()) + }; + assert_eq!( + pin_margins, expected_pin, + "pin must sit on the initially selected city" + ); + + let meridian = zone_meridian(&location.zone); + let band_x1 = longitude_to_x(meridian - 7.5, width).clamp(0.0, width); + let band_x2 = longitude_to_x(meridian + 7.5, width).clamp(0.0, width); + let expected_band = ( + band_x1.round() as i32, + (band_x2 - band_x1).round().max(1.0) as i32, + ); + let band_geometry = { + let model = controller.model(); + (model.band.margin_start(), model.band.width_request()) + }; + assert_eq!( + band_geometry, expected_band, + "band must cover the selected zone's meridian strip" + ); + + search.set_text("denver"); + pump(500); + let (popover_request, input_request, first_row_is_plain, zones) = { + let model = controller.model(); + let first_row = model.results.row_at_index(0).expect("Denver result"); + assert!( + !first_row.is_focusable(), + "suggestion rows must never take keyboard focus away from the input" + ); + assert!( + !model.results.can_focus() && !model.results_popover.can_focus(), + "nothing inside the suggestions popover may take keyboard focus" + ); + ( + model.results_popover.width_request(), + model.search.width_request(), + first_row + .child() + .is_some_and(|child| child.is::()), + model + .filtered + .iter() + .map(|index| model.locations[*index].zone.clone()) + .collect::>(), + ) + }; + assert!(first_row_is_plain, "suggestions must use normal list rows"); + assert_eq!( + popover_request, input_request, + "suggestion list and input must request exactly the same width" + ); + assert!( + !zones.is_empty() && zones.iter().all(|zone| zone == "America/Denver"), + "Denver suggestions must resolve to America/Denver, got {zones:?}" + ); + search.emit_activate(); + pump(500); + let selected_zone = { + let model = controller.model(); + model.locations[model.selected].zone.clone() + }; + assert_eq!(selected_zone, "America/Denver"); + + // Enter honors the row highlighted through the arrow-key cursor + // instead of always picking the first suggestion. + search.set_text("london"); + pump(500); + let expected_zone = { + let model = controller.model(); + assert!(model.filtered.len() > 1, "london must match several rows"); + let second = model.results.row_at_index(1).unwrap(); + model.results.select_row(Some(&second)); + model.locations[model.filtered[1]].zone.clone() + }; + search.emit_activate(); + pump(500); + let selected_zone = { + let model = controller.model(); + model.locations[model.selected].zone.clone() + }; + assert_eq!(selected_zone, expected_zone); + + // Cities absent from the tzdb table (the original search complaint) + // resolve through libgweather's database to the right zone. + search.set_text("curitiba"); + pump(500); + search.emit_activate(); + pump(500); + let selected_zone = { + let model = controller.model(); + model.locations[model.selected].zone.clone() + }; + assert_eq!(selected_zone, "America/Sao_Paulo"); + + window.close(); + pump(100); + } + + fn has_mapped_scrollbar(widget: >k::Widget) -> bool { + if widget.is::() && widget.is_mapped() { + return true; + } + + let mut child = widget.first_child(); + while let Some(current) = child { + if has_mapped_scrollbar(¤t) { + return true; + } + child = current.next_sibling(); + } + false + } + + // Exercises the real glycin pipeline (sandboxed loader over D-Bus) against + // the shipped SVG: the frame must come back at the requested native size. + // Skipped without a session bus. + #[test] + fn renders_svg_map_through_glycin() { + if std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_none() { + eprintln!("skipping glycin test: no session bus"); + return; + } + let svg = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../data/images/timezone-map.svg" + ); + let _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) + .try_init(); + let (texture, pixels) = render_map_texture(svg).expect("glycin must decode the SVG"); + assert_eq!( + (texture.width(), texture.height()), + (MAP_TEXTURE_WIDTH as i32, MAP_TEXTURE_HEIGHT as i32) + ); + // Land is opaque white, the ocean opaque GNOME blue; a broken render + // (blank/transparent) must not pass silently. + assert_eq!( + pixels.len() as u32, + MAP_TEXTURE_WIDTH * MAP_TEXTURE_HEIGHT * 4 + ); + + if std::env::var_os("SIRIUS_GLYCIN_DUMP").is_some() { + std::fs::write("/tmp/tzmap.rgba", &pixels).unwrap(); + eprintln!( + "dumped {}x{} frame to /tmp/tzmap.rgba", + texture.width(), + texture.height() + ); + } + } +} diff --git a/crates/sirius-installer/src/pages/user.rs b/crates/sirius-app/src/pages/user.rs similarity index 99% rename from crates/sirius-installer/src/pages/user.rs rename to crates/sirius-app/src/pages/user.rs index 21287e8..d13594b 100644 --- a/crates/sirius-installer/src/pages/user.rs +++ b/crates/sirius-app/src/pages/user.rs @@ -2,10 +2,10 @@ //! Emits the complete draft on every keystroke. The root state owns validation. use super::PageOutput; -use crate::config_model::UserAccount; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; +use sirius_core::UserAccount; #[derive(Default)] pub struct UserPage { diff --git a/crates/sirius-app/src/pages/welcome.rs b/crates/sirius-app/src/pages/welcome.rs new file mode 100644 index 0000000..7ebc986 --- /dev/null +++ b/crates/sirius-app/src/pages/welcome.rs @@ -0,0 +1,131 @@ +//! Branded installer entry point shown before any configuration question. + +use super::PageOutput; +use gettextrs::gettext; +use relm4::adw::prelude::*; +use relm4::{ComponentParts, ComponentSender, SimpleComponent, gtk}; +use sirius_core::Branding; +use std::path::Path; + +const FALLBACK_BANNER: &str = "/usr/share/sirius/welcome-banner.png"; +const DEV_BANNER: &str = "data/images/welcome-banner.png"; + +pub struct WelcomePage { + branding: Branding, +} + +#[derive(Debug)] +pub enum WelcomeMsg { + Begin, + Retranslate, +} + +#[relm4::component(pub)] +impl SimpleComponent for WelcomePage { + type Init = Branding; + type Input = WelcomeMsg; + type Output = PageOutput; + + view! { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 24, + set_halign: gtk::Align::Center, + set_valign: gtk::Align::Center, + set_margin_top: 24, + set_margin_bottom: 32, + + #[name = "banner"] + gtk::Picture { + set_width_request: 760, + set_height_request: 300, + set_content_fit: gtk::ContentFit::Cover, + set_can_shrink: true, + add_css_class: "welcome-banner", + }, + + gtk::Label { + add_css_class: "title-1", + #[watch] + set_label: &welcome_title(&model.branding), + }, + + gtk::Button { + add_css_class: "install-pill", + add_css_class: "suggested-action", + set_halign: gtk::Align::Center, + #[watch] + set_label: &button_label(&model.branding), + connect_clicked => WelcomeMsg::Begin, + }, + } + } + + fn init( + branding: Self::Init, + _root: Self::Root, + _sender: ComponentSender, + ) -> ComponentParts { + let model = WelcomePage { branding }; + let widgets = view_output!(); + widgets + .banner + .set_filename(Some(banner_path(&model.branding))); + ComponentParts { model, widgets } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender) { + match msg { + WelcomeMsg::Begin => { + sender.output(PageOutput::RequestNext).ok(); + } + WelcomeMsg::Retranslate => {} + } + } +} + +fn banner_path(branding: &Branding) -> &str { + let configured = branding + .welcome_banner + .as_deref() + .unwrap_or(FALLBACK_BANNER); + if Path::new(configured).is_file() { + configured + } else if Path::new(DEV_BANNER).is_file() { + DEV_BANNER + } else { + configured + } +} + +fn welcome_title(branding: &Branding) -> String { + branding + .name + .as_ref() + .map(|name| gettext("Welcome to {name}").replace("{name}", name)) + .unwrap_or_else(|| gettext("Welcome")) +} + +fn button_label(branding: &Branding) -> String { + branding + .welcome_button + .as_ref() + .map(|label| label.replace("{name}", branding.name.as_deref().unwrap_or_default())) + .unwrap_or_else(|| gettext("Start Installation")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn button_template_expands_distro_name() { + let branding = Branding { + name: Some("Example OS".into()), + welcome_button: Some("Install {name}".into()), + ..Branding::default() + }; + assert_eq!(button_label(&branding), "Install Example OS"); + assert_eq!(welcome_title(&branding), "Welcome to Example OS"); + } +} diff --git a/crates/sirius-installer/src/style.rs b/crates/sirius-app/src/style.rs similarity index 100% rename from crates/sirius-installer/src/style.rs rename to crates/sirius-app/src/style.rs diff --git a/crates/sirius-app/tests/language_switch.rs b/crates/sirius-app/tests/language_switch.rs new file mode 100644 index 0000000..e0421d5 --- /dev/null +++ b/crates/sirius-app/tests/language_switch.rs @@ -0,0 +1,24 @@ +use gettextrs::{ + LocaleCategory, bind_textdomain_codeset, bindtextdomain, gettext, setlocale, textdomain, +}; +use sirius_app::set_ui_language; +use std::path::PathBuf; + +#[test] +fn gettext_switches_from_portuguese_back_to_english() { + setlocale(LocaleCategory::LcAll, ""); + setlocale(LocaleCategory::LcMessages, "en_US.UTF-8"); + let locale_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/locale"); + bind_textdomain_codeset("sirius", "UTF-8").unwrap(); + bindtextdomain("sirius", locale_dir).unwrap(); + textdomain("sirius").unwrap(); + + set_ui_language("en_US"); + assert_eq!(gettext("Language"), "Language"); + + set_ui_language("pt_BR"); + assert_eq!(gettext("Language"), "Idioma"); + + set_ui_language("en_US"); + assert_eq!(gettext("Language"), "Language"); +} diff --git a/crates/sirius-backend/Cargo.toml b/crates/sirius-backend/Cargo.toml new file mode 100644 index 0000000..a3abfbc --- /dev/null +++ b/crates/sirius-backend/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "sirius-backend" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "System integrations and privileged installer backend for Sirius" + +[dependencies] +sirius-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +gettext-rs = { workspace = true } +libc = { workspace = true } +lsblk = { workspace = true } +uuid = { workspace = true } +zbus = { workspace = true } +zvariant = { workspace = true } +libreadymade = { workspace = true } diff --git a/crates/sirius-backend/src/distro.rs b/crates/sirius-backend/src/distro.rs new file mode 100644 index 0000000..2a0c1fe --- /dev/null +++ b/crates/sirius-backend/src/distro.rs @@ -0,0 +1,16 @@ +//! Loading of the root-owned distribution descriptor. + +use sirius_core::DistroDescriptor; + +/// Default descriptor path shipped by a live distribution. +pub const DISTRO_PATH: &str = "/etc/sirius/distro.toml"; + +/// Load the installed descriptor, with the in-tree data file as a development +/// fallback. +pub fn load() -> Result { + let source = std::fs::read_to_string(DISTRO_PATH) + .or_else(|_| std::fs::read_to_string("data/distro.toml")) + .map_err(|error| format!("cannot read distro descriptor ({DISTRO_PATH}): {error}"))?; + DistroDescriptor::from_toml(&source) + .map_err(|error| format!("invalid distro descriptor: {error}")) +} diff --git a/crates/sirius-backend/src/install.rs b/crates/sirius-backend/src/install.rs new file mode 100644 index 0000000..a1d0d8a --- /dev/null +++ b/crates/sirius-backend/src/install.rs @@ -0,0 +1,174 @@ +//! Converts collected choices into an install request and libreadymade playbook. +//! +//! # Privilege boundary +//! +//! The request carries ONLY the user's choices (disk, encryption, locale, +//! account). What gets installed — the bootc image, repart layout — is read by +//! the privileged runner itself from the root-owned descriptor at +//! `/etc/sirius/distro.toml`. The unprivileged UI must not be able to point the +//! root process at an arbitrary image or repart directory. + +use sirius_core::{DistroDescriptor, InstallConfig, InstallRequest, InstallType}; + +/// Build the wire request from collected UI config. +/// Returns an error string naming the first missing/invalid required field. +pub fn build_request(cfg: &InstallConfig) -> Result { + let target_disk = cfg + .destination_disk + .clone() + .ok_or("no destination disk selected")?; + let install_type = cfg.install_type.ok_or("no partition mode selected")?; + let encrypt = matches!(install_type, InstallType::Encrypted) || cfg.encrypt; + if matches!(install_type, InstallType::Manual) { + let plan = cfg + .partition_plan + .as_ref() + .ok_or("manual partitioning has no partition plan")?; + if plan.disk_path != target_disk { + return Err("manual partition plan targets a different disk".into()); + } + plan.validate(std::path::Path::new("/sys/firmware/efi").exists())?; + } + if encrypt { + cfg.validate_encryption()?; + } + if !cfg.user.is_empty() { + cfg.user.validate()?; + } + Ok(InstallRequest { + target_disk, + encrypt, + tpm: cfg.tpm && encrypt, + encryption_key: if encrypt { + cfg.encryption_passphrase.clone() + } else { + String::new() + }, + locale: cfg.locale.clone().unwrap_or_else(|| "en_US".into()), + keyboard: cfg.keyboard.clone().unwrap_or_else(|| "us".into()), + timezone: cfg.timezone.clone().unwrap_or_else(|| "UTC".into()), + hostname: cfg.user.hostname.clone(), + username: cfg.user.username.clone(), + full_name: cfg.user.full_name.clone(), + partition_plan: cfg.partition_plan.clone(), + }) +} + +/// Construct the real libreadymade [`Playbook`] from a validated request plus +/// the root-owned distro descriptor. +/// +/// Runs on the privileged side after the request has crossed the pkexec +/// boundary as JSON; `distro` is loaded there from the root-owned +/// `/etc/sirius/distro.toml`, never taken from the request. +/// +/// # Postinstall coverage +/// +/// The current libreadymade `postinstall::Module` enum exposes +/// only these variants: `SELinux`, `Dracut`, `ReinstallKernel`, `GRUB2`, +/// `CleanupBoot`, `PrepareFedora`, `EfiStub { distro_name }`, `InitialSetup`, +/// `Language { lang }`, `Keyboard { layout, variant }`, `CryptSetup`, `Script`, +/// `Fstab`. There is **no** module for setting the hostname, creating the user +/// account, or the timezone. We therefore: +/// +/// - map `locale` -> `Module::Language { lang }`, +/// - map the canonical XKB `layout[+variant]` id to `Module::Keyboard`, and +/// - emit `Module::InitialSetup`, which writes `/.unconfigured` to trigger +/// the distribution's first-boot setup agent (e.g. gnome-initial-setup) where the user +/// account and hostname are configured on next boot. +/// +/// `username`/`full_name`/`hostname`/`timezone` are carried on the request but +/// have no upstream module to consume them here — see the report. +pub fn into_playbook( + request: InstallRequest, + distro: &DistroDescriptor, + manual_mounts: Option, +) -> libreadymade::playbook::Playbook { + use libreadymade::backend::postinstall::Module; + use libreadymade::backend::postinstall::initial_setup::InitialSetup; + use libreadymade::backend::postinstall::keyboard::Keyboard; + use libreadymade::backend::postinstall::language::Language; + use libreadymade::backend::provisioners::disk::manual::Manual; + use libreadymade::backend::provisioners::disk::repart::Repart; + use libreadymade::backend::provisioners::filesystem::Bootc; + use libreadymade::backend::provisioners::{DiskProvisioner, FileSystemProvisioner}; + use libreadymade::playbook::{EncryptionConfig, Playbook}; + use std::path::PathBuf; + + let encryption = request.encrypt.then_some(EncryptionConfig { + tpm: request.tpm, + encryption_key: request.encryption_key, + }); + + let disk_provisioner = if let Some(mounts) = manual_mounts { + DiskProvisioner::Manual(Manual { mounts }) + } else { + DiskProvisioner::Repart(Repart { + directory: PathBuf::from(distro.disk.repart_dir.clone()), + copy_source: None, + }) + }; + + // Keep the target checkout writable while post-install modules apply the + // selected locale and first-boot state. libreadymade invokes the official + // `bootc install finalize` operation after those changes are complete. + let mut bootc_args = distro.bootc.args.clone(); + if !bootc_args.iter().any(|arg| arg == "--skip-finalize") { + bootc_args.push("--skip-finalize".into()); + } + let filesystem_provisioner = Some(FileSystemProvisioner::Bootc(Bootc { + imgref: distro.bootc.image.clone(), + target_imgref: distro.bootc.target_imgref.clone(), + enforce_sigpolicy: distro.bootc.enforce_sigpolicy, + kargs: distro.bootc.kargs.clone(), + args: bootc_args, + })); + + let (keyboard_layout, keyboard_variant) = + parse_xkb_id(&request.keyboard).unwrap_or_else(|| ("us".into(), None)); + let postinstall = vec![ + Module::Language(Language { + lang: request.locale, + }), + Module::Keyboard(Keyboard { + layout: keyboard_layout, + variant: keyboard_variant, + }), + Module::InitialSetup(InitialSetup), + ]; + + Playbook { + destination_disk: PathBuf::from(request.target_disk), + encryption, + disk_provisioner, + filesystem_provisioner, + postinstall, + } +} + +/// Split GNOME Desktop's canonical XKB id (`layout` or `layout+variant`) while +/// keeping untrusted request data out of the generated Xorg configuration. +pub(crate) fn parse_xkb_id(id: &str) -> Option<(String, Option)> { + let mut parts = id.split('+'); + let layout = parts.next()?; + let variant = parts.next(); + if parts.next().is_some() + || !valid_xkb_component(layout) + || variant.is_some_and(|value| !valid_xkb_component(value)) + { + return None; + } + Some(( + layout.into(), + variant.filter(|value| !value.is_empty()).map(Into::into), + )) +} + +fn valid_xkb_component(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +#[cfg(test)] +mod tests; diff --git a/crates/sirius-backend/src/install/tests.rs b/crates/sirius-backend/src/install/tests.rs new file mode 100644 index 0000000..e6e4e06 --- /dev/null +++ b/crates/sirius-backend/src/install/tests.rs @@ -0,0 +1,183 @@ +use super::*; +use sirius_core::{InstallType, UserAccount}; + +fn descriptor() -> DistroDescriptor { + use sirius_core::{BootcConfig, DiskConfig}; + DistroDescriptor { + bootc: BootcConfig { + image: "docker://ghcr.io/example/os:latest".into(), + target_imgref: None, + enforce_sigpolicy: false, + kargs: vec![], + args: vec![], + }, + disk: DiskConfig { + repart_dir: "/usr/share/sirius/repart.d".into(), + }, + bentos: vec![], + branding: Default::default(), + } +} + +fn full_config() -> InstallConfig { + InstallConfig { + locale: Some("pt_BR".into()), + keyboard: Some("br".into()), + timezone: Some("America/Sao_Paulo".into()), + destination_disk: Some("/dev/sda".into()), + destination_disk_name: Some("Test Disk".into()), + install_type: Some(InstallType::Encrypted), + partition_plan: None, + encrypt: false, + tpm: true, + encryption_passphrase: "correct horse battery staple".into(), + encryption_passphrase_confirm: "correct horse battery staple".into(), + user: UserAccount { + full_name: "Ada Lovelace".into(), + username: "ada".into(), + password: "hunter2hunter".into(), + password_confirm: "hunter2hunter".into(), + hostname: "localhost".into(), + }, + } +} + +#[test] +fn builds_request_from_full_config() { + let req = build_request(&full_config()).unwrap(); + assert_eq!(req.target_disk, "/dev/sda"); + assert!(req.encrypt); + assert!(req.tpm); + assert_eq!(req.timezone, "America/Sao_Paulo"); + // The LUKS key is the dedicated passphrase, not the account password. + assert_eq!(req.encryption_key, "correct horse battery staple"); +} + +#[test] +fn request_carries_no_image_or_repart_fields() { + // The privilege boundary: what gets installed comes from the root-owned + // descriptor, never from the unprivileged request. + let req = build_request(&full_config()).unwrap(); + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("bootc")); + assert!(!json.contains("repart")); +} + +#[test] +fn missing_disk_errors() { + let mut cfg = full_config(); + cfg.destination_disk = None; + let err = build_request(&cfg).unwrap_err(); + assert_eq!(err, "no destination disk selected"); +} + +#[test] +fn tpm_requires_encryption() { + let mut cfg = full_config(); + cfg.install_type = Some(InstallType::WholeDisk); + cfg.encrypt = false; + cfg.tpm = true; + let req = build_request(&cfg).unwrap(); + assert!(!req.encrypt); + assert!(!req.tpm); +} + +#[test] +fn no_encryption_key_when_plaintext() { + let mut cfg = full_config(); + cfg.install_type = Some(InstallType::WholeDisk); + cfg.encrypt = false; + let req = build_request(&cfg).unwrap(); + assert_eq!(req.encryption_key, ""); +} + +#[test] +fn plaintext_install_allows_missing_user() { + let mut cfg = full_config(); + cfg.install_type = Some(InstallType::WholeDisk); + cfg.encrypt = false; + cfg.tpm = false; + cfg.user = UserAccount::default(); + + let req = build_request(&cfg).unwrap(); + assert!(!req.encrypt); + assert_eq!(req.username, ""); + assert_eq!(req.encryption_key, ""); +} + +#[test] +fn encrypted_install_requires_passphrase() { + let mut cfg = full_config(); + cfg.install_type = Some(InstallType::Encrypted); + cfg.encrypt = true; + cfg.encryption_passphrase.clear(); + cfg.encryption_passphrase_confirm.clear(); + + let err = build_request(&cfg).unwrap_err(); + assert_eq!(err, "Passphrase must be at least 8 characters"); +} + +#[test] +fn encrypted_install_does_not_require_user_account() { + // The passphrase is dedicated now, so encryption no longer binds to + // (or requires) the account password. + let mut cfg = full_config(); + cfg.install_type = Some(InstallType::Encrypted); + cfg.encrypt = true; + cfg.user = UserAccount::default(); + + let req = build_request(&cfg).unwrap(); + assert!(req.encrypt); + assert_eq!(req.encryption_key, "correct horse battery staple"); + assert_eq!(req.username, ""); +} + +#[test] +fn playbook_takes_image_and_repart_from_descriptor() { + use libreadymade::backend::postinstall::Module; + use libreadymade::backend::provisioners::{DiskProvisioner, FileSystemProvisioner}; + + let mut distro = descriptor(); + distro.bootc.target_imgref = Some("ghcr.io/example/os:stable".into()); + distro.bootc.enforce_sigpolicy = true; + distro.bootc.kargs = vec!["rhgb".into(), "quiet".into()]; + distro.bootc.args = vec!["--skip-fetch-check".into()]; + + let req = build_request(&full_config()).unwrap(); + let playbook = into_playbook(req, &distro, None); + + let DiskProvisioner::Repart(repart) = &playbook.disk_provisioner else { + panic!("expected repart disk provisioner"); + }; + assert_eq!( + repart.directory, + std::path::PathBuf::from("/usr/share/sirius/repart.d") + ); + let Some(FileSystemProvisioner::Bootc(bootc)) = &playbook.filesystem_provisioner else { + panic!("expected bootc filesystem provisioner"); + }; + assert_eq!(bootc.imgref, "docker://ghcr.io/example/os:latest"); + assert_eq!( + bootc.target_imgref, + Some("ghcr.io/example/os:stable".into()) + ); + assert!(bootc.enforce_sigpolicy); + assert_eq!(bootc.kargs, vec!["rhgb", "quiet"]); + assert_eq!(bootc.args, vec!["--skip-fetch-check", "--skip-finalize"]); + assert!(matches!( + &playbook.postinstall[1], + Module::Keyboard(keyboard) + if keyboard.layout == "br" && keyboard.variant.is_none() + )); +} + +#[test] +fn parses_gnome_xkb_layout_and_variant_ids() { + assert_eq!(parse_xkb_id("br"), Some(("br".into(), None))); + assert_eq!( + parse_xkb_id("us+intl"), + Some(("us".into(), Some("intl".into()))) + ); + assert_eq!(parse_xkb_id("us+intl+extra"), None); + assert_eq!(parse_xkb_id("us\"\nEndSection"), None); +} diff --git a/crates/sirius-backend/src/lib.rs b/crates/sirius-backend/src/lib.rs new file mode 100644 index 0000000..34dfa80 --- /dev/null +++ b/crates/sirius-backend/src/lib.rs @@ -0,0 +1,13 @@ +//! Operating-system integrations and the privileged installation boundary. +//! +//! GTK and Relm4 must not be used in this crate. + +pub mod distro; +pub mod install; +pub mod network; +pub mod runner; +pub mod spawn; +pub mod storage; +pub mod system; + +pub use sirius_core::{InstallRequest, Progress}; diff --git a/crates/sirius-installer/src/backend/network.rs b/crates/sirius-backend/src/network.rs similarity index 99% rename from crates/sirius-installer/src/backend/network.rs rename to crates/sirius-backend/src/network.rs index 1393d73..b9a55c8 100644 --- a/crates/sirius-installer/src/backend/network.rs +++ b/crates/sirius-backend/src/network.rs @@ -1,4 +1,4 @@ -//! Small NetworkManager client used by the optional Wi-Fi page. +//! NetworkManager integration used by the optional Wi-Fi page. use std::collections::HashMap; use zbus::blocking::Connection; diff --git a/crates/sirius-installer/src/backend/runner.rs b/crates/sirius-backend/src/runner.rs similarity index 60% rename from crates/sirius-installer/src/backend/runner.rs rename to crates/sirius-backend/src/runner.rs index 57240c4..ad291ba 100644 --- a/crates/sirius-installer/src/backend/runner.rs +++ b/crates/sirius-backend/src/runner.rs @@ -1,4 +1,4 @@ -//! The privileged half. Invoked as `sirius run-playbook` under pkexec. +//! Privileged installation orchestration invoked under pkexec. //! Reads an InstallRequest JSON from stdin, executes it via libreadymade, and //! writes newline-delimited `Progress` JSON to stdout for the UI to parse. //! @@ -7,12 +7,11 @@ //! from the root-owned `/etc/sirius/distro.toml` itself, so a caller cannot //! make the root process deploy an arbitrary image or layout. -use crate::backend::Progress; -use crate::backend::adapter::InstallRequest; -use crate::backend::distro::DistroDescriptor; use gettextrs::gettext; use libreadymade::playbook::{Playbook, PlaybookProgress}; +use sirius_core::{InstallRequest, Progress}; use std::io::{Read, Write}; +use std::process::Command; /// Upper bound for the request JSON; anything larger is garbage, not a request. const MAX_REQUEST_BYTES: u64 = 1024 * 1024; @@ -65,7 +64,7 @@ fn validate_target_disk(path: &str) -> Result<(), String> { if !meta.file_type().is_block_device() { return Err(gettext("target disk is not a block device: {path}").replace("{path}", path)); } - let disk = crate::backend::storage::scan_disks()? + let disk = crate::storage::scan_disks()? .into_iter() .find(|disk| disk.path == path) .ok_or_else(|| { @@ -85,8 +84,72 @@ fn fail(message: String) -> i32 { 1 } +/// Re-execute the privileged installer in a private mount namespace. +/// +/// libreadymade temporarily mounts the target filesystems while it runs bootc. +/// Keeping those mounts private prevents them from propagating into the live +/// system and lets the inner process hide the host's OSTree repository. +pub fn run_isolated() -> i32 { + let executable = match std::env::current_exe() { + Ok(path) => path, + Err(e) => { + return fail( + gettext("cannot locate the installer executable: {error}") + .replace("{error}", &e.to_string()), + ); + } + }; + match Command::new("unshare") + .args(["--mount", "--propagation", "slave", "--"]) + .arg(executable) + .arg("run-playbook-inner") + .status() + { + Ok(status) => status.code().unwrap_or(1), + Err(e) => fail( + gettext("cannot create the private install namespace: {error}") + .replace("{error}", &e.to_string()), + ), + } +} + +/// bootc 1.12 can mistake the live host's OSTree commits for the external +/// source image and abort with "Multiple commit objects found". Its upstream +/// outside-container tests avoid that by masking `/sysroot/ostree`. Do the +/// same only inside the private namespace created by [`run_isolated`]. +fn mask_host_ostree() -> Result<(), String> { + let host_ostree = std::path::Path::new("/sysroot/ostree"); + if !host_ostree.exists() { + return Ok(()); + } + let empty = std::path::Path::new("/run/sirius/empty"); + std::fs::create_dir_all(empty).map_err(|e| { + gettext("cannot prepare the private install namespace: {error}") + .replace("{error}", &e.to_string()) + })?; + let status = Command::new("mount") + .args(["--bind"]) + .arg(empty) + .arg(host_ostree) + .status() + .map_err(|e| { + gettext("cannot hide the host OSTree repository: {error}") + .replace("{error}", &e.to_string()) + })?; + if !status.success() { + return Err( + gettext("cannot hide the host OSTree repository: mount exited with {status}") + .replace("{status}", &status.to_string()), + ); + } + Ok(()) +} + /// Entry point for the privileged subprocess. Returns the process exit code. pub fn run() -> i32 { + if let Err(e) = mask_host_ostree() { + return fail(e); + } let mut input = String::new(); if std::io::stdin() .take(MAX_REQUEST_BYTES) @@ -111,29 +174,39 @@ pub fn run() -> i32 { // SAFETY: set before any thread that reads the environment is spawned. unsafe { std::env::set_var("LANGUAGE", &request.locale) }; } + // libreadymade intentionally defaults systemd-repart to dry-run in debug + // builds. `run-playbook` is only reached after the user has confirmed the + // destructive operation and crossed the privilege boundary, so both debug + // and release binaries must perform the requested partitioning here. + // + // SAFETY: set before the playbook worker thread is spawned. + unsafe { std::env::set_var("READYMADE_DRY_RUN", "0") }; if let Err(e) = validate_target_disk(&request.target_disk) { return fail(e); } - let distro = match DistroDescriptor::load() { + if crate::install::parse_xkb_id(&request.keyboard).is_none() { + return fail( + gettext("invalid keyboard layout: {layout}").replace("{layout}", &request.keyboard), + ); + } + let distro = match crate::distro::load() { Ok(d) => d, Err(e) => return fail(e), }; let manual_mounts = match request.partition_plan.as_ref() { - Some(plan) => { - match crate::backend::storage::apply_partition_plan(plan, &request.target_disk) { - Ok(mounts) => Some(mounts), - Err(e) => { - return fail( - gettext("cannot apply partition plan: {error}") - .replace("{error}", &e.to_string()), - ); - } + Some(plan) => match crate::storage::apply_partition_plan(plan, &request.target_disk) { + Ok(mounts) => Some(mounts), + Err(e) => { + return fail( + gettext("cannot apply partition plan: {error}") + .replace("{error}", &e.to_string()), + ); } - } + }, None => None, }; - let playbook: Playbook = request.into_playbook(&distro, manual_mounts); + let playbook: Playbook = crate::install::into_playbook(request, &distro, manual_mounts); let (tx, rx) = Playbook::channel(); // Run the (blocking, root) install on a worker thread; stream progress from the channel. diff --git a/crates/sirius-installer/src/backend/spawn.rs b/crates/sirius-backend/src/spawn.rs similarity index 97% rename from crates/sirius-installer/src/backend/spawn.rs rename to crates/sirius-backend/src/spawn.rs index d030552..8a07475 100644 --- a/crates/sirius-installer/src/backend/spawn.rs +++ b/crates/sirius-backend/src/spawn.rs @@ -1,10 +1,9 @@ -//! Unprivileged side: spawn `pkexec sirius run-playbook`, pipe the request to +//! Unprivileged process boundary: spawn `pkexec sirius run-playbook`, pipe the request to //! its stdin, and parse its stdout progress lines. When already running as //! root (live installers often are), pkexec is skipped entirely. -use crate::backend::Progress; -use crate::backend::adapter::InstallRequest; use gettextrs::gettext; +use sirius_core::{InstallRequest, Progress}; use std::collections::VecDeque; use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; diff --git a/crates/sirius-installer/src/backend/storage.rs b/crates/sirius-backend/src/storage/apply.rs similarity index 61% rename from crates/sirius-installer/src/backend/storage.rs rename to crates/sirius-backend/src/storage/apply.rs index 99e92e0..7c32f17 100644 --- a/crates/sirius-installer/src/backend/storage.rs +++ b/crates/sirius-backend/src/storage/apply.rs @@ -1,182 +1,12 @@ -//! Disk discovery for the UI and the privileged UDisks2 partition executor. -//! -//! Discovery is read-only. Mutations are represented by `PartitionPlan` and -//! are applied only by `runner`, after pkexec and the final confirmation. +//! Privileged application of a confirmed partition plan through UDisks2. -use crate::config_model::{MountAssignment, PartitionOperation, PartitionPlan, PartitionRef}; -use serde::{Deserialize, Serialize}; +use super::{DiskSnapshot, scan_disks}; +use sirius_core::{MountAssignment, PartitionOperation, PartitionPlan, PartitionRef}; use std::collections::HashMap; use std::path::PathBuf; use zbus::blocking::Connection; use zvariant::{OwnedObjectPath, OwnedValue}; -const MIB: u64 = 1024 * 1024; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DiskSnapshot { - pub path: String, - pub model: String, - pub size_bytes: u64, - pub table_type: String, - pub read_only: bool, - pub in_use: bool, - pub partitions: Vec, - pub free_regions: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct PartitionSnapshot { - pub path: String, - pub start_bytes: u64, - pub size_bytes: u64, - pub filesystem: String, - pub label: String, - pub mountpoints: Vec, - pub gpt_type: String, - pub part_uuid: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct FreeRegion { - pub offset_bytes: u64, - pub size_bytes: u64, -} - -pub fn format_size(bytes: u64) -> String { - const GIB: f64 = 1024.0 * 1024.0 * 1024.0; - if bytes >= 1024 * 1024 * 1024 { - format!("{:.1} GiB", bytes as f64 / GIB) - } else { - format!("{:.0} MiB", bytes as f64 / (1024.0 * 1024.0)) - } -} - -/// Read the current block topology. `lsblk` provides the kernel's current, -/// non-privileged view and keeps the UI independent from UDisks policy. -pub fn scan_disks() -> Result, String> { - let output = std::process::Command::new("lsblk") - .args([ - "--bytes", - "--json", - "-o", - "NAME,PATH,TYPE,SIZE,START,FSTYPE,LABEL,MOUNTPOINTS,PARTTYPE,PARTUUID,MODEL,PTTYPE,RO,LOG-SEC", - ]) - .output() - .map_err(|e| format!("failed to run lsblk: {e}"))?; - if !output.status.success() { - return Err(format!( - "lsblk failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let root: serde_json::Value = - serde_json::from_slice(&output.stdout).map_err(|e| format!("invalid lsblk output: {e}"))?; - let mut disks = Vec::new(); - for node in root["blockdevices"].as_array().into_iter().flatten() { - let path = string(node, "path"); - if string(node, "type") != "disk" - || bool_value(node, "ro") - || path.starts_with("/dev/zram") - || path.starts_with("/dev/loop") - { - continue; - } - let size_bytes = number(node, "size"); - let sector_size = number(node, "log-sec").max(512); - let mut partitions = Vec::new(); - for child in node["children"].as_array().into_iter().flatten() { - if string(child, "type") != "part" { - continue; - } - partitions.push(PartitionSnapshot { - path: string(child, "path"), - start_bytes: number(child, "start").saturating_mul(sector_size), - size_bytes: number(child, "size"), - filesystem: string(child, "fstype"), - label: string(child, "label"), - mountpoints: strings(child, "mountpoints"), - gpt_type: string(child, "parttype"), - part_uuid: string(child, "partuuid"), - }); - } - partitions.sort_by_key(|p| p.start_bytes); - let free_regions = calculate_free_regions(size_bytes, &partitions); - let in_use = node_in_use(node); - let model = string(node, "model").trim().to_string(); - disks.push(DiskSnapshot { - model: if model.is_empty() { - path.clone() - } else { - model - }, - path, - size_bytes, - table_type: string(node, "pttype").to_ascii_uppercase(), - read_only: bool_value(node, "ro"), - in_use, - partitions, - free_regions, - }); - } - Ok(disks) -} - -fn calculate_free_regions(total: u64, partitions: &[PartitionSnapshot]) -> Vec { - let mut free = Vec::new(); - let mut cursor = MIB.min(total); - for partition in partitions { - if partition.start_bytes > cursor.saturating_add(MIB) { - free.push(FreeRegion { - offset_bytes: cursor, - size_bytes: partition.start_bytes - cursor, - }); - } - cursor = cursor.max(partition.start_bytes.saturating_add(partition.size_bytes)); - } - if total > cursor.saturating_add(MIB) { - free.push(FreeRegion { - offset_bytes: cursor, - size_bytes: total - cursor, - }); - } - free -} - -fn string(value: &serde_json::Value, key: &str) -> String { - value[key].as_str().unwrap_or_default().to_string() -} - -fn number(value: &serde_json::Value, key: &str) -> u64 { - value[key] - .as_u64() - .or_else(|| value[key].as_str().and_then(|s| s.parse().ok())) - .unwrap_or(0) -} - -fn bool_value(value: &serde_json::Value, key: &str) -> bool { - value[key] - .as_bool() - .or_else(|| value[key].as_u64().map(|v| v != 0)) - .unwrap_or(false) -} - -fn strings(value: &serde_json::Value, key: &str) -> Vec { - value[key] - .as_array() - .into_iter() - .flatten() - .filter_map(|v| v.as_str()) - .filter(|v| !v.is_empty()) - .map(str::to_string) - .collect() -} - -fn node_in_use(node: &serde_json::Value) -> bool { - !strings(node, "mountpoints").is_empty() - || node["children"] - .as_array() - .is_some_and(|children| children.iter().any(node_in_use)) -} - #[zbus::proxy( interface = "org.freedesktop.UDisks2.Manager", default_service = "org.freedesktop.UDisks2", @@ -469,38 +299,3 @@ fn build_mounts( } Ok(Mounts(mounts)) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn free_regions_account_for_partitions() { - let parts = vec![PartitionSnapshot { - path: "/dev/sda1".into(), - start_bytes: MIB, - size_bytes: 100 * MIB, - filesystem: "ext4".into(), - label: String::new(), - mountpoints: vec![], - gpt_type: String::new(), - part_uuid: String::new(), - }]; - assert_eq!( - calculate_free_regions(200 * MIB, &parts)[0].size_bytes, - 99 * MIB - ); - } - - #[test] - fn mounted_nested_mapper_marks_whole_disk_in_use() { - let node = serde_json::json!({ - "mountpoints": [], - "children": [{ - "mountpoints": [], - "children": [{ "mountpoints": ["/"] }] - }] - }); - assert!(node_in_use(&node)); - } -} diff --git a/crates/sirius-backend/src/storage/mod.rs b/crates/sirius-backend/src/storage/mod.rs new file mode 100644 index 0000000..6b2b9d7 --- /dev/null +++ b/crates/sirius-backend/src/storage/mod.rs @@ -0,0 +1,51 @@ +//! Storage facade. +//! +//! Discovery is read-only and safe for the app process. Applying a plan is +//! destructive and is called only from the privileged runner. + +mod apply; +mod scan; + +use serde::{Deserialize, Serialize}; + +pub use apply::apply_partition_plan; +pub use scan::scan_disks; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DiskSnapshot { + pub path: String, + pub model: String, + pub size_bytes: u64, + pub table_type: String, + pub read_only: bool, + pub in_use: bool, + pub partitions: Vec, + pub free_regions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PartitionSnapshot { + pub path: String, + pub start_bytes: u64, + pub size_bytes: u64, + pub filesystem: String, + pub label: String, + pub mountpoints: Vec, + pub gpt_type: String, + pub part_uuid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FreeRegion { + pub offset_bytes: u64, + pub size_bytes: u64, +} + +pub fn format_size(bytes: u64) -> String { + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + if bytes >= 1024 * 1024 * 1024 { + format!("{:.1} GiB", bytes as f64 / GIB) + } else { + format!("{:.0} MiB", bytes as f64 / (1024.0 * 1024.0)) + } +} diff --git a/crates/sirius-backend/src/storage/scan.rs b/crates/sirius-backend/src/storage/scan.rs new file mode 100644 index 0000000..6240809 --- /dev/null +++ b/crates/sirius-backend/src/storage/scan.rs @@ -0,0 +1,356 @@ +//! Read-only block-device discovery without spawning system utilities. + +use super::{DiskSnapshot, FreeRegion, PartitionSnapshot}; +use lsblk::{BlockDevice, Mount}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +const SECTOR_BYTES: u64 = 512; +const MIB: u64 = 1024 * 1024; + +#[derive(Debug, Clone)] +struct DeviceFacts { + key: String, + parent: Option, + holders: Vec, + path: String, + size_bytes: u64, + start_bytes: u64, + filesystem: String, + label: String, + mountpoints: Vec, + gpt_type: String, + part_uuid: String, + model: String, + table_type: String, + read_only: bool, + is_partition: bool, +} + +/// Read the current block topology from `/dev`, sysfs, udev data, and +/// `/proc/mounts`. The `lsblk` crate performs the enumeration directly; no +/// external command is executed. +pub fn scan_disks() -> Result, String> { + let mountpoints = mounted_devices()?; + let devices = std::panic::catch_unwind(BlockDevice::list) + .map_err(|_| "block-device enumeration panicked".to_owned())? + .map_err(|error| format!("failed to list block devices: {error}"))?; + let facts = devices + .iter() + .filter_map(|device| collect_facts(device, &mountpoints).ok()) + .collect::>(); + let mounted_keys = mountpoints.keys().map(String::as_str).collect(); + + Ok(build_snapshots(&facts, &mounted_keys)) +} + +fn mounted_devices() -> Result>, String> { + let mounts = Mount::list().map_err(|error| format!("failed to list mount points: {error}"))?; + let mut by_device = HashMap::>::new(); + + for mount in mounts { + let path = Path::new(&mount.device); + if !path.starts_with("/dev") { + continue; + } + let Ok(device) = BlockDevice::from_path_unpopulated(path) else { + continue; + }; + let Ok((major, minor)) = device.major_minor() else { + continue; + }; + by_device + .entry(device_key(major, minor)) + .or_default() + .push(mount.mountpoint.to_string_lossy().into_owned()); + } + + Ok(by_device) +} + +fn collect_facts( + device: &BlockDevice, + mountpoints: &HashMap>, +) -> Result { + let (major, minor) = device + .major_minor() + .map_err(|error| format!("failed to inspect {}: {error}", device.fullname.display()))?; + let key = device_key(major, minor); + let sysfs = device + .sysfs() + .map_err(|error| format!("failed to locate {} in sysfs: {error}", device.name))?; + let canonical_sysfs = std::fs::canonicalize(&sysfs) + .map_err(|error| format!("failed to resolve {} in sysfs: {error}", device.name))?; + let is_partition = sysfs.join("partition").exists(); + let parent = is_partition + .then(|| canonical_sysfs.parent()) + .flatten() + .and_then(read_device_key); + let udev = read_udev_properties(major, minor); + let model = read_trimmed(sysfs.join("device/model")) + .or_else(|| udev.get("ID_MODEL_FROM_DATABASE").cloned()) + .or_else(|| udev.get("ID_MODEL").cloned()) + .unwrap_or_default(); + + Ok(DeviceFacts { + key: key.clone(), + parent, + holders: holder_keys(&sysfs), + path: device.fullname.to_string_lossy().into_owned(), + size_bytes: device + .capacity() + .map_err(|error| format!("failed to read {} capacity: {error}", device.name))? + .unwrap_or(0) + .saturating_mul(SECTOR_BYTES), + start_bytes: read_u64(sysfs.join("start")) + .unwrap_or(0) + .saturating_mul(SECTOR_BYTES), + filesystem: property(&udev, "ID_FS_TYPE"), + label: device + .label + .clone() + .or_else(|| udev.get("ID_FS_LABEL").cloned()) + .unwrap_or_default(), + mountpoints: mountpoints.get(&key).cloned().unwrap_or_default(), + gpt_type: property(&udev, "ID_PART_ENTRY_TYPE"), + part_uuid: device.partuuid.clone().unwrap_or_default(), + model, + table_type: property(&udev, "ID_PART_TABLE_TYPE").to_ascii_uppercase(), + read_only: read_u64(sysfs.join("ro")).is_some_and(|value| value != 0), + is_partition, + }) +} + +fn build_snapshots(facts: &[DeviceFacts], mounted_keys: &HashSet<&str>) -> Vec { + let by_key = facts + .iter() + .map(|device| (device.key.as_str(), device)) + .collect::>(); + let mut children = HashMap::<&str, Vec<&str>>::new(); + + for device in facts { + if let Some(parent) = device.parent.as_deref() { + children + .entry(parent) + .or_default() + .push(device.key.as_str()); + } + for holder in &device.holders { + children + .entry(device.key.as_str()) + .or_default() + .push(holder.as_str()); + } + } + + let mut disks = facts + .iter() + .filter(|device| is_install_disk(device)) + .map(|disk| { + let mut partitions = facts + .iter() + .filter(|device| { + device.is_partition && device.parent.as_deref() == Some(disk.key.as_str()) + }) + .map(|partition| PartitionSnapshot { + path: partition.path.clone(), + start_bytes: partition.start_bytes, + size_bytes: partition.size_bytes, + filesystem: partition.filesystem.clone(), + label: partition.label.clone(), + mountpoints: partition.mountpoints.clone(), + gpt_type: partition.gpt_type.clone(), + part_uuid: partition.part_uuid.clone(), + }) + .collect::>(); + partitions.sort_by_key(|partition| partition.start_bytes); + + DiskSnapshot { + model: if disk.model.trim().is_empty() { + disk.path.clone() + } else { + disk.model.trim().to_owned() + }, + path: disk.path.clone(), + size_bytes: disk.size_bytes, + table_type: disk.table_type.clone(), + read_only: disk.read_only, + in_use: device_tree_in_use( + disk.key.as_str(), + &by_key, + &children, + mounted_keys, + &mut HashSet::new(), + ), + free_regions: calculate_free_regions(disk.size_bytes, &partitions), + partitions, + } + }) + .collect::>(); + disks.sort_by(|left, right| left.path.cmp(&right.path)); + disks +} + +fn is_install_disk(device: &DeviceFacts) -> bool { + let name = device.path.strip_prefix("/dev/").unwrap_or(&device.path); + let pseudo_prefixes = ["zram", "loop", "ram", "sr", "fd", "dm-", "md"]; + + !device.is_partition + && !device.read_only + && device.size_bytes > 0 + && !pseudo_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +fn device_tree_in_use<'a>( + key: &'a str, + devices: &HashMap<&'a str, &'a DeviceFacts>, + children: &HashMap<&'a str, Vec<&'a str>>, + mounted_keys: &HashSet<&'a str>, + visited: &mut HashSet<&'a str>, +) -> bool { + if !visited.insert(key) { + return false; + } + mounted_keys.contains(key) + || devices + .get(key) + .is_some_and(|device| !device.mountpoints.is_empty()) + || children.get(key).is_some_and(|descendants| { + descendants + .iter() + .any(|child| device_tree_in_use(child, devices, children, mounted_keys, visited)) + }) +} + +fn holder_keys(sysfs: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(sysfs.join("holders")) else { + return Vec::new(); + }; + entries + .filter_map(Result::ok) + .filter_map(|entry| read_device_key(&entry.path())) + .collect() +} + +fn read_device_key(sysfs: &Path) -> Option { + read_trimmed(sysfs.join("dev")) +} + +fn device_key(major: u32, minor: u32) -> String { + format!("{major}:{minor}") +} + +fn read_udev_properties(major: u32, minor: u32) -> HashMap { + let path = format!("/run/udev/data/b{major}:{minor}"); + let Ok(contents) = std::fs::read_to_string(path) else { + return HashMap::new(); + }; + contents + .lines() + .filter_map(|line| line.strip_prefix("E:")) + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect() +} + +fn property(properties: &HashMap, key: &str) -> String { + properties.get(key).cloned().unwrap_or_default() +} + +fn read_trimmed(path: impl AsRef) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +fn read_u64(path: impl AsRef) -> Option { + read_trimmed(path)?.parse().ok() +} + +fn calculate_free_regions(total: u64, partitions: &[PartitionSnapshot]) -> Vec { + let mut free = Vec::new(); + let mut cursor = MIB.min(total); + for partition in partitions { + if partition.start_bytes > cursor.saturating_add(MIB) { + free.push(FreeRegion { + offset_bytes: cursor, + size_bytes: partition.start_bytes - cursor, + }); + } + cursor = cursor.max(partition.start_bytes.saturating_add(partition.size_bytes)); + } + if total > cursor.saturating_add(MIB) { + free.push(FreeRegion { + offset_bytes: cursor, + size_bytes: total - cursor, + }); + } + free +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facts(key: &str, path: &str) -> DeviceFacts { + DeviceFacts { + key: key.into(), + parent: None, + holders: Vec::new(), + path: path.into(), + size_bytes: 200 * MIB, + start_bytes: 0, + filesystem: String::new(), + label: String::new(), + mountpoints: Vec::new(), + gpt_type: String::new(), + part_uuid: String::new(), + model: String::new(), + table_type: String::new(), + read_only: false, + is_partition: false, + } + } + + #[test] + fn free_regions_account_for_partitions() { + let partitions = vec![PartitionSnapshot { + path: "/dev/sda1".into(), + start_bytes: MIB, + size_bytes: 100 * MIB, + filesystem: "ext4".into(), + label: String::new(), + mountpoints: vec![], + gpt_type: String::new(), + part_uuid: String::new(), + }]; + assert_eq!( + calculate_free_regions(200 * MIB, &partitions)[0].size_bytes, + 99 * MIB + ); + } + + #[test] + fn mounted_nested_mapper_marks_whole_disk_in_use() { + let mut disk = facts("8:0", "/dev/sda"); + let mut partition = facts("8:1", "/dev/sda1"); + partition.is_partition = true; + partition.parent = Some(disk.key.clone()); + partition.holders.push("253:0".into()); + + let mounted = HashSet::from(["253:0"]); + let snapshots = build_snapshots(&[disk.clone(), partition], &mounted); + assert!(snapshots[0].in_use); + + disk.read_only = true; + assert!(build_snapshots(&[disk], &HashSet::new()).is_empty()); + } + + #[test] + fn live_scan_returns_a_result_without_external_tools() { + assert!(scan_disks().is_ok()); + } +} diff --git a/crates/sirius-backend/src/system.rs b/crates/sirius-backend/src/system.rs new file mode 100644 index 0000000..2b997a3 --- /dev/null +++ b/crates/sirius-backend/src/system.rs @@ -0,0 +1,28 @@ +//! Host lifecycle operations exposed by systemd-logind. + +use zbus::blocking::Connection; + +#[zbus::proxy( + interface = "org.freedesktop.login1.Manager", + default_service = "org.freedesktop.login1", + default_path = "/org/freedesktop/login1" +)] +trait LoginManager { + fn reboot(&self, interactive: bool) -> zbus::Result<()>; +} + +/// Ask systemd-logind to reboot the host. +/// +/// `interactive` lets polkit obtain authorization when necessary. Logind is +/// also the systemd-recommended boundary for graphical applications and can +/// authorize overriding inhibitors held by the live installer session. +pub fn reboot() -> Result<(), String> { + let connection = + Connection::system().map_err(|error| format!("cannot connect to system bus: {error}"))?; + let manager = LoginManagerProxyBlocking::new(&connection) + .map_err(|error| format!("cannot access systemd-logind: {error}"))?; + + manager + .reboot(true) + .map_err(|error| format!("systemd-logind refused to reboot: {error}")) +} diff --git a/crates/sirius-core/Cargo.toml b/crates/sirius-core/Cargo.toml new file mode 100644 index 0000000..e739434 --- /dev/null +++ b/crates/sirius-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "sirius-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Pure domain models and process protocol for Sirius" + +[dependencies] +serde = { workspace = true } +toml = { workspace = true } +gettext-rs = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/sirius-installer/src/backend/distro.rs b/crates/sirius-core/src/distro.rs similarity index 81% rename from crates/sirius-installer/src/backend/distro.rs rename to crates/sirius-core/src/distro.rs index f9d0883..779254c 100644 --- a/crates/sirius-installer/src/backend/distro.rs +++ b/crates/sirius-core/src/distro.rs @@ -1,13 +1,10 @@ -//! Static description of what Sirius installs: the bootc/OCI image to deploy and +//! Pure description of what Sirius installs: the bootc/OCI image to deploy and //! the systemd-repart config directory describing the partition layout. Sirius is //! distro-agnostic — these values come from the distribution's descriptor, shipped //! at `/etc/sirius/distro.toml`, organized into `[bootc]` and `[disk]` sections. use serde::{Deserialize, Serialize}; -/// Default on-disk path for the distro descriptor, shipped in the ISO. -pub const DISTRO_PATH: &str = "/etc/sirius/distro.toml"; - /// Top-level descriptor: one section per concern. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct DistroDescriptor { @@ -24,15 +21,24 @@ pub struct DistroDescriptor { pub branding: Branding, } -/// `[branding]` section: what the welcome page shows above the title. +/// `[branding]` section: distribution-owned identity and welcome content. #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct Branding { + /// Human-readable distribution name used by `{name}` templates. + #[serde(default)] + pub name: Option, /// Absolute path to a logo image file (preferred when set and readable). #[serde(default)] pub logo: Option, /// Themed icon name fallback (default: a star, for Sirius). #[serde(default)] pub icon: Option, + /// Label template for the welcome-page action. `{name}` expands to `name`. + #[serde(default)] + pub welcome_button: Option, + /// Path to the large welcome banner. + #[serde(default)] + pub welcome_banner: Option, } /// One `[[bento]]` link card on the progress page. @@ -51,7 +57,8 @@ pub struct Bento { /// `[bootc]` section. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct BootcConfig { - /// The bootc/OCI image reference to deploy (e.g. "ghcr.io/example/os:latest"). + /// The transport-qualified bootc/OCI source image reference accepted by + /// skopeo (e.g. "docker://ghcr.io/example/os:latest"). pub image: String, /// Optional image reference persisted into the installed bootc deployment. #[serde(default)] @@ -78,15 +85,6 @@ impl DistroDescriptor { pub fn from_toml(src: &str) -> Result { toml::from_str(src) } - - /// Load the descriptor from the installed path, falling back to the - /// in-tree `data/distro.toml` for dev/VM runs. - pub fn load() -> Result { - let src = std::fs::read_to_string(DISTRO_PATH) - .or_else(|_| std::fs::read_to_string("data/distro.toml")) - .map_err(|e| format!("cannot read distro descriptor ({DISTRO_PATH}): {e}"))?; - Self::from_toml(&src).map_err(|e| format!("invalid distro descriptor: {e}")) - } } #[cfg(test)] @@ -97,13 +95,13 @@ mod tests { fn parses_descriptor() { let src = r#" [bootc] -image = "ghcr.io/example/os:latest" +image = "docker://ghcr.io/example/os:latest" [disk] repart_dir = "/usr/share/sirius/repart.d" "#; let d = DistroDescriptor::from_toml(src).unwrap(); - assert_eq!(d.bootc.image, "ghcr.io/example/os:latest"); + assert_eq!(d.bootc.image, "docker://ghcr.io/example/os:latest"); assert_eq!(d.bootc.target_imgref, None); assert!(!d.bootc.enforce_sigpolicy); assert!(d.bootc.kargs.is_empty()); @@ -138,7 +136,7 @@ repart_dir = "/usr/share/sirius/repart.d" fn parses_optional_bentos() { let src = r#" [bootc] -image = "ghcr.io/example/os:latest" +image = "docker://ghcr.io/example/os:latest" [disk] repart_dir = "/usr/share/sirius/repart.d" @@ -172,14 +170,23 @@ image = "x" repart_dir = "/r" [branding] +name = "Example OS" logo = "/usr/share/sirius/logo.png" +welcome_button = "Install {name}" +welcome_banner = "/usr/share/sirius/welcome-banner.png" "#; let d = DistroDescriptor::from_toml(src).unwrap(); + assert_eq!(d.branding.name.as_deref(), Some("Example OS")); assert_eq!( d.branding.logo.as_deref(), Some("/usr/share/sirius/logo.png") ); assert_eq!(d.branding.icon, None); + assert_eq!(d.branding.welcome_button.as_deref(), Some("Install {name}")); + assert_eq!( + d.branding.welcome_banner.as_deref(), + Some("/usr/share/sirius/welcome-banner.png") + ); // Absent section parses as default (no logo, no icon override). let plain = "[bootc]\nimage = \"x\"\n[disk]\nrepart_dir = \"/r\"\n"; assert_eq!( diff --git a/crates/sirius-installer/src/config_model.rs b/crates/sirius-core/src/install.rs similarity index 88% rename from crates/sirius-installer/src/config_model.rs rename to crates/sirius-core/src/install.rs index ed7db5a..362e074 100644 --- a/crates/sirius-installer/src/config_model.rs +++ b/crates/sirius-core/src/install.rs @@ -1,4 +1,4 @@ -//! The shared installer state collected across wizard pages. +//! Installer choices and partition plans shared by the app and backend. use gettextrs::gettext; use serde::{Deserialize, Serialize}; @@ -80,10 +80,12 @@ impl PartitionPlan { /// runner repeats this and additionally compares it to the live topology. pub fn validate(&self, uefi: bool) -> Result<(), String> { if self.table_type != "gpt" { - return Err("manual partitioning requires a GPT disk".into()); + return Err(gettext("manual partitioning requires a GPT disk")); } if !self.disk_path.starts_with("/dev/") || self.disk_size_bytes == 0 { - return Err("manual partitioning requires a valid destination disk".into()); + return Err(gettext( + "manual partitioning requires a valid destination disk", + )); } let mut created = std::collections::HashSet::new(); let mut ranges = Vec::new(); @@ -99,7 +101,7 @@ impl PartitionPlan { label, } => { if id.is_empty() || !created.insert(id.as_str()) { - return Err("planned partition ids must be unique".into()); + return Err(gettext("planned partition ids must be unique")); } if *offset_bytes < 1024 * 1024 || *size_bytes == 0 @@ -107,14 +109,18 @@ impl PartitionPlan { .checked_add(*size_bytes) .is_none_or(|end| end > self.disk_size_bytes) { - return Err("a planned partition is outside the destination disk".into()); + return Err(gettext( + "a planned partition is outside the destination disk", + )); } if uuid::Uuid::parse_str(gpt_type).is_err() { - return Err("a planned partition has an invalid GPT type".into()); + return Err(gettext("a planned partition has an invalid GPT type")); } validate_filesystem(filesystem)?; if name.contains('\0') || label.contains('\0') { - return Err("partition names and labels cannot contain NUL bytes".into()); + return Err(gettext( + "partition names and labels cannot contain NUL bytes", + )); } ranges.push((*offset_bytes, *offset_bytes + *size_bytes)); } @@ -123,7 +129,7 @@ impl PartitionPlan { } => { validate_filesystem(filesystem)?; if label.contains('\0') { - return Err("partition labels cannot contain NUL bytes".into()); + return Err(gettext("partition labels cannot contain NUL bytes")); } } _ => {} @@ -131,26 +137,32 @@ impl PartitionPlan { } ranges.sort_unstable(); if ranges.windows(2).any(|pair| pair[0].1 > pair[1].0) { - return Err("planned partitions overlap".into()); + return Err(gettext("planned partitions overlap")); } let mut mountpoints = std::collections::HashSet::new(); for mount in &self.mounts { validate_filesystem(&mount.filesystem)?; if !mount.mount_point.starts_with('/') || !mountpoints.insert(&mount.mount_point) { - return Err("mount points must be unique absolute paths".into()); + return Err(gettext("mount points must be unique absolute paths")); } validate_reference(&mount.target, &created, self.disk_size_bytes)?; if formatted_filesystem(&mount.target, &self.operations) .is_some_and(|filesystem| filesystem != mount.filesystem) { - return Err("a mount assignment does not match its formatted filesystem".into()); + return Err(gettext( + "a mount assignment does not match its formatted filesystem", + )); } } for operation in &self.operations { match operation { PartitionOperation::Delete { target: PartitionRef::Planned { .. }, - } => return Err("a partition cannot be deleted before it is created".into()), + } => { + return Err(gettext( + "a partition cannot be deleted before it is created", + )); + } PartitionOperation::Delete { target } | PartitionOperation::Format { target, .. } => { validate_reference(target, &created, self.disk_size_bytes)? @@ -164,17 +176,17 @@ impl PartitionPlan { .filter(|mount| mount.mount_point == "/") .collect(); if roots.len() != 1 { - return Err("choose exactly one root partition".into()); + return Err(gettext("choose exactly one root partition")); } if !matches!(roots[0].filesystem.as_str(), "btrfs" | "ext4") { - return Err("the root partition must use Btrfs or ext4".into()); + return Err(gettext("the root partition must use Btrfs or ext4")); } let root_size = partition_size(&roots[0].target, &self.operations).unwrap_or(0); if root_size < Self::MIN_ROOT_BYTES { - return Err("the root partition must be at least 20 GiB".into()); + return Err(gettext("the root partition must be at least 20 GiB")); } if !is_formatted(&roots[0].target, &self.operations) { - return Err("the root partition must be explicitly formatted".into()); + return Err(gettext("the root partition must be explicitly formatted")); } if uefi { let esps: Vec<_> = self @@ -183,11 +195,11 @@ impl PartitionPlan { .filter(|mount| mount.mount_point == "/boot/efi") .collect(); if esps.len() != 1 || esps[0].filesystem != "vfat" { - return Err("choose one FAT32 EFI system partition".into()); + return Err(gettext("choose one FAT32 EFI system partition")); } let esp_size = partition_size(&esps[0].target, &self.operations).unwrap_or(0); if esp_size < Self::MIN_ESP_BYTES { - return Err("the EFI system partition must be at least 512 MiB".into()); + return Err(gettext("the EFI system partition must be at least 512 MiB")); } } Ok(()) @@ -198,7 +210,7 @@ fn validate_filesystem(filesystem: &str) -> Result<(), String> { if matches!(filesystem, "btrfs" | "ext4" | "vfat" | "swap") { Ok(()) } else { - Err(format!("unsupported filesystem: {filesystem}")) + Err(gettext("unsupported filesystem: {filesystem}").replace("{filesystem}", filesystem)) } } @@ -220,11 +232,11 @@ fn validate_reference( .checked_add(*size_bytes) .is_none_or(|end| end > disk_size) { - return Err("an existing partition reference is invalid".into()); + return Err(gettext("an existing partition reference is invalid")); } } PartitionRef::Planned { id } if !created.contains(id.as_str()) => { - return Err(format!("planned partition does not exist: {id}")); + return Err(gettext("planned partition does not exist: {id}").replace("{id}", id)); } PartitionRef::Planned { .. } => {} } diff --git a/crates/sirius-core/src/lib.rs b/crates/sirius-core/src/lib.rs new file mode 100644 index 0000000..10733ad --- /dev/null +++ b/crates/sirius-core/src/lib.rs @@ -0,0 +1,16 @@ +//! Pure domain models and process protocol shared across Sirius. +//! +//! This crate performs no hardware discovery, disk mutation, process spawning, +//! or graphical work. Those responsibilities belong to `sirius-backend` and +//! `sirius-app`. + +pub mod distro; +pub mod install; +pub mod protocol; + +pub use distro::{Bento, BootcConfig, Branding, DiskConfig, DistroDescriptor}; +pub use install::{ + InstallConfig, InstallType, MountAssignment, PartitionOperation, PartitionPlan, PartitionRef, + UserAccount, validate_encryption_passphrase, +}; +pub use protocol::{InstallRequest, Progress}; diff --git a/crates/sirius-core/src/protocol.rs b/crates/sirius-core/src/protocol.rs new file mode 100644 index 0000000..94da59c --- /dev/null +++ b/crates/sirius-core/src/protocol.rs @@ -0,0 +1,89 @@ +//! Stable messages exchanged across the unprivileged/privileged boundary. + +use crate::PartitionPlan; +use serde::{Deserialize, Serialize}; + +/// User choices sent to the privileged installer. +/// +/// Distribution-owned image and partition-layout settings are deliberately +/// absent: the privileged side reads those from its root-owned descriptor. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct InstallRequest { + pub target_disk: String, + pub encrypt: bool, + pub tpm: bool, + pub encryption_key: String, + pub locale: String, + pub keyboard: String, + pub timezone: String, + pub hostname: String, + pub username: String, + pub full_name: String, + pub partition_plan: Option, +} + +/// Progress reported by the privileged runner to the app as JSON lines. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Progress { + Step { fraction: f64, message: String }, + Log { line: String }, + Finished, + Error { message: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_json_remains_wire_compatible() { + assert_eq!( + serde_json::to_string(&Progress::Step { + fraction: 0.5, + message: "partitioning".into(), + }) + .unwrap(), + r#"{"Step":{"fraction":0.5,"message":"partitioning"}}"# + ); + assert_eq!( + serde_json::to_string(&Progress::Log { + line: "formatting".into(), + }) + .unwrap(), + r#"{"Log":{"line":"formatting"}}"# + ); + assert_eq!( + serde_json::to_string(&Progress::Finished).unwrap(), + r#""Finished""# + ); + assert_eq!( + serde_json::to_string(&Progress::Error { + message: "failed".into(), + }) + .unwrap(), + r#"{"Error":{"message":"failed"}}"# + ); + } + + #[test] + fn request_json_keeps_existing_field_names() { + let request = InstallRequest { + target_disk: "/dev/vdb".into(), + encrypt: false, + tpm: false, + encryption_key: String::new(), + locale: "en_US".into(), + keyboard: "us".into(), + timezone: "UTC".into(), + hostname: "localhost".into(), + username: "demo".into(), + full_name: "Demo".into(), + partition_plan: None, + }; + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["target_disk"], "/dev/vdb"); + assert_eq!(value["partition_plan"], serde_json::Value::Null); + assert!(value.get("image").is_none()); + assert!(value.get("repart_dir").is_none()); + } +} diff --git a/crates/sirius-diag/Cargo.toml b/crates/sirius-diag/Cargo.toml index edaf615..1a270c2 100644 --- a/crates/sirius-diag/Cargo.toml +++ b/crates/sirius-diag/Cargo.toml @@ -11,3 +11,4 @@ sysinfo = { workspace = true } # Check labels/details are user-facing: resolve them through the process # textdomain ("sirius", initialized by the installer binary) at probe time. gettext-rs = { workspace = true } +lsblk = { workspace = true } diff --git a/crates/sirius-diag/src/config.rs b/crates/sirius-diag/src/config.rs index 8c47ac0..589e172 100644 --- a/crates/sirius-diag/src/config.rs +++ b/crates/sirius-diag/src/config.rs @@ -66,6 +66,7 @@ impl SiriusConfig { /// Every page Sirius knows how to render. Used to drop unknown ids from config. pub const KNOWN_PAGES: &[&str] = &[ "welcome", + "language", "diagnostics", "network", "keyboard", @@ -116,6 +117,11 @@ impl PagesConfig { resolved.push(m.to_string()); } } + // The branded welcome is part of the installer shell, rather than + // an optional configuration question. Keep it first even when an old + // distro config predates the page or places it elsewhere. + resolved.retain(|page| page != "welcome"); + resolved.insert(0, "welcome".to_string()); resolved } } @@ -252,10 +258,20 @@ min_ram_gib = 3 }; assert_eq!( pages.resolve(), - vec!["storage", "welcome", "progress", "finished"] + vec!["welcome", "storage", "progress", "finished"] ); } + #[test] + fn resolve_always_pins_welcome_before_language() { + let pages = PagesConfig { + order: vec!["language".into(), "welcome".into(), "storage".into()], + disabled: vec!["welcome".into()], + }; + assert_eq!(pages.resolve().first().map(String::as_str), Some("welcome")); + assert_eq!(pages.resolve().get(1).map(String::as_str), Some("language")); + } + #[test] fn load_missing_file_warns_and_defaults() { let (cfg, warning) = SiriusConfig::load_or_default(Path::new("/no/such/sirius.toml")); diff --git a/crates/sirius-diag/src/facts.rs b/crates/sirius-diag/src/facts.rs index 740a2de..6305c31 100644 --- a/crates/sirius-diag/src/facts.rs +++ b/crates/sirius-diag/src/facts.rs @@ -4,6 +4,8 @@ use std::path::PathBuf; use std::process::Command; +const SECTOR_BYTES: u64 = 512; + /// Raw, unjudged facts read from the running system. #[derive(Debug, Clone)] pub struct SystemFacts { @@ -39,19 +41,13 @@ fn total_ram_bytes() -> u64 { sys.total_memory() } -/// Largest whole-disk size in bytes via `lsblk -b -d -n -o SIZE`. +/// Largest writable whole-disk size in bytes. fn largest_disk_bytes() -> u64 { - let out = Command::new("lsblk") - .args(["-b", "-d", "-n", "-o", "SIZE"]) - .output(); - match out { - Ok(o) => String::from_utf8_lossy(&o.stdout) - .lines() - .filter_map(|l| l.trim().parse::().ok()) - .max() - .unwrap_or(0), - Err(_) => 0, - } + list_disks() + .into_iter() + .map(|disk| disk.size_bytes) + .max() + .unwrap_or(0) } /// efivar SecureBoot state. The 5th byte of the variable is 1 when enabled. @@ -84,53 +80,70 @@ pub struct DiskInfo { pub size_bytes: u64, } -/// List candidate target disks via `lsblk -b -d -n -P -o NAME,SIZE,MODEL,TYPE,RO`. +/// List candidate targets directly through the `lsblk` crate and sysfs. /// Keeps only writable whole disks: pseudo block devices (zram, loop, ram, /// device-mapper, md, optical) are never valid install targets. /// Returns an empty list on error (caller shows "no disks found"). pub fn list_disks() -> Vec { - let out = std::process::Command::new("lsblk") - .args(["-b", "-d", "-n", "-P", "-o", "NAME,SIZE,MODEL,TYPE,RO"]) - .output(); - let Ok(out) = out else { return Vec::new() }; - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(parse_lsblk_line) - .collect() + let Ok(Ok(devices)) = std::panic::catch_unwind(lsblk::BlockDevice::list) else { + return Vec::new(); + }; + let mut disks = devices.iter().filter_map(disk_info).collect::>(); + disks.sort_by(|left, right| left.path.cmp(&right.path)); + disks } -/// Parse one `lsblk -P` line (`KEY="value" ...`) into a `DiskInfo`, applying -/// the install-target filter. `None` for filtered-out or malformed lines. -fn parse_lsblk_line(line: &str) -> Option { - // -P emits alternating `KEY="`/`value` segments when split on '"'. - let mut fields = std::collections::HashMap::new(); - let mut parts = line.split('"'); - while let (Some(key), Some(value)) = (parts.next(), parts.next()) { - fields.insert(key.trim().trim_end_matches('='), value); - } +fn disk_info(device: &lsblk::BlockDevice) -> Option { + let sysfs = device.sysfs().ok()?; + let size = device + .capacity() + .ok() + .flatten()? + .saturating_mul(SECTOR_BYTES); + let read_only = read_u64(sysfs.join("ro")).is_some_and(|value| value != 0); + let is_partition = sysfs.join("partition").exists(); + let model = read_trimmed(sysfs.join("device/model")).unwrap_or_default(); + + candidate_disk(&device.name, size, read_only, is_partition, &model) +} - let name = fields.get("NAME")?; - let size = fields.get("SIZE")?.parse::().ok()?; +fn candidate_disk( + name: &str, + size_bytes: u64, + read_only: bool, + is_partition: bool, + model: &str, +) -> Option { let pseudo = ["zram", "loop", "ram", "sr", "fd", "dm-", "md"]; - if *fields.get("TYPE")? != "disk" - || *fields.get("RO")? != "0" - || size == 0 - || pseudo.iter().any(|p| name.starts_with(p)) + if is_partition + || read_only + || size_bytes == 0 + || pseudo.iter().any(|prefix| name.starts_with(prefix)) { return None; } - let model = fields.get("MODEL").map(|m| m.trim()).unwrap_or_default(); Some(DiskInfo { path: format!("/dev/{name}"), - model: if model.is_empty() { + model: if model.trim().is_empty() { gettextrs::gettext("Disk") } else { - model.into() + model.trim().into() }, - size_bytes: size, + size_bytes, }) } +fn read_trimmed(path: impl AsRef) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +fn read_u64(path: impl AsRef) -> Option { + read_trimmed(path)?.parse().ok() +} + #[cfg(test)] mod tests { use super::*; @@ -143,35 +156,34 @@ mod tests { } #[test] - fn lsblk_line_keeps_real_disks() { - let d = parse_lsblk_line( - r#"NAME="vda" SIZE="68719476736" MODEL="Virtio Block Device" TYPE="disk" RO="0""#, - ) - .unwrap(); + fn keeps_real_disks() { + let d = candidate_disk("vda", 68_719_476_736, false, false, "Virtio Block Device").unwrap(); assert_eq!(d.path, "/dev/vda"); assert_eq!(d.model, "Virtio Block Device"); - assert_eq!(d.size_bytes, 68719476736); + assert_eq!(d.size_bytes, 68_719_476_736); } #[test] - fn lsblk_line_drops_pseudo_devices() { - // zram reports TYPE="disk" but is never an install target. - for line in [ - r#"NAME="zram0" SIZE="8589934592" MODEL="" TYPE="disk" RO="0""#, - r#"NAME="loop0" SIZE="1234" MODEL="" TYPE="loop" RO="0""#, - r#"NAME="sr0" SIZE="2048" MODEL="QEMU DVD-ROM" TYPE="rom" RO="1""#, - r#"NAME="sda" SIZE="0" MODEL="Empty Reader" TYPE="disk" RO="0""#, - r#"NAME="sdb" SIZE="1024" MODEL="WP Disk" TYPE="disk" RO="1""#, + fn drops_pseudo_and_unusable_devices() { + for candidate in [ + ("zram0", 8_589_934_592, false, false), + ("loop0", 1234, false, false), + ("sr0", 2048, true, false), + ("sda", 0, false, false), + ("sdb", 1024, true, false), + ("sdc1", 1024, false, true), ] { - assert!(parse_lsblk_line(line).is_none(), "should drop: {line}"); + assert!( + candidate_disk(candidate.0, candidate.1, candidate.2, candidate.3, "").is_none(), + "should drop: {}", + candidate.0 + ); } } #[test] - fn lsblk_line_defaults_missing_model() { - let d = - parse_lsblk_line(r#"NAME="nvme0n1" SIZE="512000000000" MODEL="" TYPE="disk" RO="0""#) - .unwrap(); + fn defaults_missing_model() { + let d = candidate_disk("nvme0n1", 512_000_000_000, false, false, "").unwrap(); assert_eq!(d.model, "Disk"); } } diff --git a/crates/sirius-installer/Cargo.toml b/crates/sirius-installer/Cargo.toml index 5b8f6b3..17bf48b 100644 --- a/crates/sirius-installer/Cargo.toml +++ b/crates/sirius-installer/Cargo.toml @@ -10,21 +10,15 @@ name = "sirius" path = "src/main.rs" [dependencies] -sirius-diag = { path = "../sirius-diag" } +sirius-app = { workspace = true } +sirius-backend = { workspace = true } +sirius-core = { workspace = true } +sirius-diag = { workspace = true } clap = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } -toml = { workspace = true } -relm4 = { workspace = true } gettext-rs = { workspace = true } -adw = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -libc = { workspace = true } -uuid = { workspace = true } -zbus = { workspace = true } -zvariant = { workspace = true } -libreadymade = { workspace = true } [package.metadata.generate-rpm] assets = [ @@ -32,6 +26,9 @@ assets = [ { source = "../../data/io.sirius.Installer.policy", dest = "/usr/share/polkit-1/actions/io.sirius.Installer.policy", mode = "644" }, { source = "../../data/io.sirius.Installer.desktop", dest = "/usr/share/applications/io.sirius.Installer.desktop", mode = "644" }, { source = "../../data/icons/hicolor/scalable/apps/io.sirius.Installer.svg", dest = "/usr/share/icons/hicolor/scalable/apps/io.sirius.Installer.svg", mode = "644" }, + { source = "../../data/images/welcome-banner.png", dest = "/usr/share/sirius/welcome-banner.png", mode = "644" }, + { source = "../../data/images/timezone-map.svg", dest = "/usr/share/sirius/timezone-map.svg", mode = "644" }, + { source = "../../data/images/timezone-pin.png", dest = "/usr/share/sirius/timezone-pin.png", mode = "644" }, { source = "../../data/distro.toml", dest = "/etc/sirius/distro.toml", mode = "644", config = "noreplace" }, { source = "../../data/sirius.toml", dest = "/etc/sirius/sirius.toml", mode = "644", config = "noreplace" }, { source = "../../data/repart.d/10-esp.conf", dest = "/usr/share/sirius/repart.d/10-esp.conf", mode = "644" }, @@ -47,4 +44,6 @@ polkit = "*" udisks2 = "*" NetworkManager = "*" util-linux = "*" +libgweather = "*" +glycin-loaders = "*" hicolor-icon-theme = "*" diff --git a/crates/sirius-installer/src/backend/adapter.rs b/crates/sirius-installer/src/backend/adapter.rs deleted file mode 100644 index d545124..0000000 --- a/crates/sirius-installer/src/backend/adapter.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Converts the UI's `InstallConfig` into a serializable `InstallRequest` that -//! crosses the privilege boundary, and from there into a libreadymade -//! `Playbook` on the privileged side. -//! -//! # Privilege boundary -//! -//! The request carries ONLY the user's choices (disk, encryption, locale, -//! account). What gets installed — the bootc image, repart layout — is read by -//! the privileged runner itself from the root-owned descriptor at -//! `/etc/sirius/distro.toml`. The unprivileged UI must not be able to point the -//! root process at an arbitrary image or repart directory. - -use crate::backend::distro::DistroDescriptor; -use crate::config_model::{InstallConfig, InstallType, PartitionPlan}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] -pub struct InstallRequest { - pub target_disk: String, - pub encrypt: bool, - pub tpm: bool, - pub encryption_key: String, - pub locale: String, - pub keyboard: String, - pub timezone: String, - pub hostname: String, - pub username: String, - pub full_name: String, - pub partition_plan: Option, -} - -/// Build the wire request from collected UI config. -/// Returns an error string naming the first missing/invalid required field. -pub fn build_request(cfg: &InstallConfig) -> Result { - let target_disk = cfg - .destination_disk - .clone() - .ok_or("no destination disk selected")?; - let install_type = cfg.install_type.ok_or("no partition mode selected")?; - let encrypt = matches!(install_type, InstallType::Encrypted) || cfg.encrypt; - if matches!(install_type, InstallType::Manual) { - let plan = cfg - .partition_plan - .as_ref() - .ok_or("manual partitioning has no partition plan")?; - if plan.disk_path != target_disk { - return Err("manual partition plan targets a different disk".into()); - } - plan.validate(std::path::Path::new("/sys/firmware/efi").exists())?; - } - if encrypt { - cfg.validate_encryption()?; - } - if !cfg.user.is_empty() { - cfg.user.validate()?; - } - Ok(InstallRequest { - target_disk, - encrypt, - tpm: cfg.tpm && encrypt, - encryption_key: if encrypt { - cfg.encryption_passphrase.clone() - } else { - String::new() - }, - locale: cfg.locale.clone().unwrap_or_else(|| "en_US".into()), - keyboard: cfg.keyboard.clone().unwrap_or_else(|| "us".into()), - timezone: cfg.timezone.clone().unwrap_or_else(|| "UTC".into()), - hostname: cfg.user.hostname.clone(), - username: cfg.user.username.clone(), - full_name: cfg.user.full_name.clone(), - partition_plan: cfg.partition_plan.clone(), - }) -} - -impl InstallRequest { - /// Construct the real libreadymade [`Playbook`] from this request plus the - /// distro descriptor. - /// - /// Runs on the privileged side after the request has crossed the pkexec - /// boundary as JSON; `distro` is loaded there from the root-owned - /// `/etc/sirius/distro.toml`, never taken from the request. - /// - /// # Postinstall coverage - /// - /// libreadymade's `postinstall::Module` enum (at the pinned SHA) exposes - /// only these variants: `SELinux`, `Dracut`, `ReinstallKernel`, `GRUB2`, - /// `CleanupBoot`, `PrepareFedora`, `EfiStub { distro_name }`, `InitialSetup`, - /// `Language { lang }`, `CryptSetup`, `Script`, `Fstab`. There is **no** - /// module for setting the hostname, creating the user account, the timezone, - /// or the keyboard layout. We therefore: - /// - /// - map `locale` -> `Module::Language { lang }`, and - /// - emit `Module::InitialSetup`, which writes `/.unconfigured` to trigger - /// the distribution's first-boot setup agent (e.g. gnome-initial-setup) where the user - /// account and hostname are configured on next boot. - /// - /// `username`/`full_name`/`hostname`/`timezone`/`keyboard` are carried on the - /// request but have no upstream module to consume them here — see the report. - pub fn into_playbook( - self, - distro: &DistroDescriptor, - manual_mounts: Option, - ) -> libreadymade::playbook::Playbook { - use libreadymade::backend::postinstall::Module; - use libreadymade::backend::postinstall::initial_setup::InitialSetup; - use libreadymade::backend::postinstall::language::Language; - use libreadymade::backend::provisioners::disk::manual::Manual; - use libreadymade::backend::provisioners::disk::repart::Repart; - use libreadymade::backend::provisioners::filesystem::Bootc; - use libreadymade::backend::provisioners::{DiskProvisioner, FileSystemProvisioner}; - use libreadymade::playbook::{EncryptionConfig, Playbook}; - use std::path::PathBuf; - - let encryption = self.encrypt.then_some(EncryptionConfig { - tpm: self.tpm, - encryption_key: self.encryption_key, - }); - - let disk_provisioner = if let Some(mounts) = manual_mounts { - DiskProvisioner::Manual(Manual { mounts }) - } else { - DiskProvisioner::Repart(Repart { - directory: PathBuf::from(distro.disk.repart_dir.clone()), - copy_source: None, - }) - }; - - let filesystem_provisioner = Some(FileSystemProvisioner::Bootc(Bootc { - imgref: distro.bootc.image.clone(), - target_imgref: distro.bootc.target_imgref.clone(), - enforce_sigpolicy: distro.bootc.enforce_sigpolicy, - kargs: distro.bootc.kargs.clone(), - args: distro.bootc.args.clone(), - })); - - let postinstall = vec![ - Module::Language(Language { lang: self.locale }), - Module::InitialSetup(InitialSetup), - ]; - - Playbook { - destination_disk: PathBuf::from(self.target_disk), - encryption, - disk_provisioner, - filesystem_provisioner, - postinstall, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config_model::{InstallType, UserAccount}; - - fn descriptor() -> DistroDescriptor { - use crate::backend::distro::{BootcConfig, DiskConfig}; - DistroDescriptor { - bootc: BootcConfig { - image: "ghcr.io/example/os:latest".into(), - target_imgref: None, - enforce_sigpolicy: false, - kargs: vec![], - args: vec![], - }, - disk: DiskConfig { - repart_dir: "/usr/share/sirius/repart.d".into(), - }, - bentos: vec![], - branding: Default::default(), - } - } - - fn full_config() -> InstallConfig { - InstallConfig { - locale: Some("pt_BR".into()), - keyboard: Some("br".into()), - timezone: Some("America/Sao_Paulo".into()), - destination_disk: Some("/dev/sda".into()), - destination_disk_name: Some("Test Disk".into()), - install_type: Some(InstallType::Encrypted), - partition_plan: None, - encrypt: false, - tpm: true, - encryption_passphrase: "correct horse battery staple".into(), - encryption_passphrase_confirm: "correct horse battery staple".into(), - user: UserAccount { - full_name: "Ada Lovelace".into(), - username: "ada".into(), - password: "hunter2hunter".into(), - password_confirm: "hunter2hunter".into(), - hostname: "localhost".into(), - }, - } - } - - #[test] - fn builds_request_from_full_config() { - let req = build_request(&full_config()).unwrap(); - assert_eq!(req.target_disk, "/dev/sda"); - assert!(req.encrypt); - assert!(req.tpm); - assert_eq!(req.timezone, "America/Sao_Paulo"); - // The LUKS key is the dedicated passphrase, not the account password. - assert_eq!(req.encryption_key, "correct horse battery staple"); - } - - #[test] - fn request_carries_no_image_or_repart_fields() { - // The privilege boundary: what gets installed comes from the root-owned - // descriptor, never from the unprivileged request. - let req = build_request(&full_config()).unwrap(); - let json = serde_json::to_string(&req).unwrap(); - assert!(!json.contains("bootc")); - assert!(!json.contains("repart")); - } - - #[test] - fn missing_disk_errors() { - let mut cfg = full_config(); - cfg.destination_disk = None; - let err = build_request(&cfg).unwrap_err(); - assert_eq!(err, "no destination disk selected"); - } - - #[test] - fn tpm_requires_encryption() { - let mut cfg = full_config(); - cfg.install_type = Some(InstallType::WholeDisk); - cfg.encrypt = false; - cfg.tpm = true; - let req = build_request(&cfg).unwrap(); - assert!(!req.encrypt); - assert!(!req.tpm); - } - - #[test] - fn no_encryption_key_when_plaintext() { - let mut cfg = full_config(); - cfg.install_type = Some(InstallType::WholeDisk); - cfg.encrypt = false; - let req = build_request(&cfg).unwrap(); - assert_eq!(req.encryption_key, ""); - } - - #[test] - fn plaintext_install_allows_missing_user() { - let mut cfg = full_config(); - cfg.install_type = Some(InstallType::WholeDisk); - cfg.encrypt = false; - cfg.tpm = false; - cfg.user = UserAccount::default(); - - let req = build_request(&cfg).unwrap(); - assert!(!req.encrypt); - assert_eq!(req.username, ""); - assert_eq!(req.encryption_key, ""); - } - - #[test] - fn encrypted_install_requires_passphrase() { - let mut cfg = full_config(); - cfg.install_type = Some(InstallType::Encrypted); - cfg.encrypt = true; - cfg.encryption_passphrase.clear(); - cfg.encryption_passphrase_confirm.clear(); - - let err = build_request(&cfg).unwrap_err(); - assert_eq!(err, "Passphrase must be at least 8 characters"); - } - - #[test] - fn encrypted_install_does_not_require_user_account() { - // The passphrase is dedicated now, so encryption no longer binds to - // (or requires) the account password. - let mut cfg = full_config(); - cfg.install_type = Some(InstallType::Encrypted); - cfg.encrypt = true; - cfg.user = UserAccount::default(); - - let req = build_request(&cfg).unwrap(); - assert!(req.encrypt); - assert_eq!(req.encryption_key, "correct horse battery staple"); - assert_eq!(req.username, ""); - } - - #[test] - fn playbook_takes_image_and_repart_from_descriptor() { - use libreadymade::backend::provisioners::{DiskProvisioner, FileSystemProvisioner}; - - let mut distro = descriptor(); - distro.bootc.target_imgref = Some("ghcr.io/example/os:stable".into()); - distro.bootc.enforce_sigpolicy = true; - distro.bootc.kargs = vec!["rhgb".into(), "quiet".into()]; - distro.bootc.args = vec!["--skip-fetch-check".into()]; - - let req = build_request(&full_config()).unwrap(); - let playbook = req.into_playbook(&distro, None); - - let DiskProvisioner::Repart(repart) = &playbook.disk_provisioner else { - panic!("expected repart disk provisioner"); - }; - assert_eq!( - repart.directory, - std::path::PathBuf::from("/usr/share/sirius/repart.d") - ); - let Some(FileSystemProvisioner::Bootc(bootc)) = &playbook.filesystem_provisioner else { - panic!("expected bootc filesystem provisioner"); - }; - assert_eq!(bootc.imgref, "ghcr.io/example/os:latest"); - assert_eq!( - bootc.target_imgref, - Some("ghcr.io/example/os:stable".into()) - ); - assert!(bootc.enforce_sigpolicy); - assert_eq!(bootc.kargs, vec!["rhgb", "quiet"]); - assert_eq!(bootc.args, vec!["--skip-fetch-check"]); - } -} diff --git a/crates/sirius-installer/src/backend/mod.rs b/crates/sirius-installer/src/backend/mod.rs deleted file mode 100644 index 599f12d..0000000 --- a/crates/sirius-installer/src/backend/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Backend boundary between the Sirius UI and `libreadymade`. -//! -//! This module isolates the rest of the installer from the readymade execution -//! API so that UI code never depends directly on upstream types. -//! -//! # Pinned upstream -//! -//! `libreadymade` is pinned to the LuminusOS fork of readymade, rev -//! `c58f56daa25463c660a1488bce5e5a45f0328c2b`, while upstream fixes required -//! by Sirius are pending (see the workspace `Cargo.toml`). The fork also -//! carries the `filesystem-table` build fix that previously required a -//! vendored `[patch]` override. -//! -//! # Confirmed `libreadymade` execution API (at the pinned SHA) -//! -//! - `playbook::Playbook` — plain serde struct: -//! - `destination_disk: PathBuf` -//! - `encryption: Option` -//! - `disk_provisioner: backend::provisioners::DiskProvisioner` -//! - `filesystem_provisioner: Option` -//! - `postinstall: Vec` -//! - `Playbook::channel() -> (mpsc::Sender, mpsc::Receiver)` -//! - `Playbook::play(&self, mpsc::Sender) -> color_eyre::Result<()>` -//! (the crate's `Result` alias is `color_eyre::Result`, via its prelude) -//! - `playbook::PlaybookProgress`: -//! - `Stage(String)` -//! - `StageProgress(String)` -//! - `PostModule(String, usize, usize)` -//! -//! No deviation from the documented API was observed. Note: `color_eyre` is not -//! a direct dependency here, so the anchor below references the API items by name -//! rather than spelling out the `color_eyre::Result<()>` return type. - -pub mod adapter; -pub mod distro; -pub mod network; -pub mod runner; -pub mod spawn; -pub mod storage; - -/// Progress reported to the UI, decoupled from libreadymade's `PlaybookProgress`. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub enum Progress { - Step { - fraction: f64, - message: String, - }, - /// A raw log line (the runner's stderr, where libreadymade traces the - /// actual work). Shown in the install log, does not move the bar. - Log { - line: String, - }, - Finished, - Error { - message: String, - }, -} - -#[allow(dead_code)] -fn _api_anchor() { - // `color_eyre` is not a direct dependency of this crate, so the return type - // of `Playbook::play` (`color_eyre::Result<()>`) cannot be named here. We - // anchor the items by reference instead, which still forces libreadymade to - // link and breaks the build if these names/paths ever change upstream. - use libreadymade::playbook::{Playbook, PlaybookProgress}; - let _ = Playbook::channel; - let _ = Playbook::play; - let _ = |p: PlaybookProgress| match p { - PlaybookProgress::Stage(_) => {} - PlaybookProgress::StageProgress(_) => {} - PlaybookProgress::PostModule(_, _, _) => {} - }; -} diff --git a/crates/sirius-installer/src/gui.rs b/crates/sirius-installer/src/gui.rs deleted file mode 100644 index 371c42c..0000000 --- a/crates/sirius-installer/src/gui.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! GTK4 + libadwaita + Relm4 wizard entry point. - -use relm4::RelmApp; - -/// Launch the installer GUI. -pub fn run() { - let app = RelmApp::new("io.sirius.Installer"); - app.run::(()); -} diff --git a/crates/sirius-installer/src/main.rs b/crates/sirius-installer/src/main.rs index 93e06ce..e79a299 100644 --- a/crates/sirius-installer/src/main.rs +++ b/crates/sirius-installer/src/main.rs @@ -1,13 +1,6 @@ -//! Sirius installer entry point: diagnostics, dry-run and the GTK assistant. +//! Thin Sirius entry point: diagnostics, dry-run, GTK app and privileged runner. -mod app; -mod backend; -mod config_model; -mod gui; mod logging; -mod navigator; -mod pages; -mod style; use clap::{Parser, Subcommand}; use sirius_diag::config::CONFIG_PATH; @@ -36,6 +29,9 @@ enum Command { /// Internal: execute an install request from stdin (run under pkexec). Not for direct use. #[command(hide = true)] RunPlaybook, + /// Internal: execute inside the private mount namespace prepared by RunPlaybook. + #[command(hide = true)] + RunPlaybookInner, } fn main() -> ExitCode { @@ -45,7 +41,11 @@ fn main() -> ExitCode { // against the same catalogs (LANGUAGE is pinned from the request there). if matches!(cli.command, Some(Command::RunPlaybook)) { init_gettext(); - return ExitCode::from(backend::runner::run() as u8); + return ExitCode::from(sirius_backend::runner::run_isolated() as u8); + } + if matches!(cli.command, Some(Command::RunPlaybookInner)) { + init_gettext(); + return ExitCode::from(sirius_backend::runner::run() as u8); } let _log = logging::init(); if cli.dry_run { @@ -59,9 +59,10 @@ fn main() -> ExitCode { run_diag(json) } Some(Command::RunPlaybook) => unreachable!("handled above"), + Some(Command::RunPlaybookInner) => unreachable!("handled above"), None => { init_gettext(); - gui::run(); + sirius_app::run(); ExitCode::SUCCESS } } @@ -88,7 +89,7 @@ fn init_gettext() { } fn sirius_installer_dry_run() -> serde_json::Value { - use config_model::{InstallConfig, InstallType, UserAccount}; + use sirius_core::{InstallConfig, InstallType, UserAccount}; let cfg = InstallConfig { locale: Some("en_US".into()), keyboard: Some("us".into()), @@ -109,11 +110,8 @@ fn sirius_installer_dry_run() -> serde_json::Value { hostname: "localhost".into(), }, }; - let distro = backend::distro::DistroDescriptor::from_toml( - &std::fs::read_to_string("data/distro.toml").unwrap_or_default(), - ) - .expect("data/distro.toml must parse"); - let req = backend::adapter::build_request(&cfg).expect("dry-run config must be valid"); + let distro = sirius_backend::distro::load().expect("installed distro descriptor must parse"); + let req = sirius_backend::install::build_request(&cfg).expect("dry-run config must be valid"); serde_json::json!({ "request": req, "distro": distro }) } diff --git a/crates/sirius-installer/src/pages/keyboard.rs b/crates/sirius-installer/src/pages/keyboard.rs deleted file mode 100644 index 37ff59d..0000000 --- a/crates/sirius-installer/src/pages/keyboard.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Keyboard layout page: lets the user pick a keyboard layout and test it. - -use super::PageOutput; -use gettextrs::gettext; -use relm4::adw::prelude::*; -use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; - -const LAYOUTS: &[(&str, &str)] = &[("us", "English (US)"), ("br", "Portuguese (Brazil)")]; - -pub struct KeyboardPage; - -#[derive(Debug)] -pub enum KeyboardMsg { - Chosen(usize), - /// The UI language changed; gettext resolves strings at render time, so a - /// bare re-render (Relm4 runs update_view after update) is enough. - Retranslate, -} - -#[relm4::component(pub)] -impl SimpleComponent for KeyboardPage { - type Init = (); - type Input = KeyboardMsg; - type Output = PageOutput; - - view! { - adw::StatusPage { - set_icon_name: Some("input-keyboard-symbolic"), - #[watch] - set_title: gettext("Keyboard layout").as_str(), - #[wrap(Some)] - set_child = >k::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 12, - set_halign: gtk::Align::Center, - gtk::DropDown { - set_model: Some(>k::StringList::new( - &LAYOUTS.iter().map(|(_, label)| *label).collect::>() - )), - connect_selected_notify[sender] => move |dd| { - sender.input(KeyboardMsg::Chosen(dd.selected() as usize)); - }, - }, - gtk::Entry { - #[watch] - set_placeholder_text: Some(gettext("Type here to test your layout").as_str()), - }, - }, - } - } - - fn init( - _i: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - sender - .output(PageOutput::SetKeyboard(LAYOUTS[0].0.to_string())) - .ok(); - let model = KeyboardPage; - let widgets = view_output!(); - ComponentParts { model, widgets } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - KeyboardMsg::Chosen(i) => { - let code = LAYOUTS.get(i).map(|(c, _)| *c).unwrap_or("us"); - sender - .output(PageOutput::SetKeyboard(code.to_string())) - .ok(); - } - KeyboardMsg::Retranslate => {} - } - } -} diff --git a/crates/sirius-installer/src/pages/timezone.rs b/crates/sirius-installer/src/pages/timezone.rs deleted file mode 100644 index ddbd2f6..0000000 --- a/crates/sirius-installer/src/pages/timezone.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Timezone page: lets the user select a time zone from a dropdown. - -use super::PageOutput; -use gettextrs::gettext; -use relm4::adw::prelude::*; -use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; - -const ZONES: &[&str] = &[ - "America/Sao_Paulo", - "America/New_York", - "Europe/London", - "UTC", -]; - -pub struct TimezonePage; - -#[derive(Debug)] -pub enum TimezoneMsg { - Chosen(usize), - /// The UI language changed; gettext resolves strings at render time, so a - /// bare re-render (Relm4 runs update_view after update) is enough. - Retranslate, -} - -#[relm4::component(pub)] -impl SimpleComponent for TimezonePage { - type Init = (); - type Input = TimezoneMsg; - type Output = PageOutput; - - view! { - adw::StatusPage { - set_icon_name: Some("alarm-symbolic"), - #[watch] - set_title: gettext("Time zone").as_str(), - #[wrap(Some)] - set_child = >k::DropDown { - set_halign: gtk::Align::Center, - set_model: Some(>k::StringList::new(ZONES)), - connect_selected_notify[sender] => move |dd| { - sender.input(TimezoneMsg::Chosen(dd.selected() as usize)); - }, - }, - } - } - - fn init( - _i: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - sender - .output(PageOutput::SetTimezone(ZONES[0].to_string())) - .ok(); - let model = TimezonePage; - let widgets = view_output!(); - ComponentParts { model, widgets } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - TimezoneMsg::Chosen(i) => { - let zone = ZONES.get(i).copied().unwrap_or("UTC"); - sender - .output(PageOutput::SetTimezone(zone.to_string())) - .ok(); - } - TimezoneMsg::Retranslate => {} - } - } -} diff --git a/crates/sirius-installer/src/pages/welcome.rs b/crates/sirius-installer/src/pages/welcome.rs deleted file mode 100644 index 6146cf9..0000000 --- a/crates/sirius-installer/src/pages/welcome.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Welcome page: greets the user and collects the install locale. -//! The artwork above the title comes from the distro descriptor's -//! `[branding]` (logo file, or themed icon) — defaulting to a star, for Sirius. - -use super::PageOutput; -use crate::backend::distro::Branding; -use gettextrs::gettext; -use relm4::adw::prelude::*; -use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; - -/// UI languages offered on the welcome page: (locale, native name). -/// The locale also flows into the install config unchanged. -const LANGUAGES: &[(&str, &str)] = &[("en_US", "English (US)"), ("pt_BR", "Português (BR)")]; - -pub struct WelcomePage; - -#[derive(Debug)] -pub enum WelcomeMsg { - LocaleChosen(String), - /// The UI language changed; gettext resolves strings at render time, so a - /// bare re-render (Relm4 runs update_view after update) is enough. - Retranslate, -} - -/// Apply `[branding]` to the status page: prefer the logo file, then the -/// themed icon, then the default star. -fn apply_branding(page: &adw::StatusPage, branding: &Branding) { - if let Some(path) = &branding.logo { - match gtk::gdk::Texture::from_filename(path) { - Ok(texture) => { - page.set_paintable(Some(&texture)); - return; - } - Err(e) => tracing::warn!("cannot load branding logo {path}: {e}"), - } - } - page.set_icon_name(Some(branding.icon.as_deref().unwrap_or("starred-symbolic"))); -} - -#[relm4::component(pub)] -impl SimpleComponent for WelcomePage { - type Init = Branding; - type Input = WelcomeMsg; - type Output = PageOutput; - - view! { - adw::StatusPage { - #[watch] - set_title: gettext("Welcome").as_str(), - #[watch] - set_description: Some(gettext("This assistant will guide you through installation.").as_str()), - - #[wrap(Some)] - set_child = >k::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 12, - set_halign: gtk::Align::Center, - - gtk::DropDown { - set_width_request: 240, - set_model: Some(>k::StringList::new( - &LANGUAGES.iter().map(|(_, name)| *name).collect::>(), - )), - connect_selected_notify[sender] => move |dd| { - let locale = LANGUAGES - .get(dd.selected() as usize) - .map(|(locale, _)| *locale) - .unwrap_or("en_US"); - sender.input(WelcomeMsg::LocaleChosen(locale.to_string())); - }, - }, - }, - } - } - - fn init( - branding: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let model = WelcomePage; - let widgets = view_output!(); - apply_branding(&root, &branding); - sender - .output(PageOutput::SetLocale("en_US".to_string())) - .ok(); - ComponentParts { model, widgets } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - WelcomeMsg::LocaleChosen(locale) => { - sender.output(PageOutput::SetLocale(locale)).ok(); - } - WelcomeMsg::Retranslate => {} - } - } -} diff --git a/data/distro.toml b/data/distro.toml index 01c922a..3c740f0 100644 --- a/data/distro.toml +++ b/data/distro.toml @@ -3,7 +3,7 @@ # bootc/OCI image and its systemd-repart partition layout. [bootc] -image = "ghcr.io/example/os:latest" +image = "docker://ghcr.io/example/os:latest" # Optional bootc install controls: # target_imgref = "ghcr.io/example/os:latest" # enforce_sigpolicy = false @@ -33,8 +33,11 @@ repart_dir = "/usr/share/sirius/repart.d" # link = "https://example.com/contribute" # icon = "applications-development-symbolic" -# Optional: welcome-page branding. `logo` (image file path) wins over `icon` -# (themed icon name); default is a star, for Sirius. -# [branding] +# Optional: installer branding. The button supports a `{name}` placeholder. +# `logo` wins over `icon`; `welcome_banner` controls the large opening artwork. +[branding] +# name = "Example OS" # logo = "/usr/share/sirius/logo.png" # icon = "starred-symbolic" +welcome_button = "Start Installation" +welcome_banner = "/usr/share/sirius/welcome-banner.png" diff --git a/data/images/timezone-map.svg b/data/images/timezone-map.svg new file mode 100644 index 0000000..71b3ebe --- /dev/null +++ b/data/images/timezone-map.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/images/timezone-pin.png b/data/images/timezone-pin.png new file mode 100644 index 0000000..c347a9e Binary files /dev/null and b/data/images/timezone-pin.png differ diff --git a/data/images/welcome-banner.png b/data/images/welcome-banner.png new file mode 100644 index 0000000..b183737 Binary files /dev/null and b/data/images/welcome-banner.png differ diff --git a/data/repart.d/10-esp.conf b/data/repart.d/10-esp.conf index ed7d1f5..4ffbd66 100644 --- a/data/repart.d/10-esp.conf +++ b/data/repart.d/10-esp.conf @@ -1,5 +1,6 @@ [Partition] Type=esp Format=vfat +MountPoint=/boot/efi SizeMinBytes=512M SizeMaxBytes=512M diff --git a/data/repart.d/20-root.conf b/data/repart.d/20-root.conf index a00da04..ee0c1dc 100644 --- a/data/repart.d/20-root.conf +++ b/data/repart.d/20-root.conf @@ -1,3 +1,4 @@ [Partition] Type=root Format=btrfs +MountPoint=/ diff --git a/data/sirius.toml b/data/sirius.toml index 6b0ae0b..f8dd55c 100644 --- a/data/sirius.toml +++ b/data/sirius.toml @@ -1,6 +1,7 @@ [pages] order = [ "welcome", + "language", "diagnostics", "network", "keyboard", diff --git a/data/style.css b/data/style.css index b6094df..5c3717b 100644 --- a/data/style.css +++ b/data/style.css @@ -16,6 +16,52 @@ box-shadow: 0 8px 24px alpha(@accent_bg_color, .28); } +.welcome-banner { + border-radius: 24px; + box-shadow: 0 12px 36px alpha(black, .22); +} + +.timezone-map-frame { + border-radius: 18px; + border: 1px solid alpha(@window_fg_color, .16); + box-shadow: 0 8px 24px alpha(black, .14); +} + +.timezone-map { + border-radius: 18px; + background-color: #62a0ea; +} + +.timezone-band { + background-color: alpha(@accent_bg_color, 0.22); + border-left: 1px solid alpha(@accent_bg_color, 0.65); + border-right: 1px solid alpha(@accent_bg_color, 0.65); +} + +/* Suggestions popover: keep the default opaque popover chrome, like GNOME + Maps' popover.suggestions, and only restyle the row states. The list is a + direct child of the popover contents, so libadwaita keeps it transparent + and the popover color stays uniform. */ +popover.timezone-suggestions list > row:hover { + background-color: alpha(currentColor, .04); +} + +popover.timezone-suggestions list > row:active { + background-color: alpha(currentColor, .08); +} + +popover.timezone-suggestions list > row:selected { + background-color: alpha(currentColor, .1); +} + +popover.timezone-suggestions list > row:selected:hover { + background-color: alpha(currentColor, .13); +} + +popover.timezone-suggestions list > row:selected:active { + background-color: alpha(currentColor, .19); +} + .storage-content { margin: 6px 24px 24px 24px; } diff --git a/docs/GAPS.md b/docs/GAPS.md index 6e64704..4b9a2e6 100644 --- a/docs/GAPS.md +++ b/docs/GAPS.md @@ -7,7 +7,7 @@ distro. ## TODO — postinstall provisioning modules -At the pinned `libreadymade` commit there is **no** postinstall module for the following +The current in-tree `libreadymade` has **no** postinstall module for the following settings. The wizard collects them and carries them on `InstallRequest`, but `into_playbook` currently wires only locale (`Language`) plus `InitialSetup` (which writes `/.unconfigured` to trigger the distribution's first-boot setup agent, e.g. @@ -27,7 +27,7 @@ a first-boot agent. - [x] ~~**Encryption key = user password (MVP).**~~ Resolved: the storage page now collects a dedicated LUKS passphrase pair (`encryption_passphrase` on `InstallConfig`, gated by `WizardState::storage_is_valid`), and - `adapter::build_request` uses it instead of the account password. + `sirius_backend::install::build_request` uses it instead of the account password. - [ ] **Placeholder repart templates.** `data/repart.d/*.conf` are generic ESP + btrfs defaults; ship real per-distribution layouts. - [ ] **pkexec target is pinned to `/usr/bin/sirius`** (polkit policy). Only works when @@ -35,11 +35,12 @@ a first-boot agent. root the runner is spawned directly (no pkexec), and pkexec exits 126/127 are reported with a clear polkit-agent hint in the progress log. A live session still needs a polkit agent or a rule granting the action (see INSTALL.md). -- [ ] **libreadymade comes from the LuminusOS fork**, pinned to rev `c58f56d`, while - upstream fixes required by Sirius are pending (the fork carries the patched - `filesystem-table` crate in-tree, so no Cargo `[patch]` override is needed). - `libreadymade` is pulled with `default-features = false` to avoid the `uutils` - feature (which needs `libacl-devel`); the default `rdm` copy backend is used. +- [ ] **libreadymade comes from the sibling LuminusOS `readymade` workspace** + through a path dependency while upstream fixes required by Sirius are pending. + That workspace carries the patched `filesystem-table` crate in-tree, so no + Cargo `[patch]` override is needed. `libreadymade` is built with + `default-features = false` to avoid the `uutils` feature (which needs + `libacl-devel`); the default `rdm` copy backend is used. - [x] ~~**Progress bar appears static** during the bootc image pull + repart.~~ Resolved: the progress page pulses the bar for any stage message without a fraction (`ProgressMsg::Pulse` on a 120 ms timer plus `advance_bar(0.0)`); @@ -63,7 +64,7 @@ page widgets are now mostly covered too: itself remain English-only (upstream has no catalogs). - [x] Account validation error messages (`UserAccount::validate`) and the LUKS passphrase validation. -- [ ] Error strings from `backend::storage` / `backend::distro` surfaced through +- [ ] Error strings from `sirius-backend::storage` / `sirius-backend::distro` surfaced through runner `fail(...)` wrappers are still English-only. - [ ] Storage and NetworkManager runtime flows still need hardware-in-the-loop coverage across SATA, NVMe, WPA3 transition mode, and multiple Wi-Fi adapters. diff --git a/docs/driver-enablement-plan.md b/docs/driver-enablement-plan.md index d4216af..a17d58a 100644 --- a/docs/driver-enablement-plan.md +++ b/docs/driver-enablement-plan.md @@ -12,7 +12,7 @@ rest of the installer is. Hard constraints: - LuminusOS is immutable bootc. Install is a `bootc install to-filesystem` of the - embedded payload (`crates/sirius-installer/src/backend/adapter.rs:105`). + embedded payload (`crates/sirius-backend/src/install.rs`). - The **live ISO has no `rpm-ostree`** (removed in `images/editions/core/Containerfile`), but it has `podman`, `dnf`, `bootc`. The payload is embedded as `containers-storage:@WORKSTATION_IMAGE@` in `/etc/sirius/distro.toml`. @@ -52,8 +52,8 @@ Surface in `sirius diag --json` for debugging (extend `report.rs` / `main.rs`). Profiles live in the root-owned descriptor (`/etc/sirius/distro.toml`), never trusted from the unprivileged request — same boundary as the bootc image -(`adapter.rs:5-11`). Add a `[[driver_profile]]` array; structs in -`crates/sirius-installer/src/backend/distro.rs` (`DriverProfile`, `ProfileMatch`, +(`sirius-core/src/distro.rs`). Add a `[[driver_profile]]` array; structs in +`crates/sirius-core/src/distro.rs` (`DriverProfile`, `ProfileMatch`, `ProfileRepo`): ```toml @@ -91,10 +91,10 @@ New configurable page `drivers`, placed after `partition`, before `summary`: - Register in known-pages (`crates/sirius-diag/src/config.rs`), default order (`navigator.rs` / `app.rs`), `pages/mod.rs`, and add En + PtBr keys to `i18n.rs`. Skippable via `sirius.toml` `pages.disabled`, like the rest. -- `InstallConfig` (`config_model.rs`) gains `driver_profiles: Vec`; +- `InstallConfig` (`sirius-core/src/install.rs`) gains `driver_profiles: Vec`; `apply_page_output` stores it; gate is trivially true. -### 4. Request + privileged resolve — `adapter.rs`, `runner.rs` +### 4. Request + privileged resolve — `install.rs`, `runner.rs` - `InstallRequest` gains `driver_profiles: Vec` (**ids only** — never raw repos/packages, preserving the privilege boundary). @@ -129,11 +129,13 @@ step with a clear message rather than deploying a half-built image. ## Files Create: `crates/sirius-diag/src/hardware.rs`, -`crates/sirius-installer/src/pages/drivers.rs`. +`crates/sirius-app/src/pages/drivers.rs`. Modify: `crates/sirius-diag/src/{lib.rs,config.rs,report.rs}`, -`crates/sirius-installer/src/{config_model.rs,i18n.rs,app.rs,navigator.rs}`, -`crates/sirius-installer/src/pages/mod.rs`, -`crates/sirius-installer/src/backend/{distro.rs,adapter.rs,runner.rs}`, +`crates/sirius-core/src/install.rs`, +`crates/sirius-app/src/{app.rs,navigator.rs}`, +`crates/sirius-app/src/pages/mod.rs`, +`crates/sirius-core/src/distro.rs`, +`crates/sirius-backend/src/{install.rs,runner.rs}`, `images/editions/workstation/files/etc/sirius/distro.toml` (seed profiles). Optional (persistence): `images/editions/core/Containerfile` (keep rpm-ostree). diff --git a/po/POTFILES b/po/POTFILES index 1a97d76..ebbd858 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -1,19 +1,21 @@ crates/sirius-diag/src/facts.rs crates/sirius-diag/src/probes.rs -crates/sirius-installer/src/app.rs -crates/sirius-installer/src/backend/runner.rs -crates/sirius-installer/src/backend/spawn.rs -crates/sirius-installer/src/config_model.rs -crates/sirius-installer/src/pages/diagnostics.rs -crates/sirius-installer/src/pages/finished.rs -crates/sirius-installer/src/pages/keyboard.rs -crates/sirius-installer/src/pages/network.rs -crates/sirius-installer/src/pages/progress.rs -crates/sirius-installer/src/pages/storage.rs -crates/sirius-installer/src/pages/storage/editor_view.rs -crates/sirius-installer/src/pages/storage/page_view.rs -crates/sirius-installer/src/pages/storage/partition_dialog.rs -crates/sirius-installer/src/pages/summary.rs -crates/sirius-installer/src/pages/timezone.rs -crates/sirius-installer/src/pages/user.rs -crates/sirius-installer/src/pages/welcome.rs +crates/sirius-app/src/app.rs +crates/sirius-backend/src/runner.rs +crates/sirius-backend/src/spawn.rs +crates/sirius-core/src/install.rs +crates/sirius-app/src/pages/diagnostics.rs +crates/sirius-app/src/pages/finished.rs +crates/sirius-app/src/pages/keyboard.rs +crates/sirius-app/src/pages/network.rs +crates/sirius-app/src/pages/progress.rs +crates/sirius-app/src/pages/language.rs +crates/sirius-app/src/pages/storage.rs +crates/sirius-app/src/pages/storage/draft.rs +crates/sirius-app/src/pages/storage/editor_view.rs +crates/sirius-app/src/pages/storage/page_view.rs +crates/sirius-app/src/pages/storage/partition_dialog.rs +crates/sirius-app/src/pages/summary.rs +crates/sirius-app/src/pages/timezone.rs +crates/sirius-app/src/pages/user.rs +crates/sirius-app/src/pages/welcome.rs diff --git a/po/pt_BR.po b/po/pt_BR.po index 08b20bb..2de4f98 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -16,12 +16,6 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -msgid "Back" -msgstr "Voltar" - -msgid "Next" -msgstr "Avançar" - msgid "Install" msgstr "Instalar" @@ -46,8 +40,20 @@ msgstr "Apagar disco e instalar" msgid "Welcome" msgstr "Bem-vindo" -msgid "This assistant will guide you through installation." -msgstr "Este assistente vai guiá-lo pela instalação." +msgid "Welcome to {name}" +msgstr "Bem-vindo ao {name}" + +msgid "Start Installation" +msgstr "Iniciar instalação" + +msgid "Choose the language used during installation." +msgstr "Escolha o idioma usado durante a instalação." + +msgid "Choose your language" +msgstr "Escolha seu idioma" + +msgid "Internet connection" +msgstr "Conexão com a internet" msgid "Network" msgstr "Rede" @@ -55,8 +61,8 @@ msgstr "Rede" msgid "Connect to a network. A connection is optional but recommended." msgstr "Conecte-se a uma rede. A conexão é opcional, mas recomendada." -msgid "Choose a Wi-Fi network below." -msgstr "Escolha uma rede Wi-Fi abaixo." +msgid "Choose a Wi-Fi network" +msgstr "Escolha uma rede Wi-Fi" msgid "Available Wi-Fi networks" msgstr "Redes Wi-Fi disponíveis" @@ -94,18 +100,39 @@ msgstr "Digite a senha do Wi-Fi." msgid "Keyboard layout" msgstr "Layout do teclado" +msgid "Select the keyboard layout you want to use." +msgstr "Selecione o layout de teclado que deseja usar." + +msgid "Select your keyboard layout" +msgstr "Selecione seu layout de teclado" + +msgid "Search keyboard layouts" +msgstr "Pesquise layouts de teclado" + msgid "Type here to test your layout" msgstr "Digite aqui para testar o layout" msgid "Time zone" msgstr "Fuso horário" +msgid "Time Zone" +msgstr "Fuso horário" + +msgid "Search for a city" +msgstr "Pesquise uma cidade" + +msgid "Search for a nearby city to select its time zone" +msgstr "Pesquise uma cidade próxima para selecionar o fuso horário" + +msgid "Select the nearest city by clicking the map" +msgstr "Selecione a cidade mais próxima clicando no mapa" + +msgid "Search for a city or select a location on the map to set your time zone." +msgstr "Pesquise uma cidade ou selecione um local no mapa para definir o fuso horário." + msgid "Partitioning" msgstr "Particionamento" -msgid "Automatic" -msgstr "Automático" - msgid "Encrypt the disk (LUKS)" msgstr "Criptografar o disco (LUKS)" @@ -187,18 +214,12 @@ msgstr "Todos os dados deste disco serão apagados." msgid "Discard changes" msgstr "Descartar alterações" -msgid "Disk layout" -msgstr "Mapa do disco" - msgid "Table" msgstr "Tabela" msgid "Partition editor" msgstr "Editor de partições" -msgid "Close" -msgstr "Fechar" - msgid "Done" msgstr "Concluído" @@ -241,18 +262,12 @@ msgstr "Editar partição" msgid "Create partition" msgstr "Criar partição" -msgid "Apply" -msgstr "Aplicar" - msgid "Filesystem" msgstr "Sistema de arquivos" msgid "Size (GiB)" msgstr "Tamanho (GiB)" -msgid "Mount point: / or /boot/efi" -msgstr "Ponto de montagem: / ou /boot/efi" - msgid "Mount Point" msgstr "Ponto de Montagem" @@ -271,24 +286,12 @@ msgstr "Nenhum disco disponível" msgid "Connect a disk or unmount its filesystems and reopen Sirius." msgstr "Conecte um disco ou desmonte seus sistemas de arquivos e reabra o Sirius." -msgid "in use" -msgstr "em uso" - msgid "System compatibility" msgstr "Compatibilidade do sistema" msgid "Sirius checked your hardware before installing." msgstr "O Sirius verificou seu hardware antes de instalar." -msgid "Select a disk" -msgstr "Selecione um disco" - -msgid "The chosen disk will be erased." -msgstr "O disco escolhido será apagado." - -msgid "No disks found" -msgstr "Nenhum disco encontrado" - msgid "Installing the system" msgstr "Instalando o sistema" @@ -415,12 +418,30 @@ msgstr "o destino não é um disco inteiro suportado: {path}" msgid "target disk has mounted filesystems; unmount them before installing: {path}" msgstr "o disco de destino tem sistemas de arquivos montados; desmonte-os antes de instalar: {path}" +msgid "cannot locate the installer executable: {error}" +msgstr "não foi possível localizar o executável do instalador: {error}" + +msgid "cannot create the private install namespace: {error}" +msgstr "não foi possível criar o espaço de nomes privado da instalação: {error}" + +msgid "cannot prepare the private install namespace: {error}" +msgstr "não foi possível preparar o espaço de nomes privado da instalação: {error}" + +msgid "cannot hide the host OSTree repository: {error}" +msgstr "não foi possível ocultar o repositório OSTree do sistema hospedeiro: {error}" + +msgid "cannot hide the host OSTree repository: mount exited with {status}" +msgstr "não foi possível ocultar o repositório OSTree do sistema hospedeiro: o mount terminou com {status}" + msgid "failed to read install request" msgstr "falha ao ler a solicitação de instalação" msgid "invalid install request: {error}" msgstr "solicitação de instalação inválida: {error}" +msgid "invalid keyboard layout: {layout}" +msgstr "layout de teclado inválido: {layout}" + msgid "cannot apply partition plan: {error}" msgstr "não foi possível aplicar o plano de partições: {error}" @@ -441,3 +462,90 @@ msgstr "o instalador saiu com o status {code}" msgid "installer was killed by a signal" msgstr "o instalador foi finalizado por um sinal" + +msgid "manual partitioning requires a GPT disk" +msgstr "o particionamento manual requer um disco GPT" + +msgid "manual partitioning requires a valid destination disk" +msgstr "o particionamento manual requer um disco de destino válido" + +msgid "planned partition ids must be unique" +msgstr "os identificadores das partições planejadas devem ser únicos" + +msgid "a planned partition is outside the destination disk" +msgstr "uma partição planejada está fora do disco de destino" + +msgid "a planned partition has an invalid GPT type" +msgstr "uma partição planejada tem um tipo GPT inválido" + +msgid "partition names and labels cannot contain NUL bytes" +msgstr "nomes e rótulos de partições não podem conter bytes NUL" + +msgid "partition labels cannot contain NUL bytes" +msgstr "rótulos de partições não podem conter bytes NUL" + +msgid "planned partitions overlap" +msgstr "as partições planejadas se sobrepõem" + +msgid "mount points must be unique absolute paths" +msgstr "os pontos de montagem devem ser caminhos absolutos únicos" + +msgid "a mount assignment does not match its formatted filesystem" +msgstr "uma atribuição de montagem não corresponde ao sistema de arquivos formatado" + +msgid "a partition cannot be deleted before it is created" +msgstr "uma partição não pode ser excluída antes de ser criada" + +msgid "choose exactly one root partition" +msgstr "escolha exatamente uma partição raiz" + +msgid "the root partition must use Btrfs or ext4" +msgstr "a partição raiz deve usar Btrfs ou ext4" + +msgid "the root partition must be at least 20 GiB" +msgstr "a partição raiz deve ter pelo menos 20 GiB" + +msgid "the root partition must be explicitly formatted" +msgstr "a partição raiz deve ser formatada explicitamente" + +msgid "choose one FAT32 EFI system partition" +msgstr "escolha uma partição de sistema EFI FAT32" + +msgid "the EFI system partition must be at least 512 MiB" +msgstr "a partição de sistema EFI deve ter pelo menos 512 MiB" + +msgid "unsupported filesystem: {filesystem}" +msgstr "sistema de arquivos não suportado: {filesystem}" + +msgid "an existing partition reference is invalid" +msgstr "uma referência de partição existente é inválida" + +msgid "planned partition does not exist: {id}" +msgstr "a partição planejada não existe: {id}" + +msgid "partition size must be at least 0.5 GiB" +msgstr "o tamanho da partição deve ser de pelo menos 0,5 GiB" + +msgid "mount point must be empty or an absolute path" +msgstr "o ponto de montagem deve estar vazio ou ser um caminho absoluto" + +msgid "swap partitions cannot have a mount point" +msgstr "partições swap não podem ter um ponto de montagem" + +msgid "partition plan no longer matches the selected disk" +msgstr "o plano de partições não corresponde mais ao disco selecionado" + +msgid "the selected free region is no longer available" +msgstr "a região livre selecionada não está mais disponível" + +msgid "partition size exceeds the available space" +msgstr "o tamanho da partição excede o espaço disponível" + +msgid "partition no longer exists" +msgstr "a partição não existe mais" + +msgid "mounted partitions cannot be deleted" +msgstr "partições montadas não podem ser excluídas" + +msgid "planned partition no longer exists" +msgstr "a partição planejada não existe mais" diff --git a/tools/generate-timezone-map.py b/tools/generate-timezone-map.py new file mode 100644 index 0000000..f730957 --- /dev/null +++ b/tools/generate-timezone-map.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Generate data/images/timezone-map.svg from Natural Earth 110m land GeoJSON. + +The projection matches the pixel math in crates/sirius-app/src/pages/timezone.rs +(Miller cylindrical, cropped to 81°N..59°S, no longitude offset), so pins and +the meridian band project onto the artwork exactly. Style mirrors the +gnome-initial-setup map: white land on GNOME blue (#62a0ea) ocean. + +Usage: generate-timezone-map.py ne_110m_land.geojson > ../data/images/timezone-map.svg +Natural Earth data is public domain (https://www.naturalearthdata.com/). +""" + +import json +import math +import sys + +WIDTH = 1600 +HEIGHT = 818 # 2x the gnome-initial-setup bg.png (800x409) for HiDPI +TOP_LATITUDE = 81.0 +BOTTOM_LATITUDE = -59.0 +MILLER_FULL_RANGE = 4.60682508676 + +OCEAN = "#62a0ea" +LAND = "#ffffff" + + +def miller(latitude: float) -> float: + return 1.25 * math.log(math.tan(math.pi / 4 + 0.4 * math.radians(latitude))) + + +TOP_OFFSET = MILLER_FULL_RANGE * (TOP_LATITUDE / 180.0) +MAP_RANGE = abs(miller(BOTTOM_LATITUDE) - TOP_OFFSET) + + +def project(longitude: float, latitude: float) -> tuple[float, float]: + x = (longitude + 180.0) / 360.0 * WIDTH + y = abs(miller(latitude) - TOP_OFFSET) / MAP_RANGE * HEIGHT + return x, y + + +def ring_path(ring: list) -> str: + parts = [] + for longitude, latitude in ring: + x, y = project(longitude, latitude) + parts.append(f"{x:.1f},{y:.1f}") + return "M" + "L".join(parts) + "Z" + + +def geometry_paths(geometry: dict) -> list[str]: + polygons = ( + [geometry["coordinates"]] + if geometry["type"] == "Polygon" + else geometry["coordinates"] + ) + paths = [] + for polygon in polygons: + # Drop Antarctica and anything else fully below the map's crop. + if all(lat < -58 for ring in polygon for _, lat in ring): + continue + paths.extend(ring_path(ring) for ring in polygon) + return paths + + +def main() -> None: + with open(sys.argv[1]) as source: + geojson = json.load(source) + + paths = [] + for feature in geojson["features"]: + paths.extend(geometry_paths(feature["geometry"])) + + print( + f'' + ) + print(f'') + print(f'') + print("") + + +if __name__ == "__main__": + main()