diff --git a/.ansible-lint b/.ansible-lint deleted file mode 100644 index a2f46dc9..00000000 --- a/.ansible-lint +++ /dev/null @@ -1,5 +0,0 @@ -profile: min -offline: true -exclude_paths: - - .cache/ - - assets/ diff --git a/.gitattributes b/.gitattributes index 83a8080b..f9d3d272 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Unified patches require a space on otherwise blank context lines. scripts/model-usage/integration.patch whitespace=-blank-at-eol +installer/kiosk/slint-kms.patch whitespace=-blank-at-eol diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 072b7f0b..10166e01 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,19 +1,19 @@ -# `cybex report` opens this form with the environment field prefilled +# `nerd report` opens this form with the environment field prefilled # (?template=bug_report.yml&environment=...). Keep the field ids stable. name: Bug report -description: Something in CybexOS does not work as expected +description: Something in NerdOS does not work as expected title: "[Bug]: " labels: [bug] body: - type: markdown attributes: value: | - Thanks for reporting a problem. Run `cybex report` first: it writes a - redacted diagnostic bundle (`cybexos-report-*.tar.gz`) on your computer + Thanks for reporting a problem. Run `nerd report` first: it writes a + redacted diagnostic bundle (`nerdos-report-*.tar.gz`) on your computer and prefills the environment below. Nothing is uploaded for you. Security problems do not belong here; report them privately as - described in [SECURITY.md](https://github.com/DigitalPals/CybexOS/blob/main/SECURITY.md). + described in [SECURITY.md](https://github.com/DigitalPals/NerdOS/blob/main/SECURITY.md). - type: textarea id: summary attributes: @@ -35,7 +35,7 @@ body: id: environment attributes: label: Environment - description: Filled in by `cybex report`. Otherwise give the CybexOS version (`cybex version`), whether it was installed from the ISO or a checkout, and your GPU. + description: Filled in by `nerd report`. Otherwise give the NerdOS version (`nerd version`), whether it was installed from the ISO or a checkout, and your GPU. validations: required: true - type: textarea @@ -43,7 +43,7 @@ body: attributes: label: Diagnostic bundle description: >- - Drag the `cybexos-report-*.tar.gz` file from `cybex report` into this box. + Drag the `nerdos-report-*.tar.gz` file from `nerd report` into this box. Look through it first; automatic redaction cannot recognize every private value. validations: required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3c244e92..8fc0461c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Report a security vulnerability - url: https://github.com/DigitalPals/CybexOS/security/advisories/new + url: https://github.com/DigitalPals/NerdOS/security/advisories/new about: Report vulnerabilities privately. Never open a public issue or attach unredacted logs. - name: Documentation - url: https://github.com/DigitalPals/CybexOS#readme + url: https://github.com/DigitalPals/NerdOS#readme about: Installation, updates, recovery, and links to the guides in docs/. - - name: Operations and recovery guide - url: https://github.com/DigitalPals/CybexOS/blob/main/docs/operations.md - about: Commands, update logs, recovery points, and troubleshooting. + - name: Recovery guide + url: https://github.com/DigitalPals/NerdOS/blob/main/docs/recovery.md + about: Deployment recovery, rollback, persistent state, and troubleshooting. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 44302230..7078b9d9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,12 +1,12 @@ name: Feature request -description: Suggest a change to the CybexOS desktop, installer, or tools. +description: Suggest a change to the NerdOS desktop, installer, or tools. title: "[Feature]: " labels: [enhancement] body: - type: markdown attributes: value: | - CybexOS is deliberately opinionated. A strong automatic default is + NerdOS is deliberately opinionated. A strong automatic default is preferred over a new setting, and an existing surface (a keybinding, the launcher, or a row in an existing Settings page) over a new panel. Please search existing issues first. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..3c5001be --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - nerdos-isolated diff --git a/.github/actions/fedora-source-environment/action.yml b/.github/actions/fedora-source-environment/action.yml deleted file mode 100644 index 6fe22cf8..00000000 --- a/.github/actions/fedora-source-environment/action.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Fedora source-test environment -description: >- - Install the source-test dependencies, configure the fingerprint-verified - Hyprland COPR and assert the container's Fedora release. The required source - contract (tests.yml) and the non-blocking next-release preview - (fedora-preview.yml) share this definition, so their environments cannot - drift apart. Run it after actions/checkout. - -inputs: - fedora-release: - description: >- - Fedora release the container must report. Empty means the release the - installation contract targets: inventory/group_vars/all.yml - fedora_release, which must also be in release-manifest.json. - required: false - default: "" - best-effort: - description: >- - "true" only for a preview of an unqualified release: packages and COPR - builds that release does not provide yet are reported and skipped, so - every check that can still run does. The required job never sets it. - required: false - default: "false" - -outputs: - unavailable: - description: >- - What a best-effort run could not install, space-separated; empty when - the release provides everything. - value: ${{ steps.packages.outputs.unavailable }}${{ steps.copr.outputs.unavailable }} - -runs: - using: composite - steps: - - name: Install source-test dependencies - id: packages - shell: bash - env: - BEST_EFFORT: ${{ inputs.best-effort }} - run: | - skip=() unavailable='' - if [ "$BEST_EFFORT" = true ]; then skip=(--skip-unavailable --skip-broken); fi - install_packages() { - dnf install -y "${skip[@]}" "$@" - if [ "$BEST_EFFORT" = true ]; then - unavailable+=$(rpm -q --whatprovides "$@" \ - | sed -n 's/^no package provides //p' | tr '\n' ' ') || true - fi - } - install_packages \ - ansible-core \ - python3-ansible-lint \ - python3-ruff \ - ShellCheck \ - yamllint \ - curl \ - dbus-daemon \ - desktop-file-utils \ - dnf5-plugins \ - findutils \ - git \ - glib2 \ - gnupg2 \ - jq \ - luajit \ - nodejs24 \ - patch \ - procps-ng \ - python3 \ - python3-gobject-base \ - python3-websockets \ - qt6-qtdeclarative-devel \ - qt6-qtwebsockets-devel \ - qt6-qtmultimedia \ - shared-mime-info \ - util-linux \ - xdg-utils \ - ripgrep - install_packages sway - echo "unavailable=$unavailable" >>"$GITHUB_OUTPUT" - - - name: Configure the fingerprint-verified Hyprland COPR - id: copr - shell: bash - env: - BEST_EFFORT: ${{ inputs.best-effort }} - run: | - curl --fail --location --retry 3 \ - --output /tmp/hyprland-copr.gpg \ - https://download.copr.fedorainfracloud.org/results/sdegler/hyprland/pubkey.gpg - echo '581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7 /tmp/hyprland-copr.gpg' \ - | sha256sum --check - test "$(gpg --show-keys --with-colons /tmp/hyprland-copr.gpg \ - | awk -F: '$1 == "fpr" { print $10; exit }')" \ - = 64BBBF013D1CA5E4BE5B0552C043104207862204 - install -m 0644 /tmp/hyprland-copr.gpg \ - /etc/pki/rpm-gpg/CYBEXOS-sdegler-hyprland-copr.gpg - install -m 0644 roles/desktop/templates/hyprland-copr.repo.j2 \ - '/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:sdegler:hyprland.repo' - python3 .github/actions/fedora-source-environment/qualified_packages.py prepare - # The key is verified either way. Only a preview tolerates a COPR - # without a build for its release, and it says so on the run. - if ! dnf install -y quickshell || \ - ! python3 .github/actions/fedora-source-environment/qualified_packages.py verify quickshell; then - test "$BEST_EFFORT" = true - . /etc/os-release - echo "::warning title=Hyprland COPR::sdegler/hyprland has no installable quickshell for fedora-$VERSION_ID-x86_64; checks that need qs will fail." - rm -f '/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:sdegler:hyprland.repo' - echo "unavailable=quickshell " >>"$GITHUB_OUTPUT" - fi - - - name: Assert the requested Fedora release - shell: bash - env: - REQUESTED_FEDORA: ${{ inputs.fedora-release }} - run: | - targeted=$(python3 image/fedora_target.py) - . /etc/os-release - test "$ID" = fedora - test "$VERSION_ID" = "${REQUESTED_FEDORA:-$targeted}" - echo "Fedora $VERSION_ID container; the installation contract targets Fedora $targeted." diff --git a/.github/actions/fedora-source-environment/qualified_packages.py b/.github/actions/fedora-source-environment/qualified_packages.py deleted file mode 100644 index ee798792..00000000 --- a/.github/actions/fedora-source-environment/qualified_packages.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -"""Use the installed product's package-series contract in source CI.""" -import argparse -import importlib.util -from pathlib import Path - -import yaml - -ROOT = Path(__file__).resolve().parents[3] -SPEC = importlib.util.spec_from_file_location("cybexos_versionlock", ROOT / "image/library/cybexos_versionlock.py") -policy = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(policy) - - -def prepare(series, path): - _, preserved = policy.manage(path, series) - if preserved: - raise ValueError(f"Cannot enforce qualified packages: {preserved}") - - -def verify(series, names, installed=policy.installed_evr): - policy.validate_series(series) - selected = [entry for entry in series if entry["name"] in names] - unknown = set(names) - {entry["name"] for entry in selected} - if unknown: - raise ValueError("No qualified series for " + ", ".join(sorted(unknown))) - versions = {entry["name"]: installed(entry["name"]) for entry in selected} - problems = policy.outside_series(selected, versions.get, require=True) - if problems: - raise ValueError("; ".join(problems)) - return versions - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("prepare", "verify")) - parser.add_argument("packages", nargs="*", default=["quickshell"]) - parser.add_argument("--lock-file", type=Path, default=Path(policy.DEFAULT_PATH)) - args = parser.parse_args() - try: - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - series = inventory["qualified_package_series"] - if args.command == "prepare": - prepare(series, args.lock_file) - else: - for name, version in verify(series, args.packages or ["quickshell"]).items(): - print(f"Qualified runtime: {name} {version}") - except (OSError, ValueError, KeyError) as error: - parser.exit(1, f"qualified packages: {error}\n") - - -if __name__ == "__main__": - main() diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2299cf1f..08483e72 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,36 +1,14 @@ -## Summary +Describe the concrete behavior changed and why. - +Validation: -## Installation paths +- Source/module tests and candidate desktop checks: +- Real ISO/installer/updater scenarios exercised: +- Exact tested source revision and NAR identity: +- Personal configuration/state and public plugin API coverage: +- Hardware/security checks, limitations and release blockers: +- Task-created artifacts retained (path, size, reason) and cleanup: - - -- [ ] Not an installed-product change (documentation, CI, tests, or tooling only) -- [ ] ISO path covered (image packaging, `cybexos-desktop` RPM, provisioning, rootfs, repair, or reconcile as applicable) -- [ ] Source-checkout path covered (Ansible roles applied by `./install`) -- [ ] Parity regression coverage added or extended (for example `image/test_installation_parity.py`, `image/test_desktop_payload.py`, `image/test_installed_policy.py`, `image/test_user_parity.py`) -- [ ] Saved user choices and user-owned data are preserved - -## Documentation - -- [ ] User-facing documentation updated (README, `docs/`, `image/README.md`, `agent-skills/cybexos/references/`), or none is affected - -## Checks - - - -- `./tests/run`: -- `python3 -B image/check-source`: -- Manual verification (hardware, live shell, VM), or why it was not needed: - -## Dependencies and licensing - -- [ ] New downloads are pinned by version or commit and checksum ([dependency policy](https://github.com/DigitalPals/CybexOS/blob/main/docs/dependency-policy.md)), or none were added -- [ ] New third-party code, artwork, fonts, or images have recorded provenance and a redistributable license ([licensing](https://github.com/DigitalPals/CybexOS/blob/main/docs/licensing.md)), or none were added - -## Privacy - -- [ ] No secrets, tokens, personal data, or unredacted logs in the diff, screenshots, or this description +A fixture suite or Nix build is not end-to-end qualification. Product policy +belongs in shared NixOS modules; never silently discard personal configuration +to make an update pass. diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml deleted file mode 100644 index e6b1ffd4..00000000 --- a/.github/workflows/desktop-release.yml +++ /dev/null @@ -1,213 +0,0 @@ -name: ISO lifecycle qualification - -on: - workflow_dispatch: - inputs: - tag: - description: Reviewed release tag matching VERSION - required: true - type: string - baseline_iso: - description: Older supported ISO on the PXE host under /data/pxe/iso - required: true - type: string - workflow_call: - inputs: - tag: - required: true - type: string - baseline_iso: - required: true - type: string - secrets: - CYBEXOS_RPM_SIGNING_KEY: - required: false - -permissions: - contents: read - -concurrency: - group: cybexos-iso-release - cancel-in-progress: false - -jobs: - qualify: - # Debian 13 PXE host: only reviewed main/tag code, with no signing secret - # or desktop-release environment attached to this machine. - if: github.repository == 'DigitalPals/CybexOS' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) - runs-on: cybexos-iso-${{ github.run_id }} - timeout-minutes: 360 - env: - RELEASE_TAG: ${{ inputs.tag }} - BASELINE_ISO: ${{ inputs.baseline_iso }} - PYTHONDONTWRITEBYTECODE: '1' - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Prepare isolated release workspace - run: | - set -euo pipefail - work=$(mktemp -d "$RUNNER_TEMP/cybexos-release.XXXXXXXX") - printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" - test -r /data/pxe/README.md - test -n "$BASELINE_ISO" - systemctl is-active --quiet iventoy.service - image/build --preflight - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '24' - - name: Install isolated browser test driver - run: | - npm install --prefix "$RELEASE_WORK/browser" --no-audit --no-fund --ignore-scripts playwright-core@1.63.0 - printf 'NODE_PATH=%s\n' "$RELEASE_WORK/browser/node_modules" >> "$GITHUB_ENV" - - name: Build and qualify graphical installs, upgrade and recovery - run: | - image/release-gate --execute --output "$RELEASE_WORK/qualification" \ - --tag "$RELEASE_TAG" --baseline-iso "$BASELINE_ISO" - - name: Transfer qualified artifacts and reports to the hosted signing job - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cybexos-qualified-desktop - # The common root is qualification/. Do not upload VM disks, console - # logs, browser state, the builder cache, or the source checkout. - path: | - ${{ env.RELEASE_WORK }}/qualification/build/artifacts/ - ${{ env.RELEASE_WORK }}/qualification/*/qualification.json - ${{ env.RELEASE_WORK }}/qualification/release-gate.json - if-no-files-found: error - compression-level: 0 - retention-days: 2 - - name: Retain bounded qualification evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cybexos-qualification-reports - path: | - ${{ env.RELEASE_WORK }}/qualification/release-gate.json - ${{ env.RELEASE_WORK }}/qualification/*/qualification.json - retention-days: 14 - if-no-files-found: ignore - - name: Remove task staging - if: always() - run: | - if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-release.* ]]; then - rm -rf -- "$RELEASE_WORK" - fi - - sign: - name: Sign qualified desktop artifacts on a hosted Fedora container - needs: qualify - runs-on: ubuntu-latest - environment: desktop-release - timeout-minutes: 120 - env: - RELEASE_TAG: ${{ inputs.tag }} - PYTHONDONTWRITEBYTECODE: '1' - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Reserve space for raw and prepared desktop artifacts - run: | - set -euo pipefail - # This job owns its disposable GitHub-hosted VM. Remove only unused - # preinstalled SDKs; runner tools, Docker and our checkout stay intact. - sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup - available=$(df --output=avail -B1 "$RUNNER_TEMP" | tail -n 1) - if (( available < 24 * 1024 * 1024 * 1024 )); then - echo 'Signing requires at least 24 GiB free for raw and prepared artifacts.' >&2 - exit 1 - fi - work=$(mktemp -d "$RUNNER_TEMP/cybexos-signing.XXXXXXXX") - printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" - - name: Prepare Fedora 44 RPM signing tools without private key access - run: | - docker build --tag cybexos-release-signing:local - <<'DOCKERFILE' - FROM fedora:44 - RUN dnf -y --setopt=install_weak_deps=False install python3 rpm rpm-sign createrepo_c gnupg2 tar gzip && dnf clean all - DOCKERFILE - - name: Download the exact qualified build and reports - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: cybexos-qualified-desktop - path: ${{ env.RELEASE_WORK }}/qualified - - name: Sign RPM and prepare verified release assets - env: - RELEASE_SIGNING_KEY: ${{ secrets.CYBEXOS_RPM_SIGNING_KEY }} - run: | - set -euo pipefail - test -n "$RELEASE_SIGNING_KEY" - # The key enters only this ephemeral hosted container. Its value is - # passed through the environment, never arguments or shell tracing. - docker run --rm --init --interactive \ - --name "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ - --env RELEASE_SIGNING_KEY --env RELEASE_TAG --env GITHUB_REPOSITORY \ - --env PYTHONDONTWRITEBYTECODE=1 \ - --volume "$GITHUB_WORKSPACE:/source:ro" \ - --volume "$RELEASE_WORK:/release-work" \ - --workdir /source cybexos-release-signing:local bash -s <<'SIGN' - set -euo pipefail - keyhome=/release-work/signing - install -d -m 0700 "$keyhome" - trap 'gpgconf --homedir "$keyhome" --kill all; rm -rf -- "$keyhome"' EXIT - printf '%s' "$RELEASE_SIGNING_KEY" | gpg --homedir "$keyhome" --batch --import - unset RELEASE_SIGNING_KEY - fingerprint=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["fingerprint"])') - baseurl=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["baseurl"])') - image/release-repository /release-work/qualified/build/artifacts/cybexos-desktop-*.rpm \ - /release-work/qualified/build/artifacts/cybexos-offline-apps-*.rpm \ - --output /release-work/signed --public-key image/channels/CYBEXOS-desktop.asc \ - --key "$fingerprint" --gnupghome "$keyhome" --baseurl "$baseurl" \ - --previous-repository "$baseurl" \ - --packages-baseurl "https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG" - reports=() - for scenario in encrypted-us plain-us encrypted-nl plain-nl upgrade; do - reports+=(--qualification "/release-work/qualified/$scenario/qualification.json") - done - image/prepare-github-release --signed-repository /release-work/signed \ - --artifacts /release-work/qualified/build/artifacts \ - --output /release-work/publication --repository "$GITHUB_REPOSITORY" \ - --tag "$RELEASE_TAG" --fingerprint "$fingerprint" "${reports[@]}" - # Query RPM headers with Fedora's matching RPM tools; Ubuntu's older - # reader need not understand Fedora 44's package format. The publish - # job rechecks each artifact digest before using this evidence. - for channel in desktop-rpm offline-rpm; do - package=cybexos-desktop - [[ $channel != offline-rpm ]] || package=cybexos-offline-apps - python3 -B image/distribution_review.py capture --channel "$channel" \ - --artifact /release-work/publication/assets/"$package"-*.rpm \ - --output "/release-work/publication/$channel-distribution-inventory.json" - done - # Keep the actual payload inventories and compiler evidence accessible - # for channel-specific PUBLIC review. Internal qualification needs no - # distribution approval, and these records do not grant any rights. - for evidence in rpm-inventory.tsv flatpak-inventory.tsv package-manifest.json \ - source-build-provenance.json build-provenance.json; do - cp "/release-work/qualified/build/artifacts/$evidence" "/release-work/publication/assets/$evidence" - done - ( - cd /release-work/publication/assets - sha256sum rpm-inventory.tsv flatpak-inventory.tsv package-manifest.json \ - source-build-provenance.json build-provenance.json >>desktop-SHA256SUMS - ) - # The container is root; artifacts must be readable by the hosted - # runner's upload action. Secret material is outside this directory. - chmod -R a+rX /release-work/publication - SIGN - - name: Retain qualified assets for the publishing job - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cybexos-desktop-release - path: ${{ env.RELEASE_WORK }}/publication/ - if-no-files-found: error - compression-level: 0 - retention-days: 2 - - name: Remove signing material and task artifacts - if: always() - run: | - docker rm --force "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >/dev/null 2>&1 || true - docker image rm cybexos-release-signing:local >/dev/null 2>&1 || true - if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-signing.* ]]; then - sudo rm -rf -- "$RELEASE_WORK" - fi diff --git a/.github/workflows/fedora-preview.yml b/.github/workflows/fedora-preview.yml deleted file mode 100644 index f8931198..00000000 --- a/.github/workflows/fedora-preview.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Fedora preview - -# The source contract on the next, not yet qualified Fedora release. It is -# informational: the job may fail without failing the run, it is not a -# required check, and release.yml never calls this workflow, so a preview can -# neither block nor slow a release. Packages or Hyprland COPR builds the -# release does not provide yet are skipped, so every check that can still run -# does, and are then reported as a failure of the job. "Fedora 45 -# qualification" in docs/fedora-major-upgrade.md lists what a passing preview -# does not establish. -on: - pull_request: - push: - branches: [main] - schedule: - # Weekly, to notice when Fedora or the COPR catch up between pushes. - - cron: "41 4 * * 1" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: fedora-preview-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - source-contract: - name: Fedora ${{ matrix.fedora }} source contract preview - runs-on: ubuntu-latest - timeout-minutes: 30 - continue-on-error: true - strategy: - fail-fast: false - matrix: - # Unqualified releases only; tests/fedora-release.py rejects one that - # inventory or release-manifest.json already supports. - fedora: ["45"] - container: - image: fedora:${{ matrix.fedora }} - # The same headless environment as the required tests.yml job. - env: - PYTHONDONTWRITEBYTECODE: "1" - QT_QPA_PLATFORM: offscreen - QT_QUICK_BACKEND: software - - steps: - - name: Install Git for the checkout - run: dnf install -y git-core - - # actions/checkout v4.4.0, the commit tests.yml pins. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - - name: Prepare the Fedora source-test environment - id: environment - uses: ./.github/actions/fedora-source-environment - with: - fedora-release: ${{ matrix.fedora }} - best-effort: "true" - - - name: Run every source check - run: ./tests/run - - # Whatever tests/run reported, the release is not ready while anything - # the source contract installs was unavailable. - - name: Report what the release does not provide yet - if: ${{ !cancelled() && steps.environment.outputs.unavailable != '' }} - env: - FEDORA: ${{ matrix.fedora }} - UNAVAILABLE: ${{ steps.environment.outputs.unavailable }} - run: | - echo "::error title=Fedora $FEDORA preview::Unavailable: $UNAVAILABLE" - exit 1 diff --git a/.github/workflows/fedora-vm-convergence.yml b/.github/workflows/fedora-vm-convergence.yml deleted file mode 100644 index af3c1375..00000000 --- a/.github/workflows/fedora-vm-convergence.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Fedora VM convergence - -on: - workflow_dispatch: - schedule: - - cron: "17 3 * * 1" - -permissions: - contents: read - -jobs: - fedora-44-vm: - name: Fedora 44 desktop roles, two passes - runs-on: ubuntu-latest - timeout-minutes: 120 - - steps: - # actions/checkout v4.4.0, pinned for reviewable supply-chain changes. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - - name: Install VM test dependencies - run: | - sudo apt-get update - sudo apt-get install -y cloud-image-utils qemu-system-x86 qemu-utils - if test -e /dev/kvm; then sudo chmod a+rw /dev/kvm; fi - - - name: Converge the Fedora desktop twice - run: ./tests/fedora-vm-convergence diff --git a/.github/workflows/live-image.yml b/.github/workflows/live-image.yml deleted file mode 100644 index c90b4b35..00000000 --- a/.github/workflows/live-image.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Live image integration - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - workflow_call: - -permissions: - contents: read - -jobs: - welcome-and-accounts: - name: Image tooling, installer fixtures and account isolation - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - # The image tooling follows the release the installation contract targets. - - name: Read the targeted Fedora release - run: | - release=$(python3 image/fedora_target.py) - echo "FEDORA_RELEASE=$release" >>"$GITHUB_ENV" - - name: Build Fedora test environment - run: | - docker build --build-arg FEDORA_RELEASE="$FEDORA_RELEASE" \ - -f image/Containerfile.tests -t "cybexos-image-tests:$FEDORA_RELEASE" image - - name: Exercise source fixtures without building or booting an ISO - run: docker run --rm -v "$PWD:/source:ro" "cybexos-image-tests:$FEDORA_RELEASE" - - name: Validate installer configuration and launchers - run: | - docker run --rm -e FEDORA_RELEASE -v "$PWD:/source:ro" \ - -v "$PWD/image/live-rootfs/usr/share/anaconda:/usr/share/anaconda:ro" \ - "cybexos-image-tests:$FEDORA_RELEASE" bash -euc ' - ksvalidator --version "F$FEDORA_RELEASE" image/live.ks - ksvalidator -i --version "F$FEDORA_RELEASE" /usr/share/anaconda/interactive-defaults.ks - shellcheck image/build-in-guest image/rootfs/usr/bin/hyprland-quickshell \ - image/rootfs/usr/bin/cybex image/live-rootfs/usr/libexec/cybexos-live-setup - desktop-file-validate image/rootfs/usr/share/applications/*.desktop - ' diff --git a/.github/workflows/nixos.yml b/.github/workflows/nixos.yml new file mode 100644 index 00000000..33778cea --- /dev/null +++ b/.github/workflows/nixos.yml @@ -0,0 +1,93 @@ +name: NixOS qualification +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + workflow_call: + inputs: + source_ref: + type: string + required: false + default: '' +permissions: + contents: read +jobs: + source: + runs-on: [self-hosted, linux, x64, nerdos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity + - run: nix flake check --no-build + - run: nix build .#checks.x86_64-linux.lifecycle -L + - run: nix build .#checks.x86_64-linux.parity -L + - run: nix develop -c tests/run + - run: python3 -B scripts/check_leftovers.py --repo . --allowlist scripts/rename-leftovers-allowlist.txt + - run: nix develop -c tests/qml-lint + - run: nix develop -c python3 -B tests/user-plugins.py + - run: nix develop -c python3 -B tests/assistant-overlay.py + - run: nix develop -c python3 -B tests/nixos-native + product: + runs-on: [self-hosted, linux, x64, nerdos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity + - run: nix build .#checks.x86_64-linux.product -L + - name: Build matched XPS stack and evaluate NVIDIA independently + run: nix build .#checks.x86_64-linux.xps-system .#checks.x86_64-linux.nvidia-evaluation -L + vm: + runs-on: [self-hosted, linux, x64, nerdos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity --vm + - run: nix build .#checks.x86_64-linux.vm -L + image: + runs-on: [self-hosted, linux, x64, nerdos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity --vm + - name: Build and qualify the exact source image in isolated VMs + run: | + set -euo pipefail + staging=$(mktemp -d "$RUNNER_TEMP/nerdos-image-XXXXXX") + revision=$(git rev-parse HEAD) + image="/data/pxe/iso/nerdos-$revision-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT.iso" + trap 'rm -rf "$staging"; sudo rm -f "$image" "$image.sha256"' EXIT + nix build .#iso -L --out-link "$staging/result" + sudo install -d -m 0755 /data/pxe/iso + sudo install -m 0644 "$staging"/result/iso/*.iso "$image" + (cd /data/pxe/iso; sha256sum "$(basename "$image")") > "$staging/checksum" + sudo install -m 0644 "$staging/checksum" "$image.sha256" + (cd /data/pxe/iso; sha256sum -c "$image.sha256") + # The disposable runner has no PXE service. Local PXE delivery + # separately requires the refresh/service/image-list checks in AGENTS.md. + nix develop -c scripts/check-image-parity "$image" . --output "$staging/image-parity.json" + nix develop -c scripts/qualify-installations "$image" --output "$staging/installations.json" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 00000000..ca309a7e --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,75 @@ +name: Promote authenticated channel +on: + workflow_dispatch: + inputs: + tag: + required: true + description: Existing qualified immutable release tag + channel: + required: true + type: choice + options: [beta, stable] + sequence: + required: true + description: Strictly increasing channel sequence +permissions: + contents: read +concurrency: + group: nerdos-channel-promotion + cancel-in-progress: false +jobs: + promote: + environment: nerdos-channel + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b + - name: Authenticate complete immutable release before promotion + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + CHANNEL: ${{ inputs.channel }} + SEQUENCE: ${{ inputs.sequence }} + SIGNING_KEY: ${{ secrets.CHANNEL_ED25519_PRIVATE_KEY }} + KEY_ID: ${{ vars.CHANNEL_KEY_ID }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]] + [[ "$SEQUENCE" =~ ^[1-9][0-9]*$ ]] + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq .immutable)" = true + revision=$(git rev-parse HEAD) + test "$revision" = "$(git rev-parse "$TAG^{commit}")" + test "${TAG#v}" = "$(cat VERSION)" + nix flake metadata --no-update-lock-file --json > source-metadata.json + mkdir downloaded + gh release download "$TAG" --pattern 'manifest.json*' --dir downloaded + python3 release/verify-assets.py downloaded/manifest.json release/keys/trust.json --expected-version "${TAG#v}" --expected-revision "$revision" --expected-nar-hash "$(jq -r .locked.narHash source-metadata.json)" + gh auth setup-git + git clone --single-branch --branch gh-pages "https://github.com/$GITHUB_REPOSITORY.git" site + mkdir -p site/channels + previous=() + if test -f "site/channels/$CHANNEL.json"; then + previous=(--previous "site/channels/$CHANNEL.json") + fi + umask 077 + trap 'rm -f channel-key' EXIT + printf '%s\n' "$SIGNING_KEY" > channel-key + python3 release/manage.py channel downloaded/manifest.json "$CHANNEL" "$SEQUENCE" "site/channels/$CHANNEL.json" --trust release/keys/trust.json "${previous[@]}" + python3 release/manage.py sign "site/channels/$CHANNEL.json" channel-key "$KEY_ID" --trust release/keys/trust.json --purpose channel + git -C site config user.name nerdos-release + git -C site config user.email release@nerdos.invalid + git -C site add channels + git -C site commit -m "Promote $CHANNEL to $TAG" + git -C site push origin gh-pages + rm -rf site/.git + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa + with: + path: site + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ee14759..5ed8057a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,297 +1,112 @@ name: Qualified immutable release - on: - push: - tags: - # The build step applies the repository's strict SemVer validator. - - "v*" - + workflow_dispatch: + inputs: + tag: + description: Exact existing semantic tag (v1.2.3 or v1.2.3-beta.1) + required: true + qualification_url: + description: HTTPS URL of the same-revision complete physical and lifecycle qualification report + required: true permissions: contents: read - -# Never cancel a release in flight; a second run for the same tag waits. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - jobs: - prerequisites: - name: Release prerequisites + resolve: runs-on: ubuntu-latest + outputs: + revision: ${{ steps.source.outputs.revision }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - name: Require version and older ISO + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - id: source env: - RELEASE_TAG: ${{ github.ref_name }} - BASELINE_ISO: ${{ vars.CYBEXOS_BASELINE_ISO }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail - test -n "$BASELINE_ISO" - version=${RELEASE_TAG#v} - scripts/semver validate "$version" - test "$(cat VERSION)" = "$version" - - source: - name: Source contract - # The exact job that gates main: same packages, same verified COPR key. - uses: ./.github/workflows/tests.yml - - image-source: - name: Image source contract - uses: ./.github/workflows/live-image.yml - - vm: - name: Generic Fedora VM install - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - run: | - sudo apt-get update - sudo apt-get install -y cloud-image-utils qemu-system-x86 qemu-utils - if test -e /dev/kvm; then sudo chmod a+rw /dev/kvm; fi - - run: ./tests/fedora-vm-convergence - - iso: - name: ISO install, upgrade and recovery - needs: [prerequisites, source, image-source, vm] - uses: ./.github/workflows/desktop-release.yml + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]] + test "${TAG#v}" = "$(cat VERSION)" + revision=$(git rev-parse HEAD) + test "$revision" = "$(git rev-parse "$TAG^{commit}")" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + qualify: + needs: resolve + uses: ./.github/workflows/nixos.yml with: - tag: ${{ github.ref_name }} - baseline_iso: ${{ vars.CYBEXOS_BASELINE_ISO }} - secrets: inherit - + source_ref: ${{ needs.resolve.outputs.revision }} publish: - name: Review, attest, and publish release assets - environment: public-distribution - needs: [source, vm, iso] - runs-on: ubuntu-latest - timeout-minutes: 60 + needs: [resolve, qualify] + runs-on: [self-hosted, linux, x64, nerdos-isolated] + environment: nerdos-release permissions: contents: write - id-token: write - attestations: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - name: Download qualified desktop assets - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: cybexos-desktop-release - path: dist/desktop-release - - name: Install release build dependencies - run: | - sudo apt-get update - sudo apt-get install -y ansible-core jq zstd - - name: Build the versioned source asset - env: - RELEASE_TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - version=${RELEASE_TAG#v} - [[ $RELEASE_TAG == v$version ]] - scripts/semver validate "$version" - root="dist/cybexos-$version" - mkdir -p "$root" - git archive HEAD | tar -x -C "$root" - printf '%s\n' "$version" >"$root/VERSION" - jq --arg version "$version" '. + {version:$version}' \ - "$root/release-manifest.json" >"$root/release-manifest.json.new" - mv "$root/release-manifest.json.new" "$root/release-manifest.json" - source_epoch=$(git show -s --format=%ct HEAD) - tar --create --zstd --sort=name --mtime="@$source_epoch" \ - --owner=0 --group=0 --numeric-owner --file "dist/cybexos-$version.tar.zst" \ - --directory dist "cybexos-$version" - ( - cd dist - sha256sum "cybexos-$version.tar.zst" >SHA256SUMS - ) - mkdir dist/verify - tar --extract --zstd --file "dist/cybexos-$version.tar.zst" \ - --directory dist/verify --strip-components=1 - test "$(tr -d '[:space:]' >"$GITHUB_OUTPUT" - - name: Capture the actual distribution inventories - env: - RELEASE_TAG: ${{ github.ref_name }} - SBOM_PATH: ${{ steps.sbom.outputs.path }} - run: | - set -euo pipefail - version=${RELEASE_TAG#v} - mkdir -p dist/distribution-inventories - python3 -B image/distribution_review.py capture --channel source \ - --root dist/verify --artifact "dist/cybexos-$version.tar.zst" \ - --output dist/distribution-inventories/source-distribution-inventory.json - python3 -B - <<'PYTHON' - import json - from pathlib import Path - import shutil - import sys - sys.path.insert(0, 'image') - from distribution_review import revalidate_artifacts - for channel in ('desktop-rpm', 'offline-rpm'): - path = Path('dist/desktop-release') / (channel + '-distribution-inventory.json') - record = json.loads(path.read_text()) - if record.get('channel') != channel: - raise ValueError('Wrong captured RPM channel') - revalidate_artifacts(record, Path('dist/desktop-release/assets')) - shutil.copyfile(path, Path('dist/distribution-inventories') / path.name) - PYTHON - python3 -B image/distribution_review.py capture --channel iso \ - --root dist/desktop-release/assets --sbom "$SBOM_PATH" \ - --output dist/distribution-inventories/iso-distribution-inventory.json - - name: Retain exact inventories for owner and legal review - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + ref: ${{ needs.resolve.outputs.revision }} + fetch-depth: 0 + - uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b with: - name: public-distribution-inventories - path: dist/distribution-inventories/ - if-no-files-found: error - retention-days: 30 - - name: Require reviewed PUBLIC distribution for every channel + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity --vm + - name: Verify tag and exact qualification identity env: - # Owner-supplied after inspecting this exact run's artifacts. Keeping - # approvals outside the archive avoids a self-referential digest. - PUBLIC_REVIEW: ${{ vars.CYBEXOS_PUBLIC_DISTRIBUTION_REVIEW }} + TAG: ${{ inputs.tag }} + REPORT_URL: ${{ inputs.qualification_url }} run: | set -euo pipefail - python3 -B - <<'PYTHON' - import json - import os - from pathlib import Path - import sys - sys.path.insert(0, 'image') - from distribution_review import verify - review = json.loads(os.environ.get('PUBLIC_REVIEW') or Path('assets/distribution-review.json').read_text()) - for channel in ('source', 'desktop-rpm', 'offline-rpm', 'iso'): - path = Path('dist/distribution-inventories') / (channel + '-distribution-inventory.json') - verify(json.loads(path.read_text()), review) - Path('dist/distribution-review.json').write_text(json.dumps(review, indent=2) + '\n') - PYTHON - ( - cd dist - sha256sum distribution-review.json >>SHA256SUMS - cd distribution-inventories - sha256sum *.json >>../SHA256SUMS - ) - - name: Attest the exact release archive and ISO SBOM - id: attest - # actions/attest v4.2.2 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 - with: - subject-path: | - dist/cybexos-*.tar.zst - ${{ steps.sbom.outputs.path }} - dist/distribution-inventories/*.json - dist/distribution-review.json - - name: Attest the SPDX SBOM of the exact ISO - id: attest-sbom - # actions/attest v4.2.2: sbom-path makes an SPDX predicate attestation - # whose subject is the ISO digest from its published checksum file. - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 - with: - subject-checksums: ${{ steps.sbom.outputs.iso-checksum }} - sbom-path: ${{ steps.sbom.outputs.path }} - # Installed systems verify updates offline from this bundle, so checking - # for and applying a release needs no GitHub login or API token. - - name: Package the provenance bundles for offline verification + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]] + test "${TAG#v}" = "$(cat VERSION)" + test "$(git rev-parse HEAD)" = "$(git rev-parse "$TAG^{commit}")" + test -z "$(git status --porcelain)" + mkdir -p release-output + curl --proto '=https' --fail --location "$REPORT_URL" -o release-output/qualification.json + nix flake metadata --json > source-metadata.json + python3 release/manage.py gate release-output/qualification.json "$(git rev-parse HEAD)" "$(jq -r .locked.narHash source-metadata.json)" + nix flake check -L + staging=$(mktemp -d "$RUNNER_TEMP/nerdos-release-image-XXXXXX") + revision=$(git rev-parse HEAD) + image="/data/pxe/iso/nerdos-$revision-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT.iso" + trap 'rm -rf "$staging"; sudo rm -f "$image" "$image.sha256"' EXIT + nix build .#iso -L --out-link "$staging/result" + sudo install -d -m 0755 /data/pxe/iso + sudo install -m 0644 "$staging"/result/iso/*.iso "$image" + (cd /data/pxe/iso; sha256sum "$(basename "$image")") > "$staging/checksum" + sudo install -m 0644 "$staging/checksum" "$image.sha256" + (cd /data/pxe/iso; sha256sum -c "$image.sha256") + nix develop -c scripts/check-image-parity "$image" . --output release-output/image-parity.json + nix develop -c scripts/qualify-installations "$image" --output release-output/installation-evidence.json + cp "$image" "release-output/nerdos-${TAG#v}-x86_64-linux.iso" + - name: Prepare immutable assets and manifest env: - BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} - SBOM_BUNDLE_PATH: ${{ steps.attest-sbom.outputs.bundle-path }} - SBOM_PATH: ${{ steps.sbom.outputs.path }} - ISO_CHECKSUM: ${{ steps.sbom.outputs.iso-checksum }} - RELEASE_TAG: ${{ github.ref_name }} + TAG: ${{ inputs.tag }} + SIGNING_KEY: ${{ secrets.RELEASE_ED25519_PRIVATE_KEY }} + KEY_ID: ${{ vars.RELEASE_KEY_ID }} run: | set -euo pipefail - version=${RELEASE_TAG#v} - bundle="dist/cybexos-$version.tar.zst.sigstore.jsonl" - jq -c . "$BUNDLE_PATH" >"$bundle" - test -s "$bundle" - # The same pinned, offline check the updater runs, without a token. - # The archive's provenance also covers the published SBOM file. - for subject in "dist/cybexos-$version.tar.zst" "$SBOM_PATH"; do - env -u GH_TOKEN -u GITHUB_TOKEN gh attestation verify \ - "$subject" --bundle "$bundle" \ - --repo "$GITHUB_REPOSITORY" \ - --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ - --source-ref "refs/tags/$RELEASE_TAG" --deny-self-hosted-runners - done - # The SBOM attestation's subject is the ISO, published only as parts, - # so check its predicate type and subject digest structurally here. - iso_name=$(basename "$ISO_CHECKSUM" .sha256) - sbom_bundle="$iso_name.sbom.sigstore.jsonl" - jq -c . "$SBOM_BUNDLE_PATH" >"dist/$sbom_bundle" - read -r iso_digest _ <"$ISO_CHECKSUM" - jq -e --arg digest "$iso_digest" '.dsseEnvelope.payload | @base64d | fromjson - | .predicateType == "https://spdx.dev/Document/v2.3" - and any(.subject[]; .digest.sha256 == $digest)' "dist/$sbom_bundle" >/dev/null - ( - cd dist - sha256sum "cybexos-$version.tar.zst.sigstore.jsonl" "$sbom_bundle" >>SHA256SUMS - ) - - name: Publish the completed release + trap 'rm -f signing-key' EXIT + umask 077 + printf '%s\n' "$SIGNING_KEY" > signing-key + git archive --format=tar.gz --prefix=nerdos/ HEAD > release-output/nerdos-source.tar.gz + cp release/notes.md release-output/release-notes.md + (cd release-output; sha256sum ./*.iso nerdos-source.tar.gz > SHA256SUMS) + python3 release/manage.py manifest release-output "${TAG#v}" "$(git rev-parse HEAD)" "$(jq -r .locked.narHash source-metadata.json)" release/notes.md + python3 release/manage.py sign release-output/manifest.json signing-key "$KEY_ID" --trust release/keys/trust.json --purpose release + python3 release/verify-assets.py release-output/manifest.json release/keys/trust.json --local + - name: Publish all assets together and verify public delivery env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} + IMMUTABILITY_CHECK_TOKEN: ${{ secrets.IMMUTABILITY_CHECK_TOKEN }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail - version=${RELEASE_TAG#v} - prerelease=() - [[ $version != *-* ]] || prerelease+=(--prerelease) - gh release create "$RELEASE_TAG" --verify-tag --draft \ - --generate-notes --title "CybexOS $version" "${prerelease[@]}" - gh release upload "$RELEASE_TAG" \ - "dist/cybexos-$version.tar.zst" \ - "dist/cybexos-$version.tar.zst.sigstore.jsonl" dist/SHA256SUMS \ - dist/*.iso.sbom.sigstore.jsonl dist/desktop-release/assets/* \ - dist/distribution-inventories/*.json dist/distribution-review.json - gh release edit "$RELEASE_TAG" --draft=false - for _attempt in $(seq 1 12); do - if test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq .immutable)" = true; then - exit 0 - fi - sleep 5 - done - echo "GitHub did not make $RELEASE_TAG immutable after publication" >&2 - exit 1 - - pages: - name: Publish signed desktop repository metadata - needs: publish - # Prerelease assets are downloadable but must never advance stable clients. - if: ${{ !contains(github.ref_name, '-') }} - runs-on: ubuntu-latest - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: cybexos-desktop-release - path: desktop-release - - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b - - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa - with: - path: desktop-release/pages - - name: Deploy verified metadata after RPM assets exist - id: deploy - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e + release/require-immutable-releases + gh release create "$TAG" --draft --verify-tag --title "NerdOS ${TAG#v}" --notes-file release/notes.md + gh release upload "$TAG" release-output/* + release/require-immutable-releases + gh release edit "$TAG" --draft=false + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq .immutable)" = true + python3 release/verify-assets.py release-output/manifest.json release/keys/trust.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index ff032501..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Repository checks - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - # release.yml gates a release on this same job. - workflow_call: - -permissions: - contents: read - -# A newer push to the same branch or pull request supersedes a running check; -# a release's run is never cancelled. The prefix keeps this group apart from -# the caller's own when release.yml calls this workflow, where github.workflow -# names the caller. -concurrency: - group: source-checks-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} - -jobs: - # Branch protection requires this check by name and release.yml gates on it. - # Its container and name follow inventory fedora_release only when the - # targeted release changes (tests/fedora-release.py checks they agree); - # the next release is previewed, non-blocking, in fedora-preview.yml. - fedora-44: - name: Fedora 44 source contract - runs-on: ubuntu-latest - timeout-minutes: 20 - container: - image: fedora:44 - env: - PYTHONDONTWRITEBYTECODE: "1" - QT_QPA_PLATFORM: offscreen - QT_QUICK_BACKEND: software - - steps: - # actions/checkout needs Git in the container for a real work tree; the - # shared environment step installs everything else. - - name: Install Git for the checkout - run: dnf install -y git-core - - # actions/checkout v4.4.0; pin the reviewed action code, not its moving - # major tag. Dependabot/manual review can advance this commit explicitly. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - # Dependencies, the fingerprint-verified Hyprland COPR, and an assertion - # that this container is the release inventory/group_vars/all.yml - # targets. Shared with the non-blocking fedora-preview.yml job. - - name: Prepare the Fedora source-test environment - uses: ./.github/actions/fedora-source-environment - - - name: Run every required source check - run: ./tests/run diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 952e5ef5..3d05bcac 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -45,7 +45,7 @@ jobs: - name: Verify the production server and bundled wallpaper shell: bash run: | - log_path="$RUNNER_TEMP/cybexos-web.log" + log_path="$RUNNER_TEMP/nerdos-web.log" HOST=127.0.0.1 PORT=5174 setsid node dist/standalone/server.js >"$log_path" 2>&1 & service_pid=$! cleanup() { @@ -55,8 +55,8 @@ jobs: } trap cleanup EXIT for attempt in {1..40}; do - if curl --fail --silent --max-time 2 http://127.0.0.1:5174/ >"$RUNNER_TEMP/cybexos-web.html"; then - grep -q 'CybexOS' "$RUNNER_TEMP/cybexos-web.html" + if curl --fail --silent --max-time 2 http://127.0.0.1:5174/ >"$RUNNER_TEMP/nerdos-web.html"; then + grep -q 'NerdOS' "$RUNNER_TEMP/nerdos-web.html" curl --fail --silent --show-error --max-time 10 \ http://127.0.0.1:5174/wallpapers/moon-mountains-1280.png >/dev/null exit 0 diff --git a/.gitignore b/.gitignore index 7a60b85e..7b235dde 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__/ *.pyc + +# Rust build output of the installer screen. +/installer/kiosk/target/ diff --git a/.yamllint b/.yamllint deleted file mode 100644 index be9be376..00000000 --- a/.yamllint +++ /dev/null @@ -1,15 +0,0 @@ -extends: relaxed - -rules: - document-start: disable - line-length: disable - truthy: - allowed-values: ["true", "false"] - check-keys: false - trailing-spaces: enable - key-duplicates: enable - comments: - min-spaces-from-content: 1 - octal-values: - forbid-implicit-octal: true - forbid-explicit-octal: true diff --git a/AGENTS.md b/AGENTS.md index 811d9a75..de8ef539 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,32 +1,40 @@ # Repository agent instructions -## Installation parity: ISO is authoritative +## NixOS installation and lifecycle contract -- The current ISO installation defines the CybexOS product. Keep `./install`, - `./bootstrap`, and ISO installations equivalent for the same release, - hardware, and explicit user choices. Resolve differences by bringing the - checkout path into line with the ISO, not by changing the ISO to match an - older checkout default. -- Treat applications, desktop and personal defaults, authentication, enabled - services, firewall policy, hardware support, and recovery as one shared - installation contract. Reuse the shared policy, task files, templates, and - package selections; do not add independent installer defaults. -- Preserve explicit saved choices and user-owned data during installs, - reconfiguration, and updates. Matching a fresh installation never authorizes - repartitioning an existing Fedora system or resetting personal settings. - Automatic login must retain the ISO's verified-encryption requirement. -- Every change to the installed product must cover both installation paths in - the same change, with regression coverage that compares their outcomes. - Run the source and image parity checks before handoff. A passing fixture - suite must not be reported as a completed end-to-end installation test. -- Release comparisons must use artifacts from the same source revision. - When preparing a release, rebuild and qualify the ISO/RPM if its installed - payload changed; an older ISO or an older qualification report does not - validate the new release. +- NixOS is the sole supported operating system. Target fresh installations. + Do not add installation migration, compatibility shims, or alternate + provisioning/update paths. +- `nix/modules` defines the shared installation contract for the ISO, installed + system, configuration tools and lifecycle. `install` and `bootstrap` invoke + the same fresh installer, available only in the live environment. +- Vendor files are immutable. Personal modules and machine-owned locks belong + in `/etc/nixos`; secrets/state stay outside the Nix store. Never overwrite + personal packages, services, VPNs, widgets, preferences, data or skills to make + an update pass. Never implicitly refresh unrelated personal inputs. +- Nixpkgs follows the supported stable branch independently; NerdOS follows + the machine Nixpkgs and is pinned to an authenticated exact release revision + and NAR identity. Major NixOS upgrades are deliberate; do not change + `system.stateVersion` during routine updates. +- Preserve password fallback, password-only first login, encryption-gated + automatic login, firewall protections and supported hardware. Hardware stack + failure blocks qualification; it must not silently remove support or disable + Secure Boot. +- Validate changes through the actual staged updater. Source/fixture tests and + a Nix build are not end-to-end installation, runtime or physical hardware + qualification. Test ISO/source parity and use the exact same source identity + for release artifacts and qualification. Record what was actually exercised. +- Do not modify the running workstation, partition existing systems, publish + releases, or reboot the host without explicit authorization. Use isolated + build/test environments and preserve unrelated work. ## ISO testing location - Always place completed ISOs for testing in `/data/pxe/iso`. +- The PXE host is **thebeast (`10.10.0.7`)**, reached with + `ssh john@10.10.0.7`. Its `/data/pxe/README.md` documents the service. + A development machine's directory with the same path is not the served tree; + copy the verified final ISO/checksum to the Beast before refreshing it. - Build in a unique task-specific staging directory, then copy the completed ISO and its SHA-256 checksum into `/data/pxe/iso`. Preserve existing images unless their replacement or removal is explicitly requested. @@ -35,7 +43,8 @@ incomplete ISOs outside the served ISO tree. Use ASCII filenames without spaces and permissions that allow iVentoy to read the files. - Prefer **ISO Management → Refresh** in the PXE host's iVentoy web UI - (port 26000; the host is documented in `/data/pxe/README.md`). + (`http://10.10.0.7:26000`; deployment details are in the Beast's + `/data/pxe/README.md`). On the PXE host, the equivalent requests below were verified with iVentoy 1.0.41. They use the installed UI's internal API, so recheck `http://127.0.0.1:26000/vtoy_image.html` after an iVentoy upgrade: @@ -70,7 +79,7 @@ - Clean up disposable artifacts created during your task before final handoff, including failed/retried image builds, test ISOs, VM disks, extracted root filesystems, temporary logs, and screenshots. This includes outputs outside - the checkout, especially `~/.local/share/cybexos/images/`. + the checkout, especially `~/.local/share/nerdos/images/`. - Use a unique task-specific output directory and track what you create. Arrange cleanup with traps or `finally` blocks where practical so failures and interruptions also clean up. Stop task-owned VMs/processes and unmount @@ -83,7 +92,7 @@ - Delete only artifacts you created or have verified are disposable and within the user's authorized cleanup scope. Do not infer that another task's files are stale from their name or age alone. Never blanket-delete - `~/.local/share/cybexos`: it can also contain active releases, runtime + `~/.local/share/nerdos`: it can also contain active releases, runtime configuration, user themes/plugins, and persistent data. - Verify cleanup at the end of the task by checking the output directories and their disk usage. Do not leave orphaned build/test processes or mounts. @@ -106,3 +115,10 @@ - A live test is not complete until `quickshell.service` is active, its `MainPID` is the sole remaining `qs` PID, and the current invocation's journal has been checked for QML errors. +- Scope process comparisons to the authenticated user's UID; another user's + independent desktop session is not an unmanaged menubar. Updater health + reports extra developer processes without terminating them. The live-test + guard may terminate only the confirmed task/developer instances above. +- Count Nixpkgs' Qt-wrapped Quickshell (`.quickshell-wrapped`, with Linux + `comm` truncated to `.quickshell-wra`) in these comparisons as well as + `pgrep -x qs`; the wrapper is the same upstream executable, not a second shell. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea6523c0..47dbb444 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,159 +1,24 @@ -# Contributing to CybexOS - -CybexOS is an opinionated Fedora 44 desktop built on Hyprland and Quickshell. -It has two installation paths: the bootable ISO (the `cybexos-desktop` RPM and -the image tooling under `image/`) and the source checkout (`./install`, which -applies the Ansible roles in `site.yml`). The ISO defines the product. - -Participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). -Contributions are accepted under the repository's [MIT License](LICENSE). -Report security problems privately as described in [SECURITY.md](SECURITY.md). - -## Before you start - -- Search existing issues. For a new feature or a larger change, open a feature - request first so the approach can be agreed before you write it. -- CybexOS prefers a strong automatic default over a new setting, and an - existing surface (a keybinding, the launcher, a row in an existing Settings - page) over a new panel or menubar module. -- Do not add third-party code, artwork, fonts, wallpapers, or logos unless - their license permits redistribution and you record their provenance. See - [licensing and asset provenance](docs/licensing.md) and the - [redistribution audit](docs/licensing-audit.md). - -## Development setup - -Develop on Fedora 44 x86_64: - -```bash -sudo dnf install -y git -git clone https://github.com/DigitalPals/CybexOS.git -cd CybexOS -``` - -The CI workflow, [`.github/workflows/tests.yml`](.github/workflows/tests.yml), -prepares the Fedora packages the source gate needs (Ansible, ansible-lint, -ShellCheck, yamllint, Ruff, Node.js 24, Qt 6 declarative tools, and others) -and configures the fingerprint-verified Hyprland COPR. The image fixtures' -dependencies are listed in [image/README.md](image/README.md#source-checks-no-iso-or-vm). - -On a CybexOS machine, `cybex dev enable "$PWD"` runs live Quickshell and static -Hyprland modules from your validated checkout without modifying it; -`cybex dev disable` returns to the verified runtime. Keep exactly one live -menubar: test through `quickshell.service` and never leave a source-tree `qs` -process running beside it. See [Quickshell development notes](docs/quickshell-notes.md). - -## Source gates - -Run both gates before you open a pull request. Neither builds an ISO or boots -a VM. - -```bash -./tests/run # strict source gate -python3 -B image/check-source # image tooling and installer fixtures -``` - -`./tests/run --list` shows the stages and `--only STAGE` runs one; see -[the strict source gate](docs/operations.md#the-strict-source-gate). If you do -not develop on Fedora 44, run the image fixtures in the Fedora 44 test -container: - -```bash -docker build -f image/Containerfile.tests -t cybexos-image-tests:44 image -docker run --rm -v "$PWD:/source:ro" cybexos-image-tests:44 -``` - -Pull requests must pass the required checks `Fedora 44 source contract` and -`Image tooling, installer fixtures and account isolation`. Fixture suites check -the installation contract; they are not evidence that a real ISO or checkout -installation was performed, so do not describe them that way. Slower gates, -such as `./tests/fedora-vm-convergence` and ISO qualification, are described in -[the release process](docs/releasing.md). - -## Installation parity - -The rules are summarized from [AGENTS.md](AGENTS.md) and -[installation parity](docs/installation-parity.md): - -- The current ISO installation is authoritative. `./install`, `./bootstrap`, - and the ISO must produce the same installed product for the same release, - hardware, and explicit choices. Resolve a difference by bringing the - checkout path in line with the ISO. -- Every change to the installed product (applications, desktop and personal - defaults, authentication, services, firewall policy, hardware support, - recovery) covers both paths in the same change, reusing the shared policy, - task files, templates, and package selections. Do not add installer-specific - defaults. -- Add or extend regression coverage that compares both paths' outcomes, such - as `image/test_installation_parity.py`, `image/test_desktop_payload.py`, - `image/test_installed_policy.py`, and `image/test_user_parity.py`. -- Preserve explicit saved choices and user-owned data during installs, - reconfiguration, and updates. Parity never justifies repartitioning an - existing system or resetting personal settings. Automatic login keeps the - verified-encryption requirement. -- Release comparisons use artifacts built from the same source revision. - -## Dependencies and pinning - -Follow the [dependency and pinning policy](docs/dependency-policy.md). Pins live -in `inventory/group_vars/all.yml`: GitHub release applications and fonts need an -exact tag and SHA-256, source builds a full commit, container images a manifest -digest, and vendor repository keys a checksum plus full fingerprint. Changing a -pin is a review decision; explain the upstream reason in the pull request. -A new moving input must document why currency matters more than reproducibility. - -## Code style - -- Match the surrounding code. Comments explain why, not what. -- Quickshell: theme values come from `Common/Theme.qml`; register new QML types - in their directory's `qmldir`; do not run `qmlformat`. Pure logic belongs in a - Qt-free `.js` helper with a Node test in `tests/quickshell/*.test.cjs`. -- Shell scripts pass ShellCheck. Python passes Ruff (`pyproject.toml`) and new - helpers get tests registered in `tests/run`. YAML passes yamllint - (`.yamllint`) and Ansible passes ansible-lint. -- New user helper scripts go in `assets/scripts/` and are listed in - `assets/desktop-contract.json`, which installs them on both paths. New `cybex` - subcommands go in both dispatchers: `roles/dotfiles/templates/cybex.j2` and - `image/rootfs/usr/bin/cybex`. -- Privileged actions use the existing Polkit-authorized systemd units or - `pkexec`. Never add passwordless sudo or Polkit rules. Treat external input - (URLs, files, Git repositories, themes) as data, never as code. -- User-facing copy is short, in sentence case, without exclamation marks. - -## Commits and pull requests - -- Branch from `main` and keep each pull request focused on one change. -- Write commit subjects in the imperative, describing the outcome, for example - "Keep mDNS open so network printers and scanners are found". Use the body to - explain why. -- Update the documentation that describes the behavior you changed: the - README, `docs/`, `image/README.md`, and - `agent-skills/cybexos/references/` for commands the agent skill should know. -- Fill in the pull request template, including both gate results. -- Never commit secrets, tokens, personal data, build artifacts, or unredacted - logs. Remove disposable ISOs, VM disks, and logs you created while testing. - -## Reporting bugs - -Run `cybex report` on the affected machine. It writes a redacted diagnostic -bundle, `cybexos-report-.tar.gz`, locally and opens a prefilled -GitHub bug report. Review the bundle before attaching it; the redaction is a -safeguard, not a guarantee. Never attach unredacted logs, and never report a -security problem in a public issue: follow [SECURITY.md](SECURITY.md). - -If you cannot run `cybex report`, use the **Bug report** form and include the -output of `cybex version`, the installation path (ISO or source checkout), and -steps to reproduce. - -## Where the documentation lives - -- [README.md](README.md): overview, installation, updates, and common commands -- [image/README.md](image/README.md): the ISO, its installer, first boot, and repair -- [docs/operations.md](docs/operations.md): commands, Ansible tags, updates, and recovery -- [docs/installation-parity.md](docs/installation-parity.md): the shared installation contract -- [docs/releasing.md](docs/releasing.md) and [docs/iso-releases.md](docs/iso-releases.md): release process -- [docs/architecture/ownership.md](docs/architecture/ownership.md): runtime and user-owned paths -- [docs/quickshell-notes.md](docs/quickshell-notes.md): working on the shell -- [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md): keybinding groups -- [docs/licensing.md](docs/licensing.md): licensing and asset provenance -- [AGENTS.md](AGENTS.md): rules for coding agents, including ISO testing and artifact cleanup +# Contributing + +Read [AGENTS.md](AGENTS.md). NixOS is the only operating-system implementation. +Develop vendor behavior in `nix/modules`, `lifecycle`, `installer`, `desktop`, +and `hardware`. Keep personal declarations out of vendor code. + +Use `nix develop` and `tests/run`, then `nix flake check --no-build` and relevant +Nix builds/VM tests. Source fixtures must be reported separately from actual +installation and update tests. `scripts/qualify-installations` owns disposable +VMs/disks and exercises the actual installer and updater, with evidence tied to +source content. Hardware claims require representative physical machines. + +Every product change covers the shared module and both installation entry points. +Keep plugin API 1 and the supported Omarchy adapter functional. Runtime preferences +must preserve unknown fields and explicit choices. Never fix an update by removing +personal modules, resetting settings or silently disabling an extension. + +Use isolated environments. Do not deploy a development shell onto the running +workstation without authorization. Any live Quickshell test must use +`tests/lib/quickshell-live`, including its final PID/service/journal checks. + +Release workflows are protected operations and require complete same-revision +qualification, operator-owned signing keys and immutable GitHub Releases. See +[release operations](docs/releasing.md). diff --git a/LICENSE b/LICENSE index 389ed546..0506beb2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 DigitalPals and CybexOS contributors +Copyright (c) 2026 DigitalPals and NerdOS contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 8f322c60..30bca2fc 100644 --- a/README.md +++ b/README.md @@ -1,390 +1,52 @@ -# CybexOS - -CybexOS stands for **Cybex Opinionated System**. It is an opinionated Fedora -Linux desktop built around Hyprland and Quickshell. The primary installation -path is the bootable CybexOS ISO; a source-checkout installer remains available -for development and existing checkout deployments. The core configuration is -hardware-neutral. A separate, precisely gated role preserves extra support -for the 2026 Dell XPS 14 and 16. - -The current release target is Fedora 44 on x86_64. Fedora remains responsible -for the kernel, drivers, SELinux, and base operating system. - -The application command is `cybex`; paths, services, and packages use the -`cybexos` name. The project was previously called `fedora-config`. Running -`./install` from a CybexOS checkout moves such an installation to the new names; -see [the operations guide](docs/operations.md#migrating-from-fedora-config). - -## Try the browser desktop - -The [browser experience](web/README.md) recreates the CybexOS menubar, -workspaces, launcher, windows, terminal, files, notes, and appearance settings. -It runs as a standalone web app, with Docker Compose serving it on port 5174. -Notes and preferences stay in the visitor's browser; the demo does not run -Linux or execute commands on the host. - -## What it installs - -- Hyprland, a custom Quickshell menubar, SDDM, portals, notifications, and - desktop services -- a portable Fedora package, Flatpak, shell, font, firewall, and recovery - baseline -- encrypted personal-file backups with restic to an external drive or SSH - server, set up in Settings → Backup & recovery or with `cybex backup` -- Omawrite for text, NFO, and Markdown files, with default file associations - when personal dotfiles integration is enabled -- developer/Android tools, Steam, Docker, Podman/Distrobox, Tailscale, - connected-service widgets, proprietary applications, and source-built tools - enabled by default -- automatically detected XPS 2026 speaker, camera, haptic, fingerprint, - backlight, firmware, and power support -- persistent installer configuration and lifecycle tools for source-checkout - deployments, plus a verified source release updater -- one release-scoped CybexOS skill discoverable by compatible coding - agents for safe installed-system diagnosis and customization -- a user-selectable default AI coding agent with terminal, launcher, and - keyboard entry points -- a `Super+Space` command palette for applications, actions, clipboard - history and emoji, and installable web apps - -Source-checkout installs have no desktop-preset selection: every installation -gets the same core Hyprland/Quickshell desktop. That installer asks about the -target machine, security decisions, personal dotfiles, and application opt-outs. - -On a source-checkout installation, apply only Omawrite and the managed file -associations with the saved configuration: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags omawrite,mime-defaults -``` - -## Install from the ISO - -For a normal installation, boot the CybexOS ISO and follow the on-screen -installer. It installs the desktop RPM and offline application set, then -configures the target system without requiring a Git checkout or a network -connection for the desktop payload. The image's [installation guide](image/README.md) -describes disk requirements, encryption, first boot and recovery. Use the -release instructions in [docs/releasing.md](docs/releasing.md) for current -artifact availability and checksums. - -The installed desktop is delivered by `cybexos-desktop`; its packaged files -live under `/usr/share/cybexos`. The first-login account is configured by the -image installer, and subsequent package upgrades use the system update path. - -## Install from a source checkout - -This supported path is intended for development and existing checkout-based -deployments. For a regular new installation, use the ISO above. - -The commands below install from this repository with Ansible; they do not -install or update the ISO's `cybexos-desktop` RPM. Repository-based options and -the saved installer configuration described here apply to this checkout path. - -Start with Fedora 44 and a user that can run `sudo`: - -```bash -sudo dnf install -y git -git clone https://github.com/DigitalPals/CybexOS.git -cd CybexOS -./install -``` - -No inventory or configuration file needs to be edited first. The installer -detects the current desktop user, home directory, hostname, timezone, locale, -and keyboard settings and offers them as defaults. The ISO defines the shared -fresh-install policy: all application groups and personal dotfiles are enabled, -sudo and local Polkit require authentication, and Docker requires sudo. -Automatic login defaults on only when the complete root filesystem is verified -as encrypted. These defaults also apply in non-interactive installs; interactive -setup allows explicit opt-outs. Existing saved choices remain authoritative. -Fastfetch is a required baseline package. Passwordless sudo, local Polkit and -Docker access remain explicit opt-ins; the `docker` group grants root-equivalent -control of the machine. See [installation parity](docs/installation-parity.md) -for the shared contract and release checks. - -On an encrypted single-user installation, SDDM can open the desktop after the -LUKS unlock and unlock GNOME Keyring with the briefly cached boot password. -Automatic login is attempted once per boot; logout, a desktop crash, or a -login-manager restart requires password login. An unavailable or different -boot password leaves the keyring protected and allows manual unlocking. -Unencrypted systems use password login. See the [login design and validation -status](docs/gdm-replacement-research.md). Existing installations change login -managers on the next boot, preserving their previous configuration for uninstall. - -Answers are saved in `/etc/cybexos/config.yml`, outside versioned release -trees, and are reused by later installs and updates. Existing explicit -application opt-outs are preserved; missing choices receive the full defaults. Run -`cybex configure` to ask the questions again. `./bootstrap` remains a -compatibility alias for `./install`. The first successful install snapshots -the runtime source under `~/.local/share/cybexos/releases/`, so the -cloned checkout can then be moved or removed. - -That active release also owns the canonical `cybexos` agent skill. -Installation always links it at `~/.agents/skills/cybexos`, -`~/.claude/skills/cybexos`, and `~/.codex/skills/cybexos`, whether -or not a corresponding agent is currently installed. Invoke it explicitly as -`$cybexos` in Codex or `/cybexos` in Claude Code; its focused -description also supports automatic selection. - -Before any role adopts configuration, the installer creates a one-time backup -under `~/.local/state/cybexos/backups/initial/`. Existing Hyprland and -Quickshell trees are always preserved; personal application files are added -when the optional dotfiles integration is selected. Uninstall restores those -pre-existing files. Managed Fish, Kitty, Git, and SSH settings use -fragments/includes where those applications support them. Bundled wallpapers -are installed into `~/Pictures/Wallpapers`, and a mountain wallpaper is selected when no wallpaper is configured. Existing selections and -custom folders are preserved. Change the image in CybexOS Settings → Wallpaper. -No avatar is imposed. - -To install or refresh only the wallpapers using the saved configuration: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags wallpapers -``` - -A lone tiled window on an external monitor is centered at 70% of the display's -width, adapting to resolution, scaling, and rotation. Laptop panels and -workspaces with multiple tiled windows use the normal small edge gaps. - -`Super+K` shows every keyboard shortcut, read live from Hyprland, including -described bindings added in `~/.config/cybexos/hypr/user.lua`. See -[keyboard shortcuts](docs/keyboard-shortcuts.md). - -Around the windows themselves: - -- **Windows:** `Super+Tab` opens an overview with live thumbnails of every - workspace; type to filter, or drag a window to another workspace. A - scratchpad (`Super+-`), window groups (`Super+G`) and screen zoom - (`Super+Z`) are built in, and a three-finger swipe changes workspace. -- **Capture:** region or window screenshots, scrolling screenshots, screen - recording with an optional webcam bubble, text and QR code copying, and a - colour picker. -- **Appearance:** Settings → Appearance starts with **Theme** (CybexOS, Nord, - Gruvbox, Tokyo Night, Catppuccin, Rosé Pine, Everforest, Kanagawa) and can - save the current look as a theme; `cybex theme` installs, exports and - applies themes as plain data. The terminal, GTK applications and the lock - screen follow the shell's colours and text size. See - [themes](docs/shell-appearance.md#themes). -- **Dock and extras:** floating workspaces show a dock automatically - (Settings → Bar → Dock can show it in all layouts), alongside the - busiest processes in the Control Center's Overview, and a game mode - (`Super+Shift+G`) that turns effects, gaps and the idle lock off. See - [window overview, dock and top processes](docs/overview-and-dock.md). -- **Language:** the shell follows the system language set in Settings → - Region & formats and ships English and Dutch. See - [shell translations](docs/shell-translations.md). - -Hyprland and Quickshell stay on the series each release was qualified on: -patch updates arrive normally, and the next minor or major release waits for a -CybexOS release that has reviewed it. See the -[dependency policy](docs/dependency-policy.md). - -Desktop runtime and user customization have a strict boundary. Verified -releases reconcile `~/.local/share/cybexos/runtime`, while shell -settings, Hyprland overrides, themes, and plugins live in user-owned roots -that updates never prune. An upgrade from the legacy layout emits a migration -report and preserves customized QML/Lua without trying to translate it. See -[the ownership architecture](docs/architecture/ownership.md) for the complete -path and migration contract. - -Personal bar widgets use a versioned API and live outside the distro runtime. -Codex/Claude can create a package in -`~/.local/share/cybexos/plugins//` and enable it with -`cybex plugin enable `. Its preferences and data survive updates; -no edits to built-in shell modules are needed. See the -[widget contract and commands](docs/architecture/user-widgets.md). The -ownership guide also identifies remaining application-configuration gaps. -The Omarchy compatibility adapter supports widgets, shared services, panels, -overlays, menus, and replacement bars, with representative unchanged plugins tested. See [installation and limits](docs/omarchy-plugin-compatibility.md). -The built-in Model Usage widget is a vendored copy of the -[Model Usage](https://github.com/DigitalPals/omarchy-modelusage) Omarchy -plugin, hosted through the same adapter; see -[its README](roles/desktop/files/quickshell/ModelUsage/README.md). - -Each pre-existing `cybexos` skill slot is backed up independently before -first adoption. Updates retarget all three paths through the atomic active -release link, and uninstall restores the exact original file, directory, or -symlink without changing neighboring skills. - -## Default AI agent - -Developer tooling installs pinned Claude Code, OpenCode, and Codex CLI -versions. CybexOS does not silently prefer one provider: the first -interactive invocation asks which installed agent to use and stores that -per-user choice at `~/.config/cybexos/defaults/agent`. - -```bash -cybex agent # launch the default in this directory -cybex agent --pick # choose, remember, and launch another -cybex agent set opencode # change the default without launching -cybex agent list # show supported and installed agents -cybex agent prompt "review this change" -``` - -`Super+Ctrl+Shift+A` opens the default agent in a Kitty window rooted at -`~/Code`. The Quickshell Actions tab can launch or choose it as well. Managed -Fish configuration provides the short alias `a`. - -The dispatcher passes no automatic-approval or permission-bypass flags and -never stores credentials. Authentication, model selection, permissions, and -provider-specific settings remain owned by each agent. The preference survives -updates and uninstall because it is user data rather than Ansible policy. - -## Launcher and web apps - -`Super+Space` opens the launcher. Typing searches applications and also lists -up to three matching actions, such as **Take a scrolling screenshot** or -**Toggle game mode**. The Actions tab lists every action, grouped as Install, -Remove, Set up, Toggle, Update, Capture and System. `Super+Ctrl+V` opens the -clipboard history: `Enter` pastes the selected entry, `Shift+Enter` copies it, -and `Ctrl+P` pins it so it survives when the history is pruned or cleared. - -Web apps are websites in their own window with a launcher entry. Brave Origin -opens them in app mode when it is installed; otherwise Firefox opens them. -Every account starts with Slack, WhatsApp, YouTube, Google Photos and X on -their usual shortcuts; a removed one is not added back by later updates. - -```bash -cybex webapp install --url https://app.example.com/ # fetches the site icon -cybex webapp list -cybex webapp remove example -cybex webapp launch slack -``` - -The launcher's **Install web app…** and **Remove web app…** actions do the -same. Only http and https addresses without a user name or password are -accepted. See [keyboard shortcuts](docs/keyboard-shortcuts.md) and the -[launcher notes](docs/quickshell-notes.md#launcher-providers-and-actions). - -## Update - -On ISO installations, `cybex update` updates Fedora packages, Flatpaks, and -the desktop RPM when a signed update channel has been enrolled. The default -ISO configuration does not enable a desktop RPM channel. Inspect it with -`cybex update-channel status --json`; enabling one requires its reviewed public -configuration and full signing-key fingerprint. See the -[release guide](docs/releasing.md) for channel setup. - -On source-checkout installations, use the release updater described below. -These releases are separate from the ISO desktop RPM channel. - -After the first install, use: - -```bash -cybex update + + + + NerdOS + + +# NerdOS — the **Opinionated System** + +**OS stands for Opinionated System.** [nerdos.org](https://nerdos.org). +Formerly CybexOS. + +NerdOS is a NixOS desktop built around Hyprland, a custom Quickshell interface, +keyboard-driven applications, and personal extensions that stay yours. +NixOS 26.05 on x86_64 is the current target. This is an **unreleased replacement**; +[qualification and release blockers](docs/validation.md) describe its actual status. + +The same NixOS modules define the installed product and the installation ISO, +which boots straight into a native installer screen and carries the desktop as +an offline payload. Vendor desktop +code lives in immutable store paths. Your machine owns its configuration and lock +file; personal modules and persistent data are separate from vendor releases. + +- [Fresh installation](docs/installation.md) +- [Architecture and ownership](docs/architecture/ownership.md) +- [Independent updates](docs/updates.md) +- [Personal configuration and plugin development](docs/customization.md) +- [NerdOS Assistant](docs/assistant.md) +- [Release publishing and signing keys](docs/releasing.md) +- [Recovery, persistence and garbage collection](docs/recovery.md) +- [Hardware and security](docs/hardware-security.md) +- [Validation status](docs/validation.md) + +Upstream packages and NerdOS are independently selectable: + +```console +nerd update check +nerd update stage upstream # or nerdos, both, local +nerd update status # review the built changes and returned ID/token +nerd update apply ID TOKEN +# Reboot when you choose, sign in, then: +nerd update confirm ``` -The updater follows the saved `stable` channel by default (`--channel beta` -selects and saves prereleases). It checks this repository's GitHub releases -anonymously, so no GitHub account or `gh auth login` is needed. While a channel -has no published release, the CybexOS step is skipped and packages still -update. For a newer immutable release it checks the API SHA-256 digest, -verifies the release workflow's provenance attestation offline from the -bundle published beside the archive, validates Fedora/architecture/config-schema -compatibility, and extracts into a versioned staging directory. One durable system worker owns the -saved-answer migration, candidate application, rollback, and atomic `current` -symlink change, so detaching the terminal cannot split the transaction. A -failed apply restores the previous configuration. The active release and two -recent fallbacks are kept. - -The same durable worker updates Fedora packages and system Flatpaks. From the -Quickshell panel, systemd requests authorization through the desktop's native -Polkit agent and progress remains in the Updates view; the worker then runs in -a transient system unit, so a Quickshell restart does not interrupt package -work. Terminal invocations retain their sudo-compatible path. On Btrfs, -package work first creates a recovery point: a read-only root snapshot and a -`/boot` archive. The boot menu's **CybexOS recovery points** submenu starts one -with a temporary overlay (hold Shift or press Esc during boot to show the -menu), and Settings → Backup & recovery → Recovery points or `sudo cybexos-system-snapshot -restore ID` makes it the system again; the replaced root is kept until you -discard it. - -Before you update, the Updates view's **What will change** lists each package -and app with its installed and incoming version, Fedora's advisories (security -fixes first), and CybexOS's own changes: the release notes on a source -checkout, the desktop RPM's changelog on an ISO installation. After an update -it names the recovery point that update took, with a link to the Settings -section that restores it. **Download updates automatically** (Settings → Bar → -Updates, off by default) downloads Fedora packages and system Flatpaks in the -background on AC power and unmetered networks; installing still waits for you. - -Recovery points never include your home folder. Back it up to an external -drive or an SSH server with **Settings → Backup & recovery → Set up…** (or -`cybex backup setup`). Backups are encrypted with a password kept in the GNOME -Keyring and a recovery key you save once; they run daily while the destination -is available, and a restore always goes into a new folder under `~/Restored`. - -Useful commands: - -| Command | Purpose | -| --- | --- | -| `cybex update --check` (source checkout) | Check the configured GitHub channel | -| `cybex update --system-only` (source checkout) | Update Fedora and Flatpak only | -| `cybex agent` | Launch or choose the per-user default AI coding agent | -| `cybex dev status` | Show whether the verified or a development runtime is active | -| `cybex plugin list` | Inspect personal widgets and API compatibility | -| `cybex backup status` | Show the last and next personal-file backup (`now`, `restore`, `setup` act) | -| `cybex verify` | Check the installed system (`--source` opts into developer checks) | -| `cybex doctor` | Alias for `verify` | -| `cybex report` | Write a redacted diagnostic bundle and optionally open a prefilled bug report | -| `cybex configure` (source checkout) | Re-run the installer questions | -| `cybex uninstall` (source checkout) | Remove project-managed configuration; retain applications | -| `cybex factory-reset` (ISO) | Erase all accounts and home folders and prepare the computer for a new owner | - -Detailed updater status, logs, cancellation, Btrfs recovery, and advanced -Ansible tags are documented in [the operations guide](docs/operations.md). - -## Hardware support - -Generic systems skip every XPS workaround. The `xps-2026` role activates only -for Dell vendor/product identifiers, supported SKUs `0DB9` or `0DBA`, and the -expected Panther Lake CPU family; individual devices are gated again before -their configuration is installed. See -[Dell XPS 2026 hardware support](docs/xps-2026-hardware.md). - -Machines with an NVIDIA GPU get RPM Fusion's driver automatically on both -installation paths, with Secure Boot key enrollment and the open-source driver -as a fallback; hybrid laptops keep rendering on the integrated GPU. See -[NVIDIA graphics](docs/nvidia.md). - -Any fingerprint reader that Fedora's fprintd supports, and any FIDO2/U2F -security key, can unlock the screen and approve sudo and system prompts -alongside the password; manage them in Settings > Security. Signing in after a -restart always asks for the password, which unlocks the login keyring. See -[Fingerprints and security keys](docs/security.md). - -## Development and releases - -```bash -./verify --source -./tests/fedora-vm-convergence -cybex dev enable "$PWD" -cybex dev disable -``` - -Development mode reads live Quickshell and static Hyprland modules from the -validated checkout. It never fetches, resets, merges, or writes to that -checkout, and normal internet updates remain enabled in parallel. - -CI runs the complete source contract in Fedora 44 and converges all roles on a -generic Fedora Cloud VM twice to enforce idempotence. A `vX.Y.Z` tag publishes only -after both gates pass. Repository release immutability must be enabled before -the first public tag. - -Key documentation: - -- [Operations and recovery](docs/operations.md) -- [Runtime and user ownership](docs/architecture/ownership.md) -- [Release process](docs/releasing.md) -- [Dependency and pinning policy](docs/dependency-policy.md) -- [Fedora major upgrades](docs/fedora-major-upgrade.md) -- [Licensing and asset provenance](docs/licensing.md) -- [Quickshell development notes](docs/quickshell-notes.md) -- [Contributing](CONTRIBUTING.md) and [reporting security issues](SECURITY.md) +Both the desktop Update button and CLI use the supervised, serialized lifecycle. +A failed combined preview can be retried explicitly with `stage upstream` while +retaining installed NerdOS. Personal inputs are never refreshed implicitly. -> [!IMPORTANT] -> The repository's original code and configuration are MIT-licensed. This -> license does not cover third-party packages, artwork, fonts, trademarks, or -> images. Audit those separate redistribution terms before publishing bundled -> release media; see [licensing and asset provenance](docs/licensing.md). +For source work, use `nix develop` and `tests/run`. Build the ISO with +`nix build .#iso`; use task-specific staging and the repository's ISO/PXE rules. +A successful build or fixture suite does not qualify installation, runtime, +physical hardware, or a different source revision. Nothing in these commands +publishes a release or changes the running workstation. diff --git a/SECURITY.md b/SECURITY.md index c14daaf0..07ab4788 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,97 +1,25 @@ -# Security policy - -CybexOS configures privileged parts of a Fedora system: sudo and Polkit -policy, systemd units, the firewall, login and disk-encryption handling, signed -update channels, and pinned downloads. We take reports about these seriously -and handle them privately until a fix is available. - -## Supported versions - -| Version | Supported | -| --- | --- | -| Latest CybexOS release on Fedora 44, x86_64 (ISO and source-checkout installations) | Yes | -| Older CybexOS releases | No; update with `cybex update` first | -| Other Fedora releases or architectures | No | - -Until the first public release is published, reports against the current -`main` branch are accepted. Fedora packages, the kernel, and vendor -applications receive security updates through their own channels; `cybex -update` applies them. - -## Reporting a vulnerability - -Do not report security problems in public issues, discussions, or pull -requests. - -Report them privately through GitHub's private vulnerability reporting: open -the repository's **Security** tab, choose **Advisories**, then **Report a -vulnerability**, or go directly to -. - -If that button is not available, open a public issue that asks for a private -security contact and contains no technical details. A maintainer will arrange -a private channel. - -### What to include - -- the CybexOS version (`cybex version`) and installation path (ISO or source - checkout); -- the affected component, file, or command; -- the impact: what an attacker gains, and from which starting position (local - user, another account, the local network, a malicious download or update); -- reproduction steps or a proof of concept, and any non-default settings - involved, such as passwordless sudo or sudoless Docker; -- whether the problem is already public or known to others. - -`cybex report` writes a redacted diagnostic bundle, -`cybexos-report-.tar.gz`, on your machine. Review its contents before -sharing it, and attach it only to the private advisory, never to a public -issue. Never attach unredacted logs, credentials, tokens, SSH or GPG keys, -recovery keys, or browser profiles. - -## What to expect - -Response times depend on maintainer availability. These are goals, not -guarantees: - -- acknowledge a report within 7 days; -- share an initial assessment within 14 days; -- agree on a fix and disclosure timeline with the reporter, prioritising by - severity. - -Fixes ship as new releases. Published releases are immutable, so a -correction is always a new version rather than an edited release. - -## Scope - -In scope is CybexOS's own code and configuration: - -- the source-checkout installer, Ansible roles, and saved configuration - handling; -- the ISO image, its installer, the `cybexos-desktop` RPM, provisioning, - repair, and reconciliation; -- the `cybex` command, helper scripts, Quickshell shell, and agent skill; -- update, release, signing, and provenance verification, including pinned - download checks; -- privilege boundaries: sudo, Polkit, systemd units, firewall policy, login, - and automatic-login encryption checks; -- insecure defaults, or CybexOS configuration that makes an upstream weakness - exploitable. - -Report problems in the upstream software itself to its maintainers: Fedora -(kernel and packages), RPM Fusion, Hyprland, Quickshell, Flatpak applications, -and vendor applications such as Brave, 1Password, ChatGPT, Tailscale, Steam, -Spotify, Claude Code, Codex, OpenCode, and T3 Code. If you are unsure, report -privately here and we will help route it. - -Documented, explicit opt-ins, such as passwordless sudo, passwordless local -Polkit, sudoless Docker, or additional local-network firewall ports, are -accepted trade-offs rather than vulnerabilities. A default that enables one -of them without the documented choice is in scope. - -## Disclosure - -We follow coordinated disclosure. Please keep details private until a fixed -release is available and a GitHub Security Advisory has been published, or -until 90 days after your report, whichever comes first, unless we agree on -another timeline. Advisories credit reporters who want to be credited. +# Security + +NerdOS configures a NixOS workstation's login, authorization, firewall and +privileged update lifecycle. The NixOS port is unreleased and is not yet +production-qualified. Report security issues privately through the repository's +GitHub security advisories; do not include credentials or personal archives. + +Nixpkgs supplies upstream packages. NerdOS releases and stable/beta channel +manifests use explicit Ed25519 publisher trust. Source revision and NAR identity +must match authenticated metadata. Invalid signatures, expiry, replay, API +incompatibility and normal-update downgrades are rejected. No production trust +key has been supplied; the checked-in trust set is empty and fails closed. + +Secrets never belong in Nix expressions or source archives. Use root-owned +runtime paths and service credentials. The Nix store is publicly readable. +Personal plugins execute trusted local code and are not sandboxed. + +Password-only first login unlocks the keyring. Fingerprint/FIDO authorization +retains password fallback; automatic login requires a declaration and runtime +verification of encryption. Secure Boot must never be silently disabled. +AppArmor and explicit kernel hardening replace distribution-specific policy; +policy equivalence and physical authentication still require qualification. + +[Hardware/security](docs/hardware-security.md), [update trust](docs/releasing.md) +and [recovery limits](docs/recovery.md) document the concrete boundaries. diff --git a/VERSION b/VERSION index cb676de1..7e0b2310 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.0-dev +1.0.0-beta.2 diff --git a/agent-skills/cybexos/SKILL.md b/agent-skills/cybexos/SKILL.md deleted file mode 100644 index 7afa64ab..00000000 --- a/agent-skills/cybexos/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: cybexos -description: Operate and customize an installed CybexOS Hyprland/Quickshell workstation. Use for CybexOS diagnostics and commands, personal widgets, shell or bar settings, managed desktop changes, screenshots, recording, OCR, reminders, or LocalSend; not for unrelated Fedora systems. ---- - -# CybexOS - -Use this skill for an installed [CybexOS](https://github.com/DigitalPals/CybexOS) -desktop (Cybex Opinionated System). Codex can invoke it as `$cybexos`; Claude -Code exposes the same skill as `/cybexos`. Its description also supports -automatic selection. - -## Identify the installation - -Check `rpm -q cybexos-desktop` first. On ISO installations, packaged vendor -files are under `/usr/share/cybexos`; the user's -`~/.local/share/cybexos/runtime` is a compatibility symlink to the packaged -runtime. These systems do not use `~/.local/share/cybexos/current`. - -If the desktop RPM is absent, check whether this is a source-checkout -installation. Its active release is selected by -`~/.local/share/cybexos/current`; inspect it read-only for diagnostics and -schemas. Do not assume a missing RPM means an incomplete installation. - -## Choose the ownership layer - -- User shell preferences belong in `~/.config/cybexos/shell.json`. Read - [Quickshell settings](references/quickshell-settings.md) before editing it. -- Personal bar widgets belong in user-owned plugin packages. Read - [User widgets](references/user-widgets.md); use the versioned plugin API and - `cybex plugin` commands. -- Personal Hyprland changes belong in `~/.config/cybexos/hypr/user.lua`. -- Changes to distro defaults, built-in Quickshell code, services, packages, - and other vendor behavior belong in a writable source checkout. Read - [Managed configuration](references/managed-configuration.md). - -For supported operator commands and desktop actions, read -[Commands and desktop helpers](references/commands.md). - -## Boundaries - -- Never edit packaged files under `/usr/share/cybexos` or files below the - source installation's `~/.local/share/cybexos/current` or - `~/.local/share/cybexos/runtime` directly. -- Do not treat `cybex repair` as a way to deploy checkout changes. On ISO - installations it reapplies the policy bundled with the installed RPM; make - vendor changes in source, rebuild/update the desktop RPM, and then use the - supported package update path. -- Never add personal plugin IDs or settings to `shell.json`'s built-in `mods` - or `modOpts`. Preserve plugin packages, preferences, and state across - updates and rollbacks. -- Preserve unrelated checkout changes. Read every applicable `AGENTS.md` - before modifying or testing a checkout. -- Require explicit user intent before reconfiguration, updates, uninstall, - cancellation, reboot, shutdown, reset, package removal, or another - destructive operation. A diagnostic request authorizes inspection, not a - repair or upgrade. -- Never use `pkill qs`. Every live Quickshell check must use the checkout's - `tests/lib/quickshell-live` start and end functions, including their PID, - service, and current-invocation journal checks. - -Prefer the least invasive diagnostic that answers the request. Report what was -observed, what was changed, the verification performed, and anything that -still needs manual confirmation. diff --git a/agent-skills/cybexos/references/commands.md b/agent-skills/cybexos/references/commands.md deleted file mode 100644 index ad345d8b..00000000 --- a/agent-skills/cybexos/references/commands.md +++ /dev/null @@ -1,121 +0,0 @@ -# Commands and desktop helpers - -Prefer installed commands over reconstructed shell pipelines. On RPM systems, -packaged command sources live under `/usr/share/cybexos`; on source-checkout -systems, inspect the active release under `~/.local/share/cybexos/current` or -run help before using an unfamiliar option. - -## CybexOS - -- `cybex version` reports the active desktop release; `cybex doctor --json` - runs read-only installed-system diagnostics (on checkouts, `cybex verify` - too; add `--source` only for repository/developer checks). -- `cybex update-channel status --json` reports the RPM update channel. On - checkouts, `cybex update --check` checks the release channel instead; ISO - installations do not support it. Neither starts an update. -- `sudo /usr/libexec/cybexos-reconcile --status` inspects pending account and - machine policy reconciliation after RPM upgrades; `--retry` requests a retry. -- `cybexos-update-run status --json`, `log-dir`, `read-log` and - `history --json` (finished updates with the recovery point, `snapshotId`, - each took) inspect the durable updater without starting one. -- `cybexos-runtime ipc updates check` refreshes the desktop's update check; - `cybexos-runtime ipc updates open` opens the Updates view. Neither installs. - `systemctl is-enabled cybexos-update-download.timer` shows whether - background downloads are on (off by default; changing it is a Settings - choice that asks for authorization). -- `cybex report --json --no-browser` writes a redacted diagnostic bundle - (`cybexos-report-*.tar.gz`; `--output` picks the path) and prints its - summary. It uploads nothing; the user reviews and attaches the file. -- `/usr/local/libexec/cybexos-nvidia status` prints the NVIDIA driver state - (`ready`, `reboot`, `enroll`, `failed`). Never print - `/var/lib/cybexos/nvidia-mok.json`: it holds the one-time Secure Boot - enrollment password. -- `cybex factory-reset` (ISO installations only) erases every account and home - folder for a new owner. It is destructive and interactive (typed - confirmation, sudo): only on an explicit request, after suggesting - `cybex backup now`. On a checkout it only explains it is unavailable. - -Enable a public RPM channel only after verifying its public configuration and -key fingerprint (`image/channels/stable.json` in the source tree; add -`--check` to validate without enabling). Never use a private signing key on -the workstation: - -```bash -sudo cybex update-channel enroll /path/to/public.json \ - --fingerprint FULL_OPENPGP_PRIMARY_FINGERPRINT -``` - -An actual `cybex update`, `configure`, `install`, or `uninstall` needs explicit -user intent. So do update cancellation, reboot, shutdown, and recovery/reset -operations. Do not infer authorization from a request to diagnose or check -status. - -## Backups - -`cybex backup` drives encrypted personal-file backups (restic), the same -helper as Settings → Backup & recovery. `status --json` (read-only, no -restic), `snapshots --json` (may ask to unlock the keyring) and `excludes` -(extra patterns go in `~/.config/cybexos/backup-exclude`) only inspect. -Steam userdata, Proton prefixes and rootless container volumes are included; -downloaded games, container images and writable layers are excluded. Live -database files are not a consistent export: use the application's dump/backup -tool into a backed-up home folder, or stop it for the whole backup. System -stores and separately mounted filesystems are outside this home backup. -`setup`, `now`, `restore`, `automatic`, `recovery-key` and `confirm-key` need -explicit user intent. `restore` writes only into a new folder under -`~/Restored`. Never ask for, print, store, or copy a recovery key or the -keyring password; `setup` and `recovery-key` show a new key once, to the user. - -## Desktop actions - -These commands act in the user's graphical session. A direct request for the -corresponding action supplies intent; otherwise explain the command rather -than launching an interactive selector or sending data. - -- `screenshot` selects a region (or a window, with a click), saves it under - `~/Pictures/Screenshots` and copies it; `screenshot fullscreen` captures the - focused monitor. `screenshot scroll` captures a region while the user - scrolls and saves one stitched image when they press Enter, Escape or the - shortcut again (`screenshot scroll-stop` finishes, never starts, one). -- `screen-record` toggles a region recording, saved under - `~/Videos/Screen Recordings`; `--webcam` adds a camera preview to the - recorded area, and `--encoder` prints the encoder a recording would use. -- `screen-ocr` copies a QR code or barcode's content in a selected region, - or else its English text. It never opens a decoded link. -- `color-picker` lets the user click a pixel and copies its hex colour. -- `quickshell-reminder add MINUTES [MESSAGE]` schedules a persistent reminder; - `list --json`, `cancel ID` and `clear` manage them. -- `cybex webapp list --json` lists web apps. `install --url URL [--name NAME] - [--id ID]` adds one (http/https without credentials; `--offline` uses a - generated icon), `remove ID` removes one for good, `launch ID` opens one. - The defaults are `slack`, `whatsapp`, `youtube`, `google-photos` and `x`. -- `cybex theme list --json` lists appearance themes; `apply ID` sets mode, - accent, bar colour and font (and a theme's wallpaper) at once; `export - ID|CURRENT [DIR] [--name NAME]` writes a shareable folder or `.tar.gz`; - `install SOURCE` accepts a folder, archive or `https://` Git URL holding - only data; `remove ID` deletes a user theme. Make a theme in - `~/.local/share/cybexos/themes//theme.json` (schema in - docs/shell-appearance.md#themes), not by editing shell.json theme keys. -- `game-mode status [--json]` reports game mode; `on`, `off` and `toggle` - turn effects, rounding, gaps and idle locking off until it is turned off or - the session ends. It notifies on every change. -- `cybexos-runtime ipc launcher open PROVIDER` opens the launcher on a tab - such as `clipboard` or `actions`; `cybexos-runtime ipc overview toggle` - (or `open`, `close`) shows the window overview, as `Super+Tab` does. -- `localsend` launches the LocalSend Flatpak. `localsend-share clipboard`, - `localsend-share file [PATH...]` and `localsend-share folder [PATH...]` send - through its headless interface; omitted paths open an interactive chooser. - -Screen capture, recording, OCR, colour picking, reminders, web app changes and -LocalSend are user-visible or externally consequential. Report cancellation or -command failure accurately; do not retry a send, capture, or reminder creation -unless the first attempt is known not to have completed. - -## Sign-in factors - -Fingerprints and FIDO2/U2F security keys are managed in Settings → Security -(`cybexos-runtime ipc settings open security`). Enrolment needs the person at -the reader or key, so open the page rather than running `fprintd-enroll` or -`pamu2fcfg` for them. The sudo, Polkit and lock-screen PAM steps belong to -`cybexos-auth-policy` (docs/security.md); do not hand-edit `/etc/pam.d` or -enable authselect's `with-pam-u2f`, which would also reach the login screen. diff --git a/agent-skills/cybexos/references/managed-configuration.md b/agent-skills/cybexos/references/managed-configuration.md deleted file mode 100644 index 41cd6c1e..00000000 --- a/agent-skills/cybexos/references/managed-configuration.md +++ /dev/null @@ -1,94 +0,0 @@ -# Managed Hyprland and Quickshell changes - -Use this guide for persistent behavior owned by CybexOS: Hyprland, -Quickshell source, services, packages, launchers, or provisioning policy. -Personal widgets use [the user widget API](user-widgets.md), and personal -Hyprland overrides use `~/.config/cybexos/hypr/user.lua`. Those changes do -not need a distro fork. Never edit deployed vendor copies. - -ISO installations receive vendor files in the `cybexos-desktop` RPM under -`/usr/share/cybexos`; `~/.local/share/cybexos/runtime` points to its runtime. -There is no `~/.local/share/cybexos/current` release link on this installation -type. Persistent vendor changes for an ISO installation must be made in a -source checkout, included in a rebuilt desktop RPM, and delivered through the -RPM update channel. `cybex repair` reapplies policy bundled in the installed -RPM; it does not deploy files from a checkout. - -Hyprland and Quickshell are held to the series the release qualified by -entries marked `CybexOS qualified series` in `/etc/dnf/versionlock.toml` -(`dnf versionlock list`); a converge replaces edits to them. Moving to another -series is a release change to `qualified_package_series` in -`inventory/group_vars/all.yml`. With explicit user intent, a separately -reviewed build can be admitted by an entry of the administrator's own, such as -`sudo dnf versionlock add hyprland-VERSION-RELEASE`, which CybexOS keeps. - -## Find a writable checkout - -1. Inspect the current working tree and `~/Code/CybexOS` first. A usable - checkout must be writable, have this repository's `site.yml`, and identify - `DigitalPals/CybexOS` (formerly `DigitalPals/fedora-config`) as an expected - Git remote. Do not mistake - `~/.local/share/cybexos/current` or a versioned release for a checkout. -2. If needed, search a small set of user source roots such as `~/Code` without - traversing the whole home directory. Inspect `git status` before choosing a - tree, and preserve all existing modifications. -3. If no suitable checkout exists, ask before cloning - `https://github.com/DigitalPals/CybexOS.git` into - `~/Code/CybexOS`. Cloning is not implied by a customization or - diagnostic request. -4. Read the repository root `AGENTS.md` and any nearer `AGENTS.md` files before - acting. - -Use the packaged source under `/usr/share/cybexos` read-only to inspect an ISO -installation. On a source-checkout installation, use its active release -read-only when no source change is needed. These are different deployment -paths; do not run checkout Ansible against an ISO installation as a way to -apply RPM-managed files. - -## Change and deploy - -Keep the edit in the smallest managed source file. Check the worktree before -and after, and do not reformat, delete, stage, or restore unrelated changes. - -Run the repository gate before a source-checkout deployment: - -```bash -./tests/run -``` - -For source-checkout installations, preview the machine change with the saved -installer contract when practical: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff -``` - -Deploy to a source-checkout installation through Ansible, choosing only a -documented narrow tag when its prerequisites are already present. Hyprland and -Quickshell are normally in the `desktop` role: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags desktop -``` - -Do not copy source files directly into `~/.config`. Do not run reconfiguration, -an update, uninstall, reboot, package removal, or reset unless the user -explicitly requested that operation. - -## Live Quickshell safety - -Every live shell inspection or smoke test must source this checkout's -`tests/lib/quickshell-live` and call `qs_live_begin` before the check and -`qs_live_end` afterward. Ensure `qs_live_end` also runs on failure and signals. -Those functions reconcile `quickshell.service`'s `MainPID` against every -`pgrep -x qs` result, inspect extra processes before terminating only confirmed -developer instances, restore service health, and inspect the current -invocation journal. Never substitute a handwritten shortcut and never run -`pkill qs`. - -Prefer deployment followed by testing through `quickshell.service`. If a -source-tree `qs -d` or `qs -p` session is genuinely necessary, stop -`quickshell.service` first and install cleanup that always terminates only that -known developer PID and restores the service. The test is incomplete until -`qs_live_end` confirms the managed service is active and is the sole clean -`qs` process. diff --git a/agent-skills/cybexos/references/user-widgets.md b/agent-skills/cybexos/references/user-widgets.md deleted file mode 100644 index d3bd98af..00000000 --- a/agent-skills/cybexos/references/user-widgets.md +++ /dev/null @@ -1,82 +0,0 @@ -# Personal bar widgets - -Create a widget for the installed user without changing the distro. The active -release's [widget contract](../../../docs/architecture/user-widgets.md) is the -authoritative API reference; read it before implementing behavior. - -## Ownership and workflow - -1. Inspect `cybex plugin list`. Use a unique lowercase ID such as - `personal.focus`; preserve an existing package with that ID. -2. Create `~/.local/share/cybexos/plugins//manifest.json` and its QML - entrypoint. Use an ordinary QtQuick Item and declare - `required property var pluginApi`. Declare `apiVersion: 1` in the manifest. -3. Use `pluginApi.settings`, `pluginApi.theme`, and `pluginApi.setSetting()`. - Keep helper code/assets in the package and persistent data under - `pluginApi.dataPath`. Do not import private Common/Bar components or - hardcode a release path. Widgets run as trusted code with the user's access. -4. Enable with `cybex plugin enable --width 120 --order 0`. - New packages are disabled until explicitly enabled. Enablement and settings - refresh within two seconds. QML code edits require a Quickshell restart. -5. Set preferences with `cybex plugin set ''`. - This merges one key under a lock. Never replace the whole registry or edit - built-in `shell.json` module lists to register a plugin. -6. Verify the widget in the bar and run `cybex plugin list` again. - API/manifest problems appear there; QML load errors appear on the widget's - error tooltip. Use `cybex plugin disable ` to recover without - deleting code, preferences, or data. - -V1 places widgets before the right-hand built-in modules. Width is 24–320 -pixels; lower order comes first, with IDs breaking ties. Widgets that exceed -the available space are hidden with a count and tooltip. Each output gets its -own widget instance; settings and state are shared. Run shared background -services separately rather than starting one daemon per output. - -For a live check, follow the repository's `tests/lib/quickshell-live` begin/end -workflow described in [managed configuration](managed-configuration.md). Test -through `quickshell.service`; never leave a second source-tree shell running. - -## Minimal package - -`manifest.json`: - -```json -{ - "id": "personal.focus", - "name": "Focus", - "version": "1.0.0", - "apiVersion": 1, - "entrypoint": "Widget.qml" -} -``` - -`Widget.qml`: - -```qml -import QtQuick - -Rectangle { - required property var pluginApi - color: pluginApi.theme.background - radius: 5 - Text { - anchors.centerIn: parent - text: parent.pluginApi.settings.label || "Focus" - color: parent.pluginApi.theme.foreground - font.family: parent.pluginApi.theme.fontFamily - font.pixelSize: parent.pluginApi.theme.fontSize - } -} -``` - -After creating the package: - -```bash -cybex plugin set personal.focus label '"Deep work"' -cybex plugin enable personal.focus --width 120 -``` - -An API mismatch preserves the package and its settings but does not execute -its QML. Do not lower a manifest's API version to bypass that check; adapt the -implementation to a supported API. External programs, services, and network -APIs remain the plugin author's dependencies; list them when handing off. diff --git a/agent-skills/nerdos/SKILL.md b/agent-skills/nerdos/SKILL.md new file mode 100644 index 00000000..0956b818 --- /dev/null +++ b/agent-skills/nerdos/SKILL.md @@ -0,0 +1,67 @@ +--- +name: nerdos +description: Customize or maintain a NerdOS NixOS Hyprland/Quickshell desktop, including personal packages, services, VPNs, widgets, settings, staged updates and recovery. Use on supported NerdOS installations; not for unrelated NixOS hosts. +--- + +# NerdOS + +Read `/etc/os-release` and `/etc/nerdos/release.json` before making changes. +The supported machine configuration is `/etc/nixos/flake.nix` and its +`flake.lock`; `/etc/nixos` is the machine-owned working-configuration symlink. +Read its modules and applicable repository instructions. A checkout is vendor +source, not the personal configuration of the running desktop. + +Choose the owner before editing: + +- Vendor: `/nix/store`, `/etc/nerdos/runtime` and + `/run/current-system/sw/share/nerdos`. Inspect read-only. Personal changes + never require editing these files or forking NerdOS. +- Machine/personal declarations: `/etc/nixos/local/*.nix`, imported by + `local/default.nix`. Add packages, services and VPN definitions here. + Normal Nix definitions override vendor `mkDefault` values. Preserve unrelated + declarations and inputs; do not replace entire files with a small example. +- Desktop preferences: `~/.config/nerdos/shell.json`, `displays.json` and + `hypr/user.lua`. Preferences apply without rebuilding the operating system. + Read [Quickshell settings](references/quickshell-settings.md) before edits. +- Plugins: personal code, preferences, placement and data are separate from + the vendor runtime. Read [User widgets](references/user-widgets.md), use the + API 1 contract and `nerd plugin` for native or Omarchy plugins. Personal QML + must not import private shell types. Plugins are trusted code, not sandboxed. +- Secrets/state: `/var/lib/nerdos/secrets` (root `0700`, files `0600`), + persistent service directories, and the person's home. Use `privateKeyFile`, + `LoadCredential` or the service's documented runtime secret API. Never put + secret values or `builtins.readFile` of a secret in a flake, Nix string, + source archive, command argument, log, or public repository. + +For packages, services or a VPN, read +[Personal declarations](references/managed-configuration.md). NetworkManager +profiles and credentials may instead use its persistent system D-Bus APIs; +do not copy a credential into a Nix declaration. + +Preview a requested system change through `nerd update stage local`. This +builds the complete configuration and checks the candidate desktop. Inspect +`nerd update status`, including actual package changes and errors. Apply only +the reviewed `transaction` and `reviewToken` using +`nerd update apply TRANSACTION REVIEW_TOKEN`. An authorized request to make +and apply a customization covers this workflow; a diagnostic request does not. +Never substitute a direct `nixos-rebuild switch` or manual lock refresh for the +supported lifecycle. Explain required administrator authentication. + +For updates, select `upstream`, `nerdos` or `both` explicitly. Personal inputs +are updated only on a separate explicit request. A combined failure offers an +explicit upstream-only retry retaining installed NerdOS; never remove modules +or widgets to make a build pass. Keep `system.stateVersion` unchanged. Read +[Commands and recovery](references/commands.md) when applying or recovering. + +All live Quickshell tests must source the installed +`/run/current-system/sw/share/nerdos/lib/quickshell-live` and run `qs_live_begin` +and `qs_live_end`. In a checkout use `tests/lib/quickshell-live`. The managed +service's MainPID must be the sole `qs` PID and its current journal must be +clean. Never use `pkill qs` or launch a second menubar. Preview personal plugin +code in an isolated test environment, or use the managed service under these +guards when the user has authorized a live test. + +Report what changed, the preview/build and health results, persistent data +impact, and any required user-selected restart. Never reboot without an +explicit preference or instruction. Generation rollback restores system code +and configuration; it does not roll back credentials or application databases. diff --git a/agent-skills/nerdos/references/commands.md b/agent-skills/nerdos/references/commands.md new file mode 100644 index 00000000..c79ed3dd --- /dev/null +++ b/agent-skills/nerdos/references/commands.md @@ -0,0 +1,32 @@ +# Commands and recovery + +- `nerd update check`: independent upstream and authenticated NerdOS status. +- `nerd update stage upstream|nerdos|both|local`: build and validate a staged + complete configuration, preserving the working configuration. +- `nerd update status`: durable progress, actual closure changes, errors, + restart needs, transaction ID and review token. +- `nerd update apply ID TOKEN`: supervised activation of the reviewed build. +- `nerd update confirm`: after an explicit reboot and authenticated desktop + login, check boot/services/Hyprland/Quickshell and retain the known-good system. +- `nerd update history`: retained deployment identities and recovery state. +- `nerd update rollback [ID]`: restore the pending or selected deployment's + previous system and configuration. No automatic reboot or application-data + rollback is implied. +- `nerd plugin ...`, `nerd theme ...`, `nerd shell ...`: persistent desktop + extensions, themes and diagnostics. + +Read the error and transaction status before retrying. Interrupted staging +retains the working system. Interrupted activation is recovered at boot. A trial +boot leaves the previous generation as the persistent boot default until fresh +boot and login health pass. If a trial fails before userspace, reboot and choose +the previous generation; automatic reboots are disabled. Boot the installation +ISO for offline recovery if the firmware cannot find a retained generation. + +Persistent credentials, NetworkManager profiles, databases and widget data +remain in place through rollback. Back up and test explicit data migrations +before a future service/schema change; rolling system code back does not undo +data writes. Do not downgrade a database or rewrite an unknown newer schema. + +Use the user's normal desktop commands for screenshots (`screenshot region`), +recording (`screen-record`), OCR (`screen-ocr`) and file sharing (`localsend-share`). +Do not capture or transmit private material without an authorized task. diff --git a/agent-skills/nerdos/references/managed-configuration.md b/agent-skills/nerdos/references/managed-configuration.md new file mode 100644 index 00000000..7f443665 --- /dev/null +++ b/agent-skills/nerdos/references/managed-configuration.md @@ -0,0 +1,35 @@ +# Personal declarations + +Inspect `/etc/nixos/flake.nix`, `machine.nix`, `local/default.nix`, and existing +modules first. Add a narrowly scoped module and import it from +`local/default.nix`; preserve other imports. Example: + +```nix +{ pkgs, ... }: { + environment.systemPackages = [ pkgs.wireshark ]; + services.syncthing.enable = true; + networking.wg-quick.interfaces.work = { + address = [ "10.20.0.2/32" ]; + privateKeyFile = "/var/lib/nerdos/secrets/work-vpn.key"; + peers = [{ + publicKey = "PUBLIC_KEY"; + allowedIPs = [ "10.20.0.0/16" ]; + endpoint = "vpn.example.org:51820"; + }]; + }; +} +``` + +Service `StateDirectory` and `LoadCredential` retain state and load runtime +secrets without exposing plaintext to the Nix store. For VPN credentials, ask +for an authorized local secret file or use a private secret-entry mechanism; +never ask the person to paste a key into chat. Do not print it to validate it. + +Run `nerd update stage local`, review `nerd update status`, and apply the +returned transaction/token. Failed evaluation/build leaves the active system +available. Correct the personal module; do not disable unrelated modules. + +Nixpkgs must remain the single stable root input. New personal dependencies +follow the root `nixpkgs`. Vendor input updates must leave personal input +identities alone. Major NixOS upgrades require explicit intent and compatibility +qualification; `system.stateVersion` records the installation state baseline. diff --git a/agent-skills/cybexos/references/quickshell-settings.md b/agent-skills/nerdos/references/quickshell-settings.md similarity index 85% rename from agent-skills/cybexos/references/quickshell-settings.md rename to agent-skills/nerdos/references/quickshell-settings.md index 021686da..c69d0979 100644 --- a/agent-skills/cybexos/references/quickshell-settings.md +++ b/agent-skills/nerdos/references/quickshell-settings.md @@ -1,7 +1,7 @@ # Quickshell settings Use this guide only for values persisted in -`~/.config/cybexos/shell.json`. Personal widget packages and their +`~/.config/nerdos/shell.json`. Personal widget packages and their separate preferences use [the user widget API](user-widgets.md). ## Inspect the active schema @@ -9,18 +9,18 @@ separate preferences use [the user widget API](user-widgets.md). Do not assume a schema version or copy defaults from another release. Read: ```text -~/.local/share/cybexos/current/roles/desktop/files/quickshell/Common/SettingsHelpers.js -~/.local/share/cybexos/current/roles/desktop/files/quickshell/Common/Settings.qml +/etc/nerdos/runtime/quickshell/Common/SettingsHelpers.js +/etc/nerdos/runtime/quickshell/Common/Settings.qml ``` `SettingsHelpers.js` is authoritative for `VERSION`, `defaults()`, accepted enums and ranges, module IDs, composite normalization, and migrations. -`Settings.qml` identifies the persisted properties and the live watcher. It can -be loaded with Node because it exports its pure helpers: +`Settings.qml` identifies the persisted properties and the live watcher. +`SettingsHelpers.js` can be loaded with Node because it exports its pure helpers: ```bash node -e 'const s=require(process.argv[1]); console.log(JSON.stringify({version:s.VERSION,defaults:s.defaults(),modules:s.MODULE_IDS},null,2))' \ - "$HOME/.local/share/cybexos/current/roles/desktop/files/quickshell/Common/SettingsHelpers.js" + /etc/nerdos/runtime/quickshell/Common/SettingsHelpers.js ``` The stable top-level setting families are: @@ -28,7 +28,7 @@ The stable top-level setting families are: - Wallpaper: `wall`, `wallDir`, and `shuffle`. - Appearance: theme, glass, contrast, motion, text scale, density, bar color, font, accent, and palette fields. `theme` and `themePalette` record the - named theme applied last; change them with `cybex theme apply ID`, which + named theme applied last; change them with `nerd theme apply ID`, which sets every value a theme owns together. - Bar: `position`, `barStyle`, `gap`, `barHeight`, `barRadius`, `autoHide`, and `exclusive`; the optional dock's `dock`, `dockAutoHide`, and `dockPinned` @@ -46,10 +46,10 @@ not partial patches. Do not set or lower the `v` field manually. ## Merge safely -1. If a live shell will be checked, first locate the applicable checkout (the - active release is sufficient for diagnostics), source - `tests/lib/quickshell-live`, and call `qs_live_begin`. Arrange cleanup so - `qs_live_end` runs even after failure or interruption. +1. If a live shell will be checked, source the installed + `/run/current-system/sw/share/nerdos/lib/quickshell-live` and call + `qs_live_begin`. In a checkout use `tests/lib/quickshell-live`. Arrange cleanup + so `qs_live_end` runs even after failure or interruption. 2. Read the current file. If it exists, require a regular, valid JSON object; stop on a symlink, non-regular file, malformed JSON, or read failure. 3. Put only the requested keys in a JSON object patch. Preserve arrays in full. diff --git a/agent-skills/nerdos/references/user-widgets.md b/agent-skills/nerdos/references/user-widgets.md new file mode 100644 index 00000000..91251475 --- /dev/null +++ b/agent-skills/nerdos/references/user-widgets.md @@ -0,0 +1,88 @@ +# Personal bar widgets + +Create a widget for the installed user without changing the distro. The active +release's authoritative API reference is installed at +`/run/current-system/sw/share/nerdos/source/docs/architecture/user-widgets.md`; +read it before implementing behavior. In a checkout, read +[the widget contract](../../../docs/architecture/user-widgets.md). + +## Ownership and workflow + +1. Inspect `nerd plugin list`. Use a unique lowercase ID such as + `personal.focus`; preserve an existing package with that ID. +2. Create `~/.local/share/nerdos/plugins//manifest.json` and its QML + entrypoint. Use an ordinary QtQuick Item and declare + `required property var pluginApi`. Declare `apiVersion: 1` in the manifest. +3. Use `pluginApi.settings`, `pluginApi.theme`, and `pluginApi.setSetting()`. + Keep helper code/assets in the package and persistent data under + `pluginApi.dataPath`. Do not import private Common/Bar components or + hardcode a release path. Widgets run as trusted code with the user's access. +4. Enable with `nerd plugin enable --width 120 --order 0`. + New packages are disabled until explicitly enabled. Enablement and settings + refresh within two seconds. QML code edits require a Quickshell restart. +5. Set preferences with `nerd plugin set ''`. + This merges one key under a lock. Never replace the whole registry or edit + built-in `shell.json` module lists to register a plugin. +6. Verify the widget in the bar and run `nerd plugin list` again. + API/manifest problems appear there; QML load errors appear on the widget's + error tooltip. Use `nerd plugin disable ` to recover without + deleting code, preferences, or data. + +API 1 supports left, center and right plugin sections. Native widgets default +to the right section; move them with `nerd plugin move-widget left|center|right ` +or drag them in the bar. Width is 24–320 pixels; lower order comes first within +a section, with IDs breaking ties. Widgets that exceed +the available space are hidden with a count and tooltip. Each output gets its +own widget instance; settings and state are shared. Run shared background +services separately rather than starting one daemon per output. + +For a live check, source the installed +`/run/current-system/sw/share/nerdos/lib/quickshell-live` and run +`qs_live_begin` and `qs_live_end`. In a checkout use `tests/lib/quickshell-live`. +Test through `quickshell.service`; never leave a second source-tree shell +running. + +## Minimal package + +`manifest.json`: + +```json +{ + "id": "personal.focus", + "name": "Focus", + "version": "1.0.0", + "apiVersion": 1, + "entrypoint": "Widget.qml" +} +``` + +`Widget.qml`: + +```qml +import QtQuick + +Rectangle { + required property var pluginApi + color: pluginApi.theme.background + radius: 5 + Text { + anchors.centerIn: parent + text: parent.pluginApi.settings.label || "Focus" + color: parent.pluginApi.theme.foreground + font.family: parent.pluginApi.theme.fontFamily + font.pixelSize: parent.pluginApi.theme.fontSize + } +} +``` + +After creating the package: + +```bash +nerd plugin set personal.focus label '"Deep work"' +nerd plugin enable personal.focus --width 120 +``` + +An API mismatch preserves the package and its settings but does not execute +its QML. Do not lower a manifest's API version to bypass that check; adapt the +implementation to a supported API. External programs, services, and network +APIs remain the plugin author's dependencies; list them when handing off. diff --git a/ansible.cfg b/ansible.cfg deleted file mode 100644 index e26deb33..00000000 --- a/ansible.cfg +++ /dev/null @@ -1,28 +0,0 @@ -[defaults] -inventory = inventory/hosts.yml -roles_path = roles -library = image/library -callback_plugins = plugins/callback -interpreter_python = auto_silent -retry_files_enabled = False -stdout_callback = cybexos -force_color = True -# Every invocation validates the machine it is running on from fresh setup -# facts. A persistent cache is unsafe for a local workstation inventory alias: -# it can outlive a kernel update or even follow the checkout to another host. -gathering = explicit -fact_caching = memory -# A notified handler is otherwise lost when a later task fails: the retry sees -# its triggering task as unchanged, so an initramfs rebuild, daemon-reload, or -# service restart would never happen. Run already-notified handlers first. -force_handlers = True - -[privilege_escalation] -become = True - -[connection] -# Feed modules to the interpreter over stdin instead of writing, chmodding, -# and deleting a temporary file per task. Fedora's sudo has no requiretty. -# Async tasks and file transfers still use temporary files, which the base -# role's acl package makes readable to an unprivileged become_user. -pipelining = True diff --git a/assets/brand/FONT-LICENSES/jetbrains-mono.txt b/assets/brand/FONT-LICENSES/jetbrains-mono.txt new file mode 100644 index 00000000..8f7ed671 --- /dev/null +++ b/assets/brand/FONT-LICENSES/jetbrains-mono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/brand/FONT-LICENSES/space-grotesk.txt b/assets/brand/FONT-LICENSES/space-grotesk.txt new file mode 100644 index 00000000..d5666d70 --- /dev/null +++ b/assets/brand/FONT-LICENSES/space-grotesk.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/brand/README.md b/assets/brand/README.md new file mode 100644 index 00000000..0653acb4 --- /dev/null +++ b/assets/brand/README.md @@ -0,0 +1,29 @@ +# NerdOS artwork + +The supplied NerdOS identity package is preserved here unchanged, including SVG +masters, outlined lettering, hinted small icons, ASCII art, usage rules and +generators. Read [USAGE.md](USAGE.md) before using a mark. NerdOS means the +**Opinionated System**, at **nerdos.org**. + +Deployment copies from the rename kit: + +| Destination | Kit source | +| --- | --- | +| `desktop/plymouth/logo{,@2x}.png` | `derived/plymouth/` | +| `desktop/quickshell/assets/nerdos{,-white}.svg` | `derived/quickshell/` | +| `desktop/icons/hicolor/` | `derived/hicolor/` | +| `desktop/fastfetch/` | `derived/fastfetch/` | +| `web/public/nerdos-logo.svg` | `derived/welcome/nerdos-logo.svg` | +| `web/public/nerdos{,-white}.svg` | `derived/quickshell/` | +| `web/public/` favicons and manifest | `derived/web/`, excluding the example HTML snippet | + +Nix packages install the boot theme, hicolor icons, fastfetch files and canonical +brand sources. The same shared module selects them for the ISO and installed +system. No Fedora/Cockpit welcome or new-owner window exists in this NixOS tree; +the terminal installer and browser welcome carry the identity. + +These marks are original project artwork supplied in October 2026, under the +repository MIT license. Space Grotesk and JetBrains Mono lettering is outlined +from SIL OFL 1.1 fonts; these SVGs have no live-text/font dependency. Their font +copyright and licence notices are in `FONT-LICENSES/`. +Trademark clearance for **NerdOS** remains an owner task. diff --git a/assets/brand/USAGE.md b/assets/brand/USAGE.md new file mode 100644 index 00000000..252fece5 --- /dev/null +++ b/assets/brand/USAGE.md @@ -0,0 +1,138 @@ +# NerdOS identity — usage guide + +**NerdOS** (formerly CybexOS) · **OS = Opinionated System** · nerdos.org + +The mark is a pair of chunky, softly rounded glasses with a strip of repair tape across the bridge. +The glasses carry the identity on their own; the lime tape is the single accent. + +--- + +## 1. Logo versions + +| Version | File | Use | +|---|---|---| +| Horizontal logo | `logo/svg/nerdos-logo-*.svg` | Default. Website header, docs, slides, packaging | +| Horizontal + descriptor | `logo/svg/nerdos-logo-descriptor-*.svg` | Where the name needs explaining: landing hero, installer, press | +| Glasses symbol | `symbol/svg/nerdos-symbol-*.svg` | Avatars, stickers, boot splash, loading states, anywhere the name is already present | +| App icon | `app-icon/svg/nerdos-app-icon-*.svg` | Launchers, docks, stores, social avatars | +| Favicon set | `favicon/` | Browser tabs, bookmarks, home-screen installs | +| ASCII | `ascii/` | fastfetch, MOTD, terminal splash, README headers | + +Each graphic version comes in four colourways: + +| Suffix | Ink | Tape | Background it is made for | +|---|---|---|---| +| `on-light` | Charcoal | Lime | Off-white, white, light greys | +| `on-dark` | Off-white | Lime | Charcoal, black, dark photos | +| `mono-black` | Black | Black | One-colour print, fax, embossing, laser etching | +| `mono-white` | White | White | One-colour reversed, dark single-colour print | + +In one-colour versions the tape is drawn in the ink colour and separated from the frames by a thin knockout gap, +so it still reads as tape. The mono files can be recoloured to any single solid colour. + +App icons come as rounded tiles (`*-{dark,light,mono-black,mono-white}.svg`) and as full-bleed squares +(`*-square.svg`) for platforms that apply their own mask. + +## 2. Colour + +| Name | Hex | RGB | Role | +|---|---|---|---| +| **Charcoal** | `#1A1B1E` | 26 27 30 | Primary ink on light; primary background on dark | +| **Off-white** | `#F3F2EC` | 243 242 236 | Primary background on light; ink on dark | +| **Electric lime** | `#C8FF1F` | 200 255 31 | The tape. Accent only | +| Graphite | `#5A5C61` | 90 92 97 | Secondary text on light | +| Fog | `#A6A8A1` | 166 168 161 | Secondary text on dark | +| Lime deep | `#4A7300` | 74 115 0 | Lime-family text/links on light backgrounds (5:1 on off-white) | + +Rules: + +* Lime is a highlight, not a fill. Keep it to roughly 5–10% of any layout. It belongs on the tape, on + focus states, and on small accents against charcoal. +* Never set lime text on off-white or white: contrast is about 1.05:1. Use **Lime deep** for lime-family text on light grounds. +* Lime on charcoal is about 15:1, so it is fine for text and UI on dark grounds. +* No gradients, glows or shadows on the mark. + +Print: electric lime sits outside the CMYK gamut. For print, match a spot (or fluorescent) ink to the hex by eye +with your printer, or accept a slightly duller green in process colour. Charcoal prints well as a rich black. + +## 3. Typography + +| Role | Typeface | Notes | +|---|---|---| +| Wordmark | Space Grotesk Bold, customised | Emboldened ~2.4% of em on stems, optically re-spaced. Always use the outlined SVG; never retype the wordmark | +| Descriptor | JetBrains Mono Bold, uppercase | Tracked +90, justified to the wordmark width | +| Headlines (UI, web, slides) | Space Grotesk 600–700 | Tight tracking (−1% to −2%) at large sizes | +| Body | Space Grotesk 400 / system UI | 16 px minimum on the web | +| Code, terminal, labels | JetBrains Mono 400–700 | Same family as the descriptor and the ASCII art | + +Both families are open-source (SIL Open Font License) and available on Google Fonts. + +## 4. Construction, clear space and alignment + +* **X** = the frame thickness of the glasses at the size used. +* **Clear space:** keep at least **2X** free on every side of every lockup and of the symbol. Nothing (text, edges, + other logos) enters that zone. +* The glasses frame height is **1.22 × the cap height** of the wordmark. The gap between symbol and wordmark is + **0.46 × cap height**. The glasses are centred on the cap height (or on the text block when the descriptor is present). +* Don't rebuild lockups by hand; use the supplied SVGs. + +## 5. Minimum sizes + +| Asset | Digital | Print | +|---|---|---| +| Horizontal logo | 96 px wide | 20 mm wide | +| Horizontal + descriptor | 200 px wide | 40 mm wide | +| Glasses symbol (master) | 24 px wide | 8 mm wide | +| Glasses symbol, below 24 px | use `symbol/svg/small/` 16 px / 32 px hinted versions | — | +| App icon | 32 px; under 48 px use the hinted favicon tiles | 10 mm | + +Below these sizes, switch to the next simpler version: horizontal logo → symbol → hinted favicon. + +## 6. Favicons and web app icons + +Copy everything in `favicon/` to the web root of nerdos.org and paste `favicon/head-snippet.html` into ``: + +```html + + + + + +``` + +* `favicon.svg` is drawn on a 32 px grid and switches automatically: charcoal glasses on light tabs, off-white on + dark tabs (`prefers-color-scheme`). +* `favicon.ico` holds pixel-hinted 16, 32 and 48 px charcoal tiles that read on any tab colour. +* `apple-touch-icon.png` (180 px) is opaque and full-bleed; iOS rounds the corners. +* `icon-192.png` / `icon-512.png` are rounded tiles for the manifest's `any` purpose. +* `icon-maskable-192.png` / `icon-maskable-512.png` are full-bleed, with the glasses inside the central 80% safe circle. + +## 7. ASCII + +See `ascii/README.md`. Frames and lettering use `$1`, the tape uses `$2` (lime). On light terminals switch +`$1` to charcoal and `$2` to Lime deep (`#4A7300`). + +## 8. Don't + +* Don't add eyes, faces, pupils or reflections to the lenses. +* Don't fill the lenses. +* Don't change the tape colour, angle or position, and don't add more tape. +* Don't rotate, skew, outline or add effects (gradients, shadows, glows) to the mark. +* Don't place the colour logo on busy photos or mid-tone backgrounds; use mono or a solid charcoal/off-white panel. +* Don't use the lime as a background behind the full-colour logo. +* Don't typeset "NerdOS" in another font to imitate the wordmark. In running text, write **NerdOS** (capital N, capital O, capital S). + +## Package contents + +``` +logo/ horizontal logo ± descriptor · SVG masters + PNG (512/1024/2048 px wide) +symbol/ glasses symbol · SVG + PNG (128–1024) · small/ hinted 16 & 32 px masters +app-icon/ rounded tile + full-bleed square · dark, light, mono-black, mono-white · SVG + PNG (64–1024) +favicon/ favicon.svg, favicon.ico (16/32/48), apple-touch-icon, 192/512 + maskable, webmanifest, head snippet +ascii/ compact, large, banner · plain + fastfetch placeholder versions · fastfetch config · colour guide +_concepts/ the three explored directions (A Tape Job — selected; B Browline; C Pixel) +``` + +All SVGs are outlined (no live text, no font dependency), use named layers (`symbol` › `frames`, `tape`, +`wordmark`, `descriptor`), and have transparent backgrounds unless they are app icons. +Fonts: Space Grotesk and JetBrains Mono, SIL Open Font License 1.1. diff --git a/assets/brand/_source/README.md b/assets/brand/_source/README.md new file mode 100644 index 00000000..ecdd2424 --- /dev/null +++ b/assets/brand/_source/README.md @@ -0,0 +1,16 @@ +# Regenerating the NerdOS assets + +Everything in this package is generated from `brand.py` (geometry, colours, lockup ratios), so a change +there — tape angle, frame thickness, a colour — flows into every SVG, PNG, favicon and icon. + +```sh +npm i @fontsource/space-grotesk @fontsource/jetbrains-mono # font outlines (SIL OFL) +pip install fonttools brotli uharfbuzz skia-pathops resvg-py pillow +export FONTSOURCE_DIR=node_modules/@fontsource NERDOS_OUT=build/nerdos-brand +python3 export.py # logo / symbol / app-icon / favicon +python3 ascii_export.py # ascii/ (hand-drawn art lives in this file) +``` + +* `geo.py` — rounded rects, boolean ops (Skia path ops), SVG path output +* `glyphs.py` — HarfBuzz shaping + fontTools outlines for the wordmark and descriptor +* The 16/32/48 px favicons are hand-hinted pixel drawings (`symbol_px16`, `symbol_px32`, `tile_hinted`), not scaled masters. diff --git a/assets/brand/_source/ascii_export.py b/assets/brand/_source/ascii_export.py new file mode 100644 index 00000000..21c92bd4 --- /dev/null +++ b/assets/brand/_source/ascii_export.py @@ -0,0 +1,180 @@ +"""NerdOS ASCII logos for fastfetch: plain text + $1/$2 placeholder versions + config + guide.""" +import os +OUT = os.path.join(os.environ.get("NERDOS_OUT", "build/nerdos-brand"), "ascii") +os.makedirs(OUT, exist_ok=True) + + +def build(stub, lens, gap, tape, bridge_rows, stub_rows, stubch="="): + Lw = len(lens[0]) + out = [] + for r, row in enumerate(lens): + s = stubch * stub if r in stub_rows else " " * stub + g = "#" * gap if r in bridge_rows else " " * gap + line = list(s + row + g + row[::-1] + s) + for c in tape.get(r, []): + line[stub + Lw + c] = "/" + out.append("".join(line).rstrip()) + return out + + +LARGE_LENS = [ + " .################. ", + "####################", + "#### ####", + "#### ####", + "####. .####", + "'##################'", + " '##############' ", +] +large = build(2, LARGE_LENS, 6, {0: [4, 5, 6], 1: [3, 4, 5], 2: [2, 3, 4], 3: [1, 2, 3], 4: [0, 1]}, {1, 2}, {1}) + +COMPACT_LENS = [ + " .######. ", + "##########", + "### ###", + " '######' ", +] +compact = build(1, COMPACT_LENS, 4, {0: [3, 4], 1: [2, 3], 2: [1, 2]}, {1}, {1}) + +G = { + "N": ["##. ##", "###. ##", "##'#. ##", "## '#.##", "## '###"], + "e": [" ", ".####.", "##__##", "##''''", "'####'"], + "r": [" ", "##.##", "###''", "## ", "## "], + "d": [" ##", ".#####", "## ##", "## ##", "'#####"], + "O": [".######.", "##' '##", "## ##", "##. .##", "'######'"], + "S": [".######", "##' ", "'#####.", " '##", "######'"], +} + + +def word(text, sp=1, extra=None): + rows = [""] * 5 + for i, ch in enumerate(text): + for r in range(5): + rows[r] += G[ch][r] + ((" " * (sp + (extra or {}).get(i, 0))) if i < len(text) - 1 else "") + return [x.rstrip() for x in rows] + + +wm = word("NerdOS", 1, {3: 1}) +W = max(len(x) for x in large) +desc = "O P I N I O N A T E D S Y S T E M" +banner = large + [""] + [" " * ((W - len(wm[0])) // 2) + x for x in wm] + ["", " " * ((W - len(desc)) // 2) + desc] + + +def placeholders(lines, frame="$1", accent="$2"): + """Wrap tape runs ('/') in $2 and everything else in $1. Each line starts with $1.""" + out = [] + for l in lines: + s, cur = "", None + for ch in l: + want = accent if ch == "/" else frame + if ch != " " and want != cur: + s += want + cur = want + s += ch + out.append(s) + return out + + +files = {"nerdos-compact": compact, "nerdos-large": large, "nerdos-banner": banner} +for name, lines in files.items(): + open(f"{OUT}/{name}.txt", "w").write("\n".join(lines) + "\n") + open(f"{OUT}/{name}.fastfetch.txt", "w").write("\n".join(placeholders(lines)) + "\n") + +config = """// NerdOS fastfetch preset — copy to ~/.config/fastfetch/config.jsonc +// Logo files go in ~/.config/fastfetch/ (or adjust "source"). +{ + "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", + "logo": { + "type": "file", // "file" = $1/$2 colour placeholders are replaced + "source": "~/.config/fastfetch/nerdos-large.fastfetch.txt", + "color": { + "1": "#F3F2EC", // frames + lettering: off-white (use "#1A1B1E" on light terminals) + "2": "#C8FF1F" // tape: electric lime + }, + "padding": { "top": 1, "left": 2, "right": 4 } + }, + "display": { + "separator": " ", + "color": { "keys": "#C8FF1F" } + }, + "modules": [ + "title", "separator", "os", "kernel", "uptime", "packages", "shell", + "wm", "terminal", "cpu", "gpu", "memory", "disk", "break", "colors" + ] +} +""" +open(f"{OUT}/fastfetch-config.jsonc", "w").write(config) + +readme = f"""# NerdOS ASCII logos + +Three sizes, all drawn from the same glasses geometry as the graphic logo +(chunky rounded frames, hinge stubs `==`, and the lime repair tape `///` across the bridge). + +| File | Size | Use | +|---|---|---| +| `nerdos-compact.txt` | {max(map(len, compact))} × {len(compact)} | narrow terminals, `--logo-type small`-style setups, MOTD one-liners | +| `nerdos-large.txt` | {max(map(len, large))} × {len(large)} | default fastfetch logo | +| `nerdos-banner.txt` | {max(map(len, banner))} × {len(banner)} | large logo + wordmark + descriptor (splash, MOTD, installer) | + +Each comes in two flavours: + +* `*.txt` — plain 7-bit ASCII, no colour codes. Use with `"type": "file-raw"` or anywhere else (README, motd, docs). +* `*.fastfetch.txt` — the same art with fastfetch colour placeholders: `$1` = frames and lettering, `$2` = tape. + Use with `"type": "file"` so the placeholders are replaced. + +## Colour guidance + +The tape is the only accent. Everything else stays one colour. + +| Placeholder | Role | Truecolor | 256-colour fallback | 16-colour fallback | +|---|---|---|---|---| +| `$1` | frames, wordmark (dark terminal) | `#F3F2EC` | `38;5;255` | `bright_white` | +| `$1` | frames, wordmark (light terminal) | `#1A1B1E` | `38;5;234` | `black` | +| `$2` | tape (dark terminal) | `#C8FF1F` | `38;5;190` | `bright_green` | +| `$2` | tape (light terminal) | `#4A7300` | `38;5;64` | `green` | + +* On a light terminal background, switch `$1` to charcoal and `$2` to Lime deep: bright lime text + disappears on white. +* If the terminal can't show colour, leave the art uncoloured: the `/` characters still read as tape + against the `#` frames. +* Don't recolour the frames lime, and don't use more than these two colours. + +## fastfetch + +```sh +mkdir -p ~/.config/fastfetch +cp nerdos-*.txt fastfetch-config.jsonc ~/.config/fastfetch/ +mv ~/.config/fastfetch/fastfetch-config.jsonc ~/.config/fastfetch/config.jsonc +fastfetch +``` + +One-off from the command line: + +```sh +fastfetch --logo ~/.config/fastfetch/nerdos-compact.fastfetch.txt --logo-type file \\ + --logo-color-1 '#F3F2EC' --logo-color-2 '#C8FF1F' +``` + +Hex colours (`#RRGGBB`) need a recent fastfetch; on older builds use the 256-colour codes from the table +(e.g. `--logo-color-2 '38;5;190'`). + +## Preview + +nerdos-large.txt +``` +{chr(10).join(large)} +``` + +nerdos-compact.txt +``` +{chr(10).join(compact)} +``` +""" +open(f"{OUT}/README.md", "w").write(readme) + +if __name__ == "__main__": + for n, l in files.items(): + print(n, max(map(len, l)), "x", len(l)) + print("\n".join(l)) + print() + print(open(f"{OUT}/nerdos-compact.fastfetch.txt").read()) diff --git a/assets/brand/_source/brand.py b/assets/brand/_source/brand.py new file mode 100644 index 00000000..b943e20c --- /dev/null +++ b/assets/brand/_source/brand.py @@ -0,0 +1,202 @@ +"""NerdOS identity — master geometry. All paths are pathops.Path in a y-down space.""" +import pathops +from geo import * +from glyphs import font_path, load, shape, metrics +from fontTools.pens.transformPen import TransformPen + +# ---------------------------------------------------------------- palette +INK = "#1A1B1E" # Charcoal +PAPER = "#F3F2EC" # Off-white +LIME = "#C8FF1F" # Electric lime (tape) +GRAPHITE = "#5A5C61" # secondary text on light +FOG = "#A6A8A1" # secondary text on dark +BLACK, WHITE = "#000000", "#FFFFFF" + +# ---------------------------------------------------------------- symbol (master) +# Units: lens 204 x 152, frame 40. Overall symbol ≈ 504 x 152 incl. hinge stubs. +LW, LH, T, G = 204, 152, 40, 56 +TAPE = dict(w=48, h=88, ang=30, r=5, gap=9) + + +def _tape(cx, cy, w, h, ang, r, gap): + t = xform(rrect(cx - w / 2, cy - h / 2, w, h, r), rot(ang, cx, cy)) + g = xform(rrect(cx - w / 2 - gap, cy - h / 2 - gap, w + 2 * gap, h + 2 * gap, r + gap), rot(ang, cx, cy)) + return t, g + + +def symbol(stubs=True): + lens = op(rrect(0, 0, LW, LH, (40, 40, 64, 64)), rrect(T, T, LW - 2 * T, LH - 2 * T, (16, 16, 36, 36)), D) + right = xform(lens, move(LW + G, 0)) + bridge = rrect(LW - 20, 22, G + 40, 42, 0) + parts = [lens, right, bridge] + if stubs: + parts += [rrect(-20, 18, 40, 36, (11, 0, 0, 11)), rrect(2 * LW + G - 20, 18, 40, 36, (0, 11, 11, 0))] + frames = union(*parts) + tape, gap = _tape(LW + G / 2, 43, **TAPE) + return {"frames": op(frames, gap, D), "tape": tape} + + +# ---------------------------------------------------------------- pixel-tuned small symbols +def symbol_px16(): + """Hand-hinted 16x16 grid version (1 unit = 1 px). Glasses span the full width.""" + y0 = 5 + def lens(x): + outer = rrect(x, y0, 7, 6, (1.2, 1.2, 2.2, 2.2), k=0.6) + inner = rrect(x + 2, y0 + 2, 3, 2, 0) + return op(outer, inner, D) + frames = union(lens(0), lens(9), rrect(6, y0 + 1, 4, 2, 0)) + # tape: 2-px band stepping one column per row, '/' direction + cells = [(9, y0 - 1), (10, y0 - 1), (8, y0), (9, y0), (8, y0 + 1), (9, y0 + 1), (7, y0 + 2), (8, y0 + 2), (6, y0 + 3), (7, y0 + 3)] + tape = union(*[rrect(cx, cy, 1, 1, 0) for cx, cy in cells]) + return {"frames": op(frames, tape, D), "tape": tape, "box": (0, 0, 16, 16)} + + +def symbol_px16_tile(): + """16x16 app-tile favicon: charcoal tile, light frames 1px narrower lens openings.""" + y0 = 5 + def lens(x): + outer = rrect(x, y0, 6, 6, (1, 1, 2, 2), k=0.6) + inner = rrect(x + 2, y0 + 2, 2, 2, 0) + return op(outer, inner, D) + frames = union(lens(1), lens(9), rrect(6, y0 + 1, 4, 2, 0)) + cells = [(9, y0 - 1), (10, y0 - 1), (8, y0), (9, y0), (8, y0 + 1), (9, y0 + 1), (7, y0 + 2), (8, y0 + 2), (6, y0 + 3), (7, y0 + 3)] + tape = union(*[rrect(cx, cy, 1, 1, 0) for cx, cy in cells]) + return {"frames": op(frames, tape, D), "tape": tape, "tile": rrect(0, 0, 16, 16, 3.5), "box": (0, 0, 16, 16)} + + +def symbol_px32(): + """Hinted 32x32 version (1 unit = 1 px): frame 4 px, opening 6x4, 3-px tape with 1-px knockout.""" + y0 = 9 + def lens(x): + outer = rrect(x, y0, 14, 12, (3, 3, 5, 5)) + inner = rrect(x + 4, y0 + 4, 6, 4, (1, 1, 1.5, 1.5)) + return op(outer, inner, D) + frames = union(lens(0), lens(18), rrect(12, y0 + 2, 8, 4, 0)) + # 3-px band, slope 2 rows : 1 col, pixel aligned + rows = [(y0 - 2, 18), (y0 - 1, 18), (y0, 17), (y0 + 1, 17), (y0 + 2, 16), (y0 + 3, 16), (y0 + 4, 15), (y0 + 5, 15), (y0 + 6, 14), (y0 + 7, 14)] + tape = union(*[rrect(x, y, 3, 1, 0) for y, x in rows]) + gap = union(*[rrect(x - 1, y, 5, 1, 0) for y, x in rows], rrect(17, y0 - 3, 4, 1, 0), rrect(13, y0 + 8, 4, 1, 0)) + return {"frames": op(frames, gap, D), "tape": tape, "box": (0, 0, 32, 32)} + + +def tile_hinted(n): + """Pixel-hinted app-tile favicons (charcoal tile, light frames, lime tape, 1-px knockout).""" + if n == 16: + return symbol_px16_tile() + if n == 32: + lw, lh, x, gap, y0, f = 11, 10, 2, 6, 11, 3 + ro, ri, by, bh, r = (2, 2, 4, 4), (0.8, 0.8, 1.5, 1.5), 1, 3, 7.2 + tw = 2 + rows = [(9, 17), (10, 17), (11, 16), (12, 16), (13, 15), (14, 15), (15, 14), (16, 14)] + else: # 48 + lw, lh, x, gap, y0, f = 16, 14, 4, 8, 17, 4 + ro, ri, by, bh, r = (3, 3, 6, 6), (1.2, 1.2, 2.5, 2.5), 2, 4, 10.8 + tw = 3 + rows = [(14, 25), (15, 25), (16, 24), (17, 24), (18, 23), (19, 23), (20, 22), (21, 22), (22, 21), (23, 21), (24, 20)] + def lens(xx): + return op(rrect(xx, y0, lw, lh, ro), rrect(xx + f, y0 + f, lw - 2 * f, lh - 2 * f, ri), D) + xr = x + lw + gap + frames = union(lens(x), lens(xr), rrect(x + lw - 1, y0 + by, gap + 2, bh, 0)) + tape = union(*[rrect(xx, yy, tw, 1, 0) for yy, xx in rows]) + (ty0, tx0), (ty1, tx1) = rows[0], rows[-1] + knock = union(*[rrect(xx - 1, yy, tw + 2, 1, 0) for yy, xx in rows], + rrect(tx0 - 1, ty0 - 1, tw + 2, 1, 0), rrect(tx1 - 1, ty1 + 1, tw + 2, 1, 0)) + return {"frames": op(frames, knock, D), "tape": tape, "tile": rrect(0, 0, n, n, r), "box": (0, 0, n, n)} + + +def symbol_tile(size, inset=0.14): + """App-icon composition on a square of side `size`: returns frames/tape/tile paths.""" + s0 = symbol() + x0, y0, x1, y1 = bounds(union(s0["frames"], s0["tape"])) + fx0, fy0, fx1, fy1 = bounds(s0["frames"]) + w = size * (1 - 2 * inset) + k = w / (fx1 - fx0) + m = (k, 0, 0, k, size * inset - fx0 * k, size / 2 - (fy0 + fy1) / 2 * k) + return {"frames": xform(s0["frames"], m), "tape": xform(s0["tape"], m), + "tile": rrect(0, 0, size, size, size * 0.225), "square": rrect(0, 0, size, size, 0), "box": (0, 0, size, size)} + + +# ---------------------------------------------------------------- type +WORD_FONT = ("space-grotesk", 700) +DESC_FONT = ("jetbrains-mono", 700) +EMBOLDEN = 24 # per-mille of em: custom heavier cut of Space Grotesk Bold + + +def _outline(fam, wt, text, size, tracking=0, adjust=None): + p_ = font_path(fam, wt) + tt, _ = load(p_) + gs = tt.getGlyphSet() + upm = tt["head"].unitsPerEm + s = size / upm + out = pathops.Path() + pen = out.getPen() + cx = 0 + gl = shape(p_, text) + for i, (name, adv, xo, yo) in enumerate(gl): + gs[name].draw(TransformPen(pen, (s, 0, 0, -s, (cx + xo) * s, -yo * s))) + cx += adv + (tracking * upm / 1000 if i < len(gl) - 1 else 0) + if adjust and i in adjust: + cx += adjust[i] + return simplify(out), cx * s, metrics(p_)["cap"] * s + + +def wordmark(size=200): + """'NerdOS' — Space Grotesk Bold, emboldened + optically spaced. Baseline y=0.""" + # per-pair spacing in font units (N-e, e-r, r-d, d-O, O-S) + adj = {0: 6, 1: 6, 2: -10, 3: 22, 4: 10} + p, w, cap = _outline(*WORD_FONT, "NerdOS", size, tracking=0, adjust=adj) + sw = EMBOLDEN * size / 1000 + st = pathops.Path(p) + st.stroke(sw, pathops.LineCap.BUTT_CAP, pathops.LineJoin.MITER_JOIN, 4) + p = simplify(union(p, st)) + # restore cap height / baseline so weight is added mainly to stems + x0, y0, x1, y1 = bounds(p) + k = cap / (cap + sw) + p = xform(p, (1, 0, 0, k, 0, -k * sw / 2)) + x0, y0, x1, y1 = bounds(p) + p = xform(p, move(-x0, 0)) + return p, x1 - x0, cap + + +def descriptor(width): + p, w, cap = _outline(*DESC_FONT, "OPINIONATED SYSTEM", 200, tracking=90) + x0, y0, x1, y1 = bounds(p) + s = width / (x1 - x0) + p = xform(p, (s, 0, 0, s, -x0 * s, 0)) + return p, cap * s + + +# ---------------------------------------------------------------- lockups +SYM_RATIO = 1.22 # symbol height / cap height +GAP_RATIO = 0.46 # symbol→wordmark gap / cap height + + +def lockup(desc=False): + """Returns dict of paths: frames, tape, word, (desc). Origin = top-left of art box.""" + wp, ww, cap = wordmark(200) + s0 = symbol() + x0, y0, x1, y1 = bounds(union(s0["frames"], s0["tape"])) + fx0, fy0, fx1, fy1 = bounds(s0["frames"]) + # scale symbol by FRAME height (tape overhang excluded) so lens frame aligns optically to cap height + sh = cap * SYM_RATIO + s = sh / (fy1 - fy0) + gap = cap * GAP_RATIO + out = {} + if desc: + dp, dcap = descriptor(ww) + dgap = cap * 0.36 + block_top, block_bot = -cap, dgap + dcap + out["desc"] = xform(dp, move(0, dgap + dcap)) + else: + block_top, block_bot = -cap, 0 + cy = (block_top + block_bot) / 2 + m = (s, 0, 0, s, -fx0 * s, cy - (fy0 + fy1) / 2 * s) + out["frames"] = xform(s0["frames"], m) + out["tape"] = xform(s0["tape"], m) + symw = (fx1 - fx0) * s + out["word"] = xform(wp, move(symw + gap, 0)) + if desc: + out["desc"] = xform(out["desc"], move(symw + gap, 0)) + out["X"] = T * s # clear-space unit = lens frame thickness at this scale + out["cap"] = cap + return out diff --git a/assets/brand/_source/export.py b/assets/brand/_source/export.py new file mode 100644 index 00000000..1573707e --- /dev/null +++ b/assets/brand/_source/export.py @@ -0,0 +1,230 @@ +"""Build the complete NerdOS brand package into out/nerdos-brand/.""" +import io, os, json, shutil, struct +import resvg_py +from PIL import Image +from brand import * + +OUT = os.environ.get("NERDOS_OUT", "build/nerdos-brand") +shutil.rmtree(OUT, ignore_errors=True) + + +def mk(*p): + path = os.path.join(OUT, *p) + os.makedirs(os.path.dirname(path), exist_ok=True) + return path + + +def png(svg, w=None, h=None): + return Image.open(io.BytesIO(bytes(resvg_py.svg_to_bytes(svg_string=svg, width=w, height=h)))).convert("RGBA") + + +def r2(v): + s = f"{v:.2f}".rstrip("0").rstrip(".") + return "0" if s in ("-0", "") else s + + +def normalize(paths, box=None): + """Translate a dict of paths so the art box starts at 0,0. Returns (paths, w, h).""" + keys = [k for k, v in paths.items() if hasattr(v, "draw")] + x0, y0, x1, y1 = box or bounds(union(*[paths[k] for k in keys])) + out = {k: xform(paths[k], move(-x0, -y0)) for k in keys} + return out, x1 - x0, y1 - y0 + + +def svg_file(layers, w, h, title, desc=None, extra_head=""): + """layers: list of (id, fill, path|[(id, path)])""" + body = [] + for lid, fill, p in layers: + if isinstance(p, list): + inner = "".join(f'' for i, pp in p) + body.append(f' {inner}') + else: + body.append(f' ') + dsc = f"{desc}" if desc else "" + return (f'\n {title}{dsc}\n{extra_head}' + "\n".join(body) + "\n\n") + + +VARIANTS = { + # name: (ink, tape) + "on-light": (INK, LIME), + "on-dark": (PAPER, LIME), + "mono-black": (BLACK, BLACK), + "mono-white": (WHITE, WHITE), +} + +manifest = [] + + +def save_svg(rel, text): + p = mk(*rel.split("/")) + open(p, "w").write(text) + manifest.append(rel) + return p + + +def save_png(rel, im): + p = mk(*rel.split("/")) + im.save(p, optimize=True) + manifest.append(rel) + return p + + +# ------------------------------------------------------------------ horizontal logos +for desc in (False, True): + L = lockup(desc) + keys = ["frames", "tape", "word"] + (["desc"] if desc else []) + P, w, h = normalize({k: L[k] for k in keys}) + stem = "nerdos-logo-descriptor" if desc else "nerdos-logo" + for vname, (ink, tape) in VARIANTS.items(): + layers = [("symbol", None, None)] + layers = [("frames", ink, P["frames"]), ("tape", tape, P["tape"]), ("wordmark", ink, P["word"])] + if desc: + layers.append(("descriptor", ink, P["desc"])) + # group the symbol for easy editing + text = svg_file([], w, h, "NerdOS — Opinionated System" if desc else "NerdOS") + sym = (f' \n \n' + f' \n \n' + f' \n') + if desc: + sym += f' \n' + text = text.replace("\n", sym + "\n") + save_svg(f"logo/svg/{stem}-{vname}.svg", text) + for W in (512, 1024, 2048): + save_png(f"logo/png/{stem}-{vname}-{W}.png", png(text, w=W)) + +# ------------------------------------------------------------------ symbol +S = symbol() +P, w, h = normalize(S) +for vname, (ink, tape) in VARIANTS.items(): + text = svg_file([], w, h, "NerdOS glasses symbol") + text = text.replace("\n", f' \n \n' + f' \n \n\n') + save_svg(f"symbol/svg/nerdos-symbol-{vname}.svg", text) + for W in (128, 256, 512, 1024): + save_png(f"symbol/png/nerdos-symbol-{vname}-{W}.png", png(text, w=W)) + +# small-size symbol masters (pixel-hinted, for 16 / 32 px use) +for n, fn in ((16, symbol_px16), (32, symbol_px32)): + sp = fn() + for vname, (ink, tape) in (("on-light", (INK, LIME)), ("on-dark", (PAPER, LIME))): + text = svg_file([("frames", ink, sp["frames"]), ("tape", tape, sp["tape"])], n, n, + f"NerdOS glasses symbol, {n}px-hinted") + save_svg(f"symbol/svg/small/nerdos-symbol-{n}px-{vname}.svg", text) + save_png(f"symbol/png/small/nerdos-symbol-{n}px-{vname}.png", png(text, w=n)) + +# ------------------------------------------------------------------ app icons +APP = { + "dark": (INK, PAPER, LIME), + "light": (PAPER, INK, LIME), + "mono-black": (BLACK, WHITE, WHITE), + "mono-white": (WHITE, BLACK, BLACK), +} + + +def app_svg(bg, fg, tape, size=1024, inset=0.13, shape="tile", title="NerdOS app icon"): + sp = symbol_tile(size, inset) + base = sp["tile"] if shape == "tile" else sp["square"] + return svg_file([("background", bg, base), ("frames", fg, sp["frames"]), ("tape", tape, sp["tape"])], + size, size, title) + + +for vname, (bg, fg, tape) in APP.items(): + t = app_svg(bg, fg, tape) + save_svg(f"app-icon/svg/nerdos-app-icon-{vname}.svg", t) + for W in (64, 128, 256, 512, 1024): + save_png(f"app-icon/png/nerdos-app-icon-{vname}-{W}.png", png(t, w=W)) + # full-bleed square master (for platforms that apply their own mask, e.g. iOS / Android adaptive) + tq = app_svg(bg, fg, tape, inset=0.13, shape="square", title="NerdOS app icon (full-bleed)") + save_svg(f"app-icon/svg/nerdos-app-icon-{vname}-square.svg", tq) + save_png(f"app-icon/png/nerdos-app-icon-{vname}-square-1024.png", png(tq, w=1024)) + +# ------------------------------------------------------------------ favicon package +FAV = "favicon" +# favicon.svg — adaptive: charcoal on light tabs, off-white on dark tabs (32-grid hinted geometry) +p32 = symbol_px32() +fav_svg = (f'\n' + f' \n' + f' \n' + f' \n\n') +save_svg(f"{FAV}/favicon.svg", fav_svg) + +# hinted tiles for ICO + PNG favicons +tiles = {} +for n in (16, 32, 48): + sp = tile_hinted(n) + t = svg_file([("background", INK, sp["tile"]), ("frames", PAPER, sp["frames"]), ("tape", LIME, sp["tape"])], + n, n, f"NerdOS favicon {n}px") + tiles[n] = png(t, w=n) + save_svg(f"{FAV}/source/favicon-tile-{n}.svg", t) + save_png(f"{FAV}/favicon-{n}x{n}.png", tiles[n]) + + +def write_ico(path, images): + """Multi-size ICO with PNG-compressed entries (supported by all current browsers and Windows Vista+).""" + blobs = [] + for im in images: + b = io.BytesIO() + im.save(b, format="PNG", optimize=True) + blobs.append((im.size[0], b.getvalue())) + header = struct.pack(" + + + + + +""") +manifest.append(f"{FAV}/head-snippet.html") + +print(len(manifest), "files") +json.dump(manifest, open(os.path.join(OUT, "manifest.json"), "w"), indent=1) diff --git a/assets/brand/_source/geo.py b/assets/brand/_source/geo.py new file mode 100644 index 00000000..3601f877 --- /dev/null +++ b/assets/brand/_source/geo.py @@ -0,0 +1,98 @@ +"""Vector geometry helpers: rounded rects, booleans (skia-pathops), transforms, SVG output.""" +import math +import pathops +from fontTools.pens.svgPathPen import SVGPathPen +from fontTools.pens.transformPen import TransformPen +from fontTools.pens.boundsPen import ControlBoundsPen + +K = 0.5522847498 +U, D, I = pathops.PathOp.UNION, pathops.PathOp.DIFFERENCE, pathops.PathOp.INTERSECTION + + +def _r4(r): + return (r, r, r, r) if isinstance(r, (int, float)) else tuple(r) + + +def rrect(x, y, w, h, r=0, k=K): + """Rounded rect, y-down. r = radius or (tl, tr, br, bl).""" + tl, tr, br, bl = _r4(r) + p = pathops.Path() + pen = p.getPen() + pen.moveTo((x + tl, y)) + pen.lineTo((x + w - tr, y)) + if tr: + pen.curveTo((x + w - tr + k * tr, y), (x + w, y + tr - k * tr), (x + w, y + tr)) + pen.lineTo((x + w, y + h - br)) + if br: + pen.curveTo((x + w, y + h - br + k * br), (x + w - br + k * br, y + h), (x + w - br, y + h)) + pen.lineTo((x + bl, y + h)) + if bl: + pen.curveTo((x + bl - k * bl, y + h), (x, y + h - bl + k * bl), (x, y + h - bl)) + pen.lineTo((x, y + tl)) + if tl: + pen.curveTo((x, y + tl - k * tl), (x + tl - k * tl, y), (x + tl, y)) + pen.closePath() + return p + + +def poly(pts): + p = pathops.Path() + pen = p.getPen() + pen.moveTo(pts[0]) + for q in pts[1:]: + pen.lineTo(q) + pen.closePath() + return p + + +def xform(path, m): + out = pathops.Path() + path.draw(TransformPen(out.getPen(), m)) + return out + + +def rot(deg, cx=0, cy=0): + a = math.radians(deg) + c, s = math.cos(a), math.sin(a) + # rotate about (cx,cy), y-down so positive = clockwise visually + return (c, s, -s, c, cx - c * cx + s * cy, cy - s * cx - c * cy) + + +def move(dx, dy, s=1.0): + return (s, 0, 0, s, dx, dy) + + +def op(a, b, kind): + return pathops.op(a, b, kind) + + +def union(*ps): + out = pathops.Path() + for p in ps: + out = pathops.op(out, p, U) + return out + + +def simplify(p): + return pathops.simplify(p) + + +def bounds(p): + return p.bounds # xmin, ymin, xmax, ymax + + +def _ntos(v): + s = f"{v:.2f}".rstrip("0").rstrip(".") + return "0" if s in ("-0", "") else s + + +def d(path): + pen = SVGPathPen(None, ntos=_ntos) + path.draw(pen) + return pen.getCommands() + + +def from_pen_drawing(draw_fn): + p = pathops.Path() + draw_fn(p.getPen()) + return p diff --git a/assets/brand/_source/glyphs.py b/assets/brand/_source/glyphs.py new file mode 100644 index 00000000..b571f5e0 --- /dev/null +++ b/assets/brand/_source/glyphs.py @@ -0,0 +1,85 @@ +"""Text → outlined SVG path helpers (harfbuzz shaping + fontTools outlines).""" +import io, functools +from fontTools.ttLib import TTFont +from fontTools.pens.svgPathPen import SVGPathPen +from fontTools.pens.transformPen import TransformPen +from fontTools.pens.recordingPen import RecordingPen +import uharfbuzz as hb + +import os +FS = os.environ.get("FONTSOURCE_DIR", "node_modules/@fontsource") + + +def font_path(family, weight): + return f"{FS}/{family}/files/{family}-latin-{weight}-normal.woff2" + + +@functools.lru_cache(None) +def load(path): + f = TTFont(path) + f.flavor = None + buf = io.BytesIO() + f.save(buf) + data = buf.getvalue() + tt = TTFont(io.BytesIO(data)) + face = hb.Face(hb.Blob(data)) + hbfont = hb.Font(face) + return tt, hbfont + + +def shape(path, text, features=None): + tt, hbfont = load(path) + buf = hb.Buffer() + buf.add_str(text) + buf.guess_segment_properties() + hb.shape(hbfont, buf, features or {"kern": True, "liga": False}) + order = tt.getGlyphOrder() + out = [] + for info, pos in zip(buf.glyph_infos, buf.glyph_positions): + out.append((order[info.codepoint], pos.x_advance, pos.x_offset, pos.y_offset)) + return out + + +def metrics(path): + tt, _ = load(path) + os2 = tt["OS/2"] + return { + "upm": tt["head"].unitsPerEm, + "cap": getattr(os2, "sCapHeight", 0) or 700, + "xh": getattr(os2, "sxHeight", 0) or 500, + } + + +def text_path(path, text, size, x=0.0, y=0.0, tracking=0.0, pair_adjust=None): + """Return (svg_d, width). y is the baseline. tracking in em/1000. + pair_adjust: dict index->extra units (font units) added after glyph i.""" + tt, _ = load(path) + gs = tt.getGlyphSet() + upm = tt["head"].unitsPerEm + s = size / upm + pen = SVGPathPen(gs) + cx = 0.0 + glyphs = shape(path, text) + for i, (name, adv, xo, yo) in enumerate(glyphs): + tp = TransformPen(pen, (s, 0, 0, -s, x + (cx + xo) * s, y - yo * s)) + gs[name].draw(tp) + cx += adv + if i < len(glyphs) - 1: + cx += tracking * upm / 1000.0 + if pair_adjust and i in pair_adjust: + cx += pair_adjust[i] + return pen.getCommands(), cx * s + + +def glyph_record(path, text): + """Per-glyph RecordingPens in font units with x positions (for custom assembly).""" + tt, _ = load(path) + gs = tt.getGlyphSet() + res = [] + cx = 0 + for name, adv, xo, yo in shape(path, text): + rp = RecordingPen() + gs[name].draw(rp) + res.append({"name": name, "x": cx + xo, "adv": adv, "rec": rp}) + cx += adv + return res diff --git a/assets/brand/app-icon/png/nerdos-app-icon-dark-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-dark-1024.png new file mode 100644 index 00000000..7f3e4c76 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-dark-1024.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-dark-128.png b/assets/brand/app-icon/png/nerdos-app-icon-dark-128.png new file mode 100644 index 00000000..91ab5beb Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-dark-128.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-dark-256.png b/assets/brand/app-icon/png/nerdos-app-icon-dark-256.png new file mode 100644 index 00000000..53549423 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-dark-256.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-dark-512.png b/assets/brand/app-icon/png/nerdos-app-icon-dark-512.png new file mode 100644 index 00000000..aadabd5d Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-dark-512.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-dark-64.png b/assets/brand/app-icon/png/nerdos-app-icon-dark-64.png new file mode 100644 index 00000000..809ff747 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-dark-64.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-dark-square-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-dark-square-1024.png new file mode 100644 index 00000000..aa8af8c6 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-dark-square-1024.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-light-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-light-1024.png new file mode 100644 index 00000000..52aba8dc Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-light-1024.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-light-128.png b/assets/brand/app-icon/png/nerdos-app-icon-light-128.png new file mode 100644 index 00000000..953cc4f9 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-light-128.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-light-256.png b/assets/brand/app-icon/png/nerdos-app-icon-light-256.png new file mode 100644 index 00000000..f5822a84 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-light-256.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-light-512.png b/assets/brand/app-icon/png/nerdos-app-icon-light-512.png new file mode 100644 index 00000000..d46bd885 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-light-512.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-light-64.png b/assets/brand/app-icon/png/nerdos-app-icon-light-64.png new file mode 100644 index 00000000..85f6cb26 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-light-64.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-light-square-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-light-square-1024.png new file mode 100644 index 00000000..52bf0404 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-light-square-1024.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-black-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-1024.png new file mode 100644 index 00000000..a584cbd8 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-1024.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-black-128.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-128.png new file mode 100644 index 00000000..e20b42a6 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-128.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-black-256.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-256.png new file mode 100644 index 00000000..a2a012b0 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-256.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-black-512.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-512.png new file mode 100644 index 00000000..31accf31 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-512.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-black-64.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-64.png new file mode 100644 index 00000000..df7392d8 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-64.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-black-square-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-square-1024.png new file mode 100644 index 00000000..b349a284 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-black-square-1024.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-white-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-1024.png new file mode 100644 index 00000000..120f026f Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-1024.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-white-128.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-128.png new file mode 100644 index 00000000..b8fdb804 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-128.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-white-256.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-256.png new file mode 100644 index 00000000..baa15faa Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-256.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-white-512.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-512.png new file mode 100644 index 00000000..032841d8 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-512.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-white-64.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-64.png new file mode 100644 index 00000000..92b23be1 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-64.png differ diff --git a/assets/brand/app-icon/png/nerdos-app-icon-mono-white-square-1024.png b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-square-1024.png new file mode 100644 index 00000000..b3b891f6 Binary files /dev/null and b/assets/brand/app-icon/png/nerdos-app-icon-mono-white-square-1024.png differ diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-dark-square.svg b/assets/brand/app-icon/svg/nerdos-app-icon-dark-square.svg new file mode 100644 index 00000000..5c60bec1 --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-dark-square.svg @@ -0,0 +1,6 @@ + + NerdOS app icon (full-bleed) + + + + diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-dark.svg b/assets/brand/app-icon/svg/nerdos-app-icon-dark.svg new file mode 100644 index 00000000..5001809a --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-dark.svg @@ -0,0 +1,6 @@ + + NerdOS app icon + + + + diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-light-square.svg b/assets/brand/app-icon/svg/nerdos-app-icon-light-square.svg new file mode 100644 index 00000000..fd5b1c4b --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-light-square.svg @@ -0,0 +1,6 @@ + + NerdOS app icon (full-bleed) + + + + diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-light.svg b/assets/brand/app-icon/svg/nerdos-app-icon-light.svg new file mode 100644 index 00000000..47de3df8 --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-light.svg @@ -0,0 +1,6 @@ + + NerdOS app icon + + + + diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-mono-black-square.svg b/assets/brand/app-icon/svg/nerdos-app-icon-mono-black-square.svg new file mode 100644 index 00000000..85a2d1b7 --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-mono-black-square.svg @@ -0,0 +1,6 @@ + + NerdOS app icon (full-bleed) + + + + diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-mono-black.svg b/assets/brand/app-icon/svg/nerdos-app-icon-mono-black.svg new file mode 100644 index 00000000..8d104c1b --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-mono-black.svg @@ -0,0 +1,6 @@ + + NerdOS app icon + + + + diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-mono-white-square.svg b/assets/brand/app-icon/svg/nerdos-app-icon-mono-white-square.svg new file mode 100644 index 00000000..3a98fcb3 --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-mono-white-square.svg @@ -0,0 +1,6 @@ + + NerdOS app icon (full-bleed) + + + + diff --git a/assets/brand/app-icon/svg/nerdos-app-icon-mono-white.svg b/assets/brand/app-icon/svg/nerdos-app-icon-mono-white.svg new file mode 100644 index 00000000..9b2835fd --- /dev/null +++ b/assets/brand/app-icon/svg/nerdos-app-icon-mono-white.svg @@ -0,0 +1,6 @@ + + NerdOS app icon + + + + diff --git a/assets/brand/ascii/README.md b/assets/brand/ascii/README.md new file mode 100644 index 00000000..983680e4 --- /dev/null +++ b/assets/brand/ascii/README.md @@ -0,0 +1,73 @@ +# NerdOS ASCII logos + +Three sizes, all drawn from the same glasses geometry as the graphic logo +(chunky rounded frames, hinge stubs `==`, and the lime repair tape `///` across the bridge). + +| File | Size | Use | +|---|---|---| +| `nerdos-compact.txt` | 26 × 4 | narrow terminals, `--logo-type small`-style setups, MOTD one-liners | +| `nerdos-large.txt` | 50 × 7 | default fastfetch logo | +| `nerdos-banner.txt` | 50 × 15 | large logo + wordmark + descriptor (splash, MOTD, installer) | + +Each comes in two flavours: + +* `*.txt` — plain 7-bit ASCII, no colour codes. Use with `"type": "file-raw"` or anywhere else (README, motd, docs). +* `*.fastfetch.txt` — the same art with fastfetch colour placeholders: `$1` = frames and lettering, `$2` = tape. + Use with `"type": "file"` so the placeholders are replaced. + +## Colour guidance + +The tape is the only accent. Everything else stays one colour. + +| Placeholder | Role | Truecolor | 256-colour fallback | 16-colour fallback | +|---|---|---|---|---| +| `$1` | frames, wordmark (dark terminal) | `#F3F2EC` | `38;5;255` | `bright_white` | +| `$1` | frames, wordmark (light terminal) | `#1A1B1E` | `38;5;234` | `black` | +| `$2` | tape (dark terminal) | `#C8FF1F` | `38;5;190` | `bright_green` | +| `$2` | tape (light terminal) | `#4A7300` | `38;5;64` | `green` | + +* On a light terminal background, switch `$1` to charcoal and `$2` to Lime deep: bright lime text + disappears on white. +* If the terminal can't show colour, leave the art uncoloured: the `/` characters still read as tape + against the `#` frames. +* Don't recolour the frames lime, and don't use more than these two colours. + +## fastfetch + +```sh +mkdir -p ~/.config/fastfetch +cp nerdos-*.txt fastfetch-config.jsonc ~/.config/fastfetch/ +mv ~/.config/fastfetch/fastfetch-config.jsonc ~/.config/fastfetch/config.jsonc +fastfetch +``` + +One-off from the command line: + +```sh +fastfetch --logo ~/.config/fastfetch/nerdos-compact.fastfetch.txt --logo-type file \ + --logo-color-1 '#F3F2EC' --logo-color-2 '#C8FF1F' +``` + +Hex colours (`#RRGGBB`) need a recent fastfetch; on older builds use the 256-colour codes from the table +(e.g. `--logo-color-2 '38;5;190'`). + +## Preview + +nerdos-large.txt +``` + .################. ///.################. +==#######################///####################== + #### ######///##### #### + #### #### /// #### #### + ####. .####// ####. .#### + '##################' '##################' + '##############' '##############' +``` + +nerdos-compact.txt +``` + .######. //.######. +=############//##########= + ### ### // ### ### + '######' '######' +``` diff --git a/assets/brand/ascii/fastfetch-config.jsonc b/assets/brand/ascii/fastfetch-config.jsonc new file mode 100644 index 00000000..c0f99603 --- /dev/null +++ b/assets/brand/ascii/fastfetch-config.jsonc @@ -0,0 +1,22 @@ +// NerdOS fastfetch preset — copy to ~/.config/fastfetch/config.jsonc +// Logo files go in ~/.config/fastfetch/ (or adjust "source"). +{ + "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", + "logo": { + "type": "file", // "file" = $1/$2 colour placeholders are replaced + "source": "~/.config/fastfetch/nerdos-large.fastfetch.txt", + "color": { + "1": "#F3F2EC", // frames + lettering: off-white (use "#1A1B1E" on light terminals) + "2": "#C8FF1F" // tape: electric lime + }, + "padding": { "top": 1, "left": 2, "right": 4 } + }, + "display": { + "separator": " ", + "color": { "keys": "#C8FF1F" } + }, + "modules": [ + "title", "separator", "os", "kernel", "uptime", "packages", "shell", + "wm", "terminal", "cpu", "gpu", "memory", "disk", "break", "colors" + ] +} diff --git a/assets/brand/ascii/nerdos-banner.fastfetch.txt b/assets/brand/ascii/nerdos-banner.fastfetch.txt new file mode 100644 index 00000000..352d170a --- /dev/null +++ b/assets/brand/ascii/nerdos-banner.fastfetch.txt @@ -0,0 +1,15 @@ + $1.################. $2///$1.################. +$1==#######################$2///$1####################== + $1#### ######$2///$1##### #### + $1#### #### $2/// $1#### #### + $1####. .####$2// $1####. .#### + $1'##################' '##################' + $1'##############' '##############' + + $1##. ## ## .######. .###### + $1###. ## .####. ##.## .##### ##' '## ##' + $1##'#. ## ##__## ###'' ## ## ## ## '#####. + $1## '#.## ##'''' ## ## ## ##. .## '## + $1## '### '####' ## '##### '######' ######' + + $1O P I N I O N A T E D S Y S T E M diff --git a/assets/brand/ascii/nerdos-banner.txt b/assets/brand/ascii/nerdos-banner.txt new file mode 100644 index 00000000..a859099d --- /dev/null +++ b/assets/brand/ascii/nerdos-banner.txt @@ -0,0 +1,15 @@ + .################. ///.################. +==#######################///####################== + #### ######///##### #### + #### #### /// #### #### + ####. .####// ####. .#### + '##################' '##################' + '##############' '##############' + + ##. ## ## .######. .###### + ###. ## .####. ##.## .##### ##' '## ##' + ##'#. ## ##__## ###'' ## ## ## ## '#####. + ## '#.## ##'''' ## ## ## ##. .## '## + ## '### '####' ## '##### '######' ######' + + O P I N I O N A T E D S Y S T E M diff --git a/assets/brand/ascii/nerdos-compact.fastfetch.txt b/assets/brand/ascii/nerdos-compact.fastfetch.txt new file mode 100644 index 00000000..b0a58fb3 --- /dev/null +++ b/assets/brand/ascii/nerdos-compact.fastfetch.txt @@ -0,0 +1,4 @@ + $1.######. $2//$1.######. +$1=############$2//$1##########= + $1### ### $2// $1### ### + $1'######' '######' diff --git a/assets/brand/ascii/nerdos-compact.txt b/assets/brand/ascii/nerdos-compact.txt new file mode 100644 index 00000000..6b6bedd9 --- /dev/null +++ b/assets/brand/ascii/nerdos-compact.txt @@ -0,0 +1,4 @@ + .######. //.######. +=############//##########= + ### ### // ### ### + '######' '######' diff --git a/assets/brand/ascii/nerdos-large.fastfetch.txt b/assets/brand/ascii/nerdos-large.fastfetch.txt new file mode 100644 index 00000000..a1643f34 --- /dev/null +++ b/assets/brand/ascii/nerdos-large.fastfetch.txt @@ -0,0 +1,7 @@ + $1.################. $2///$1.################. +$1==#######################$2///$1####################== + $1#### ######$2///$1##### #### + $1#### #### $2/// $1#### #### + $1####. .####$2// $1####. .#### + $1'##################' '##################' + $1'##############' '##############' diff --git a/assets/brand/ascii/nerdos-large.txt b/assets/brand/ascii/nerdos-large.txt new file mode 100644 index 00000000..ccdca309 --- /dev/null +++ b/assets/brand/ascii/nerdos-large.txt @@ -0,0 +1,7 @@ + .################. ///.################. +==#######################///####################== + #### ######///##### #### + #### #### /// #### #### + ####. .####// ####. .#### + '##################' '##################' + '##############' '##############' diff --git a/assets/brand/favicon/apple-touch-icon.png b/assets/brand/favicon/apple-touch-icon.png new file mode 100644 index 00000000..d0ef98ef Binary files /dev/null and b/assets/brand/favicon/apple-touch-icon.png differ diff --git a/assets/brand/favicon/extras/favicon-transparent-on-dark-16x16.png b/assets/brand/favicon/extras/favicon-transparent-on-dark-16x16.png new file mode 100644 index 00000000..03f52de3 Binary files /dev/null and b/assets/brand/favicon/extras/favicon-transparent-on-dark-16x16.png differ diff --git a/assets/brand/favicon/extras/favicon-transparent-on-dark-32x32.png b/assets/brand/favicon/extras/favicon-transparent-on-dark-32x32.png new file mode 100644 index 00000000..b372344f Binary files /dev/null and b/assets/brand/favicon/extras/favicon-transparent-on-dark-32x32.png differ diff --git a/assets/brand/favicon/extras/favicon-transparent-on-light-16x16.png b/assets/brand/favicon/extras/favicon-transparent-on-light-16x16.png new file mode 100644 index 00000000..5b1eb02c Binary files /dev/null and b/assets/brand/favicon/extras/favicon-transparent-on-light-16x16.png differ diff --git a/assets/brand/favicon/extras/favicon-transparent-on-light-32x32.png b/assets/brand/favicon/extras/favicon-transparent-on-light-32x32.png new file mode 100644 index 00000000..c94205b1 Binary files /dev/null and b/assets/brand/favicon/extras/favicon-transparent-on-light-32x32.png differ diff --git a/assets/brand/favicon/favicon-16x16.png b/assets/brand/favicon/favicon-16x16.png new file mode 100644 index 00000000..bd72eefa Binary files /dev/null and b/assets/brand/favicon/favicon-16x16.png differ diff --git a/assets/brand/favicon/favicon-32x32.png b/assets/brand/favicon/favicon-32x32.png new file mode 100644 index 00000000..b7479f7e Binary files /dev/null and b/assets/brand/favicon/favicon-32x32.png differ diff --git a/assets/brand/favicon/favicon-48x48.png b/assets/brand/favicon/favicon-48x48.png new file mode 100644 index 00000000..bee13b39 Binary files /dev/null and b/assets/brand/favicon/favicon-48x48.png differ diff --git a/assets/brand/favicon/favicon.ico b/assets/brand/favicon/favicon.ico new file mode 100644 index 00000000..70a34aab Binary files /dev/null and b/assets/brand/favicon/favicon.ico differ diff --git a/assets/brand/favicon/favicon.svg b/assets/brand/favicon/favicon.svg new file mode 100644 index 00000000..4de38a98 --- /dev/null +++ b/assets/brand/favicon/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/brand/favicon/head-snippet.html b/assets/brand/favicon/head-snippet.html new file mode 100644 index 00000000..3585e1d6 --- /dev/null +++ b/assets/brand/favicon/head-snippet.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/brand/favicon/icon-192.png b/assets/brand/favicon/icon-192.png new file mode 100644 index 00000000..1060d00c Binary files /dev/null and b/assets/brand/favicon/icon-192.png differ diff --git a/assets/brand/favicon/icon-512.png b/assets/brand/favicon/icon-512.png new file mode 100644 index 00000000..cb315bf9 Binary files /dev/null and b/assets/brand/favicon/icon-512.png differ diff --git a/assets/brand/favicon/icon-maskable-192.png b/assets/brand/favicon/icon-maskable-192.png new file mode 100644 index 00000000..42c09bb6 Binary files /dev/null and b/assets/brand/favicon/icon-maskable-192.png differ diff --git a/assets/brand/favicon/icon-maskable-512.png b/assets/brand/favicon/icon-maskable-512.png new file mode 100644 index 00000000..a35e55a2 Binary files /dev/null and b/assets/brand/favicon/icon-maskable-512.png differ diff --git a/assets/brand/favicon/site.webmanifest b/assets/brand/favicon/site.webmanifest new file mode 100644 index 00000000..a8ec48cd --- /dev/null +++ b/assets/brand/favicon/site.webmanifest @@ -0,0 +1,35 @@ +{ + "name": "NerdOS", + "short_name": "NerdOS", + "description": "NerdOS \u2014 Opinionated System", + "start_url": "/", + "display": "standalone", + "theme_color": "#1A1B1E", + "background_color": "#1A1B1E", + "icons": [ + { + "src": "/icon-192.png", + "type": "image/png", + "sizes": "192x192", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "any" + }, + { + "src": "/icon-maskable-192.png", + "type": "image/png", + "sizes": "192x192", + "purpose": "maskable" + }, + { + "src": "/icon-maskable-512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ] +} diff --git a/assets/brand/favicon/source/apple-touch-icon.svg b/assets/brand/favicon/source/apple-touch-icon.svg new file mode 100644 index 00000000..1fc906dd --- /dev/null +++ b/assets/brand/favicon/source/apple-touch-icon.svg @@ -0,0 +1,6 @@ + + NerdOS + + + + diff --git a/assets/brand/favicon/source/favicon-tile-16.svg b/assets/brand/favicon/source/favicon-tile-16.svg new file mode 100644 index 00000000..97ce9e7d --- /dev/null +++ b/assets/brand/favicon/source/favicon-tile-16.svg @@ -0,0 +1,6 @@ + + NerdOS favicon 16px + + + + diff --git a/assets/brand/favicon/source/favicon-tile-32.svg b/assets/brand/favicon/source/favicon-tile-32.svg new file mode 100644 index 00000000..4a6bb57b --- /dev/null +++ b/assets/brand/favicon/source/favicon-tile-32.svg @@ -0,0 +1,6 @@ + + NerdOS favicon 32px + + + + diff --git a/assets/brand/favicon/source/favicon-tile-48.svg b/assets/brand/favicon/source/favicon-tile-48.svg new file mode 100644 index 00000000..1c60db03 --- /dev/null +++ b/assets/brand/favicon/source/favicon-tile-48.svg @@ -0,0 +1,6 @@ + + NerdOS favicon 48px + + + + diff --git a/assets/brand/favicon/source/icon-maskable.svg b/assets/brand/favicon/source/icon-maskable.svg new file mode 100644 index 00000000..ed83d051 --- /dev/null +++ b/assets/brand/favicon/source/icon-maskable.svg @@ -0,0 +1,6 @@ + + NerdOS (maskable) + + + + diff --git a/assets/brand/kit-sha256.json b/assets/brand/kit-sha256.json new file mode 100644 index 00000000..6b607a25 --- /dev/null +++ b/assets/brand/kit-sha256.json @@ -0,0 +1,629 @@ +{ + "schema": 1, + "files": { + "assets/brand/USAGE.md": { + "source": "brand/USAGE.md", + "sha256": "8ae447144f308c75628b89a96697e01b67dc84e4253b75bbdb8fc7da9e2fca96" + }, + "assets/brand/_source/README.md": { + "source": "brand/_source/README.md", + "sha256": "ab96d87d6243d1b7e20660c1a0acacce8e339d860ded44301005245a5c52f563" + }, + "assets/brand/_source/ascii_export.py": { + "source": "brand/_source/ascii_export.py", + "sha256": "f402d465c53d91d4873e5a76118f81d01ad52cf8ca7eb342a22fb6b6940083c1" + }, + "assets/brand/_source/brand.py": { + "source": "brand/_source/brand.py", + "sha256": "dffed5718b027c3767356dbefe76479295e94d62eb01855a4da317642aa5f086" + }, + "assets/brand/_source/export.py": { + "source": "brand/_source/export.py", + "sha256": "9bd9565f64b2f10acb27d97612d940306cfa12ecd455fb2bffff523c9ebd4f13" + }, + "assets/brand/_source/geo.py": { + "source": "brand/_source/geo.py", + "sha256": "209527cff27a137d895ae61c4e57f8b30ffb00662b7003bb2c7f289ae9492bf5" + }, + "assets/brand/_source/glyphs.py": { + "source": "brand/_source/glyphs.py", + "sha256": "4e454b40d1523f5bdc28444e8ea52abdfbf644cfe01b3114cc23541c1f74c4de" + }, + "assets/brand/app-icon/png/nerdos-app-icon-dark-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-dark-1024.png", + "sha256": "45d3181326e019c28aca171467bea0eabe914cbdb8b108bca5b449e7fa86dd46" + }, + "assets/brand/app-icon/png/nerdos-app-icon-dark-128.png": { + "source": "brand/app-icon/png/nerdos-app-icon-dark-128.png", + "sha256": "ce944fd99af250afafa52428194b8826d7b91b2525747b4d5a5d430c426d5bc9" + }, + "assets/brand/app-icon/png/nerdos-app-icon-dark-256.png": { + "source": "brand/app-icon/png/nerdos-app-icon-dark-256.png", + "sha256": "c86362dbe6b338a2e391a697f6d5f33ec2cd761ebecaa43ec5642b9997a782d4" + }, + "assets/brand/app-icon/png/nerdos-app-icon-dark-512.png": { + "source": "brand/app-icon/png/nerdos-app-icon-dark-512.png", + "sha256": "e41775c68992cf78c14605f9a90d18b13059b3c8af14ef19a9d3e76f4c4f43fa" + }, + "assets/brand/app-icon/png/nerdos-app-icon-dark-64.png": { + "source": "brand/app-icon/png/nerdos-app-icon-dark-64.png", + "sha256": "267a41f605ff475ec53e8181bd9e2df110f00c39230119d875f81a41d492d6b7" + }, + "assets/brand/app-icon/png/nerdos-app-icon-dark-square-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-dark-square-1024.png", + "sha256": "502aaea886086a393a51e2460ad66c2d6ce337a6aff646311e61398729b4780b" + }, + "assets/brand/app-icon/png/nerdos-app-icon-light-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-light-1024.png", + "sha256": "732f72b17ab37ad67f1c426d3b089e44d2c2eae8b477eabfff6ffcf27a6c43e2" + }, + "assets/brand/app-icon/png/nerdos-app-icon-light-128.png": { + "source": "brand/app-icon/png/nerdos-app-icon-light-128.png", + "sha256": "f8f01e2162288c3cfd0f7f81effa57da2942c6bbed36c06c1e5d368bfe591cb9" + }, + "assets/brand/app-icon/png/nerdos-app-icon-light-256.png": { + "source": "brand/app-icon/png/nerdos-app-icon-light-256.png", + "sha256": "cc3f4500ff6a4b30d717dafb9fae9d881240af0343608e3125351d02cbea2d53" + }, + "assets/brand/app-icon/png/nerdos-app-icon-light-512.png": { + "source": "brand/app-icon/png/nerdos-app-icon-light-512.png", + "sha256": "ca81e9f3d4061adc3eafbd299302d89c7bb028e955bd64c1768c4f7c0735dd08" + }, + "assets/brand/app-icon/png/nerdos-app-icon-light-64.png": { + "source": "brand/app-icon/png/nerdos-app-icon-light-64.png", + "sha256": "f207a27ddf196318b12cfb0b558a5e15e72eef8d1876f64bac942ed038a8c4c6" + }, + "assets/brand/app-icon/png/nerdos-app-icon-light-square-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-light-square-1024.png", + "sha256": "e24b63cda623cc255779361108a48d16fda3e8ae442a74fa6e6bdc98d100ad1e" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-black-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-black-1024.png", + "sha256": "224ce8f4e1a00652e107844b7af90da1007e7afe74edacc1c5cd2b69d178e1fc" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-black-128.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-black-128.png", + "sha256": "b8d32b66a00164de549e6c3dfdd7cecd62d21f2d72fdc044f267b0dc722d1645" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-black-256.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-black-256.png", + "sha256": "95e821b2983cdf46fba9e1d33831b43b158fd24e479e5a730fb7c980e32b18b3" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-black-512.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-black-512.png", + "sha256": "ad589f69fa4682bad66add8e5fc8fc43d446641a116705404d7bea4302fbad10" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-black-64.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-black-64.png", + "sha256": "535289f971565cedf80a65805d507deff758c69a650fbd08b7bb17419f9ddef2" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-black-square-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-black-square-1024.png", + "sha256": "116ea3801510b23c1a55df25d9e354e0fdc5a64698beda7d9f59f4c166c9c7ce" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-white-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-white-1024.png", + "sha256": "f70e2250f8a6c9b69424e3b572b9b35a4ff122ecb19effc971561b14c7f94ae8" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-white-128.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-white-128.png", + "sha256": "944aa62aaa6e4a69cf0659b94962d84fca8eb49cc06669f960d3fa02e9b95e1e" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-white-256.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-white-256.png", + "sha256": "4174e2b02c7d15eea8c2b470df7c220a9fb6d7949d6e5631ef8643578420de3e" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-white-512.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-white-512.png", + "sha256": "329154f1e94c6b409cf637941db9dad1b184ab7f01b98b8598d245f9f79e51c0" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-white-64.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-white-64.png", + "sha256": "d15d38cdb43fde033ac53b854fb84290404db9ec74d3d99afa79274f3f78a9ff" + }, + "assets/brand/app-icon/png/nerdos-app-icon-mono-white-square-1024.png": { + "source": "brand/app-icon/png/nerdos-app-icon-mono-white-square-1024.png", + "sha256": "27fa24918b12da5f600b7aa44918c1be37ab60d5f964bebdc96e059bff77936f" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-dark-square.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-dark-square.svg", + "sha256": "2be2f7744475fab13c7c60682b6060a4b372857594635a1fb70b84e4e2b1f0d1" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-dark.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-dark.svg", + "sha256": "2873aa6c576593fe6f6a0dc3b315615b352e27068f6ab407d78dc092120ea223" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-light-square.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-light-square.svg", + "sha256": "8573f093c8b2091a18955be8a283b65107bd79ac51cd432db7427772aa0db582" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-light.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-light.svg", + "sha256": "d034dbe906d1723b6db23500a191fd44245406de596e98f6c03e69e943df34b4" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-mono-black-square.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-mono-black-square.svg", + "sha256": "549e833101f04aa889ec305379e9ab5abcd019c86e7eb6fd82f71f66fbac70d9" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-mono-black.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-mono-black.svg", + "sha256": "e89dff92d205bd27e1619eec8c8bc14834348f06bbd1e7792abc59a7b7b6fe66" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-mono-white-square.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-mono-white-square.svg", + "sha256": "35ebcb5251485355f6d2344d1292340cab8b973832ed1534a5abcd8c025cb7c4" + }, + "assets/brand/app-icon/svg/nerdos-app-icon-mono-white.svg": { + "source": "brand/app-icon/svg/nerdos-app-icon-mono-white.svg", + "sha256": "75a5bfbbeb6410f5f69f8498a043c7bfa4df8a348ffc40c8d279e2be7ed8c053" + }, + "assets/brand/ascii/README.md": { + "source": "brand/ascii/README.md", + "sha256": "ca2d785d7c0967df4eb9f772b9daa35529c97f4abbadc3ee7556972430fa23df" + }, + "assets/brand/ascii/fastfetch-config.jsonc": { + "source": "brand/ascii/fastfetch-config.jsonc", + "sha256": "bbfbcd03c7f2b7c624a520a50180e81361fe8246054773c912cf74fa9dc52d45" + }, + "assets/brand/ascii/nerdos-banner.fastfetch.txt": { + "source": "brand/ascii/nerdos-banner.fastfetch.txt", + "sha256": "f2f01615875cdad90450951400d480a46452b4dfa72d0d821993e8b3f4baf2d3" + }, + "assets/brand/ascii/nerdos-banner.txt": { + "source": "brand/ascii/nerdos-banner.txt", + "sha256": "65058fc7fb811fef59c09c77e8ba69fcadbacce7c3d6a4f00300189da90f0c29" + }, + "assets/brand/ascii/nerdos-compact.fastfetch.txt": { + "source": "brand/ascii/nerdos-compact.fastfetch.txt", + "sha256": "f014612980594d6bfd0508949a69746f141ae5ce17f6203bba539deadc9827dd" + }, + "assets/brand/ascii/nerdos-compact.txt": { + "source": "brand/ascii/nerdos-compact.txt", + "sha256": "d2c47db127df6f36952be4c4b09a40be8537bf3c89f94c0de35adc00e619a674" + }, + "assets/brand/ascii/nerdos-large.fastfetch.txt": { + "source": "brand/ascii/nerdos-large.fastfetch.txt", + "sha256": "5ccde93ad0438685b5a98e83c830d90db4763019061f09f91d35c8d3e4c23719" + }, + "assets/brand/ascii/nerdos-large.txt": { + "source": "brand/ascii/nerdos-large.txt", + "sha256": "6ae8b1831ab7f2ff51b6c8fedd919a79012d3e1d4166b5e3180513f58dbace0a" + }, + "assets/brand/favicon/apple-touch-icon.png": { + "source": "brand/favicon/apple-touch-icon.png", + "sha256": "0e2a93b95565706ae74bf253d95eefbac1c3830fa813532e552a9d98f30a05a6" + }, + "assets/brand/favicon/extras/favicon-transparent-on-dark-16x16.png": { + "source": "brand/favicon/extras/favicon-transparent-on-dark-16x16.png", + "sha256": "836ff72564581743a1f18d3f1ec3445a03a80715358f500dc839e07a96875739" + }, + "assets/brand/favicon/extras/favicon-transparent-on-dark-32x32.png": { + "source": "brand/favicon/extras/favicon-transparent-on-dark-32x32.png", + "sha256": "bef995cb1cde5f813c894ea7beb7b20c983bfcd5a5136e85d046c43029c9fcd6" + }, + "assets/brand/favicon/extras/favicon-transparent-on-light-16x16.png": { + "source": "brand/favicon/extras/favicon-transparent-on-light-16x16.png", + "sha256": "6891cb1a69af95c5a5d73d311a47d528244d8d63ae662aeacdb78c27d10ffd29" + }, + "assets/brand/favicon/extras/favicon-transparent-on-light-32x32.png": { + "source": "brand/favicon/extras/favicon-transparent-on-light-32x32.png", + "sha256": "f7855bf944e8139619aba90792a72c75ee6dc951a7a23c1d0f37025a6daf63ca" + }, + "assets/brand/favicon/favicon-16x16.png": { + "source": "brand/favicon/favicon-16x16.png", + "sha256": "b869eda05d19e57c95d17db415e8c125f214b7715da864c84d5662c1ec7bf9a4" + }, + "assets/brand/favicon/favicon-32x32.png": { + "source": "brand/favicon/favicon-32x32.png", + "sha256": "f7603e818306af0d538119eb3f8c17d64fe9a6d032d1f15d10af991b7a59fbd0" + }, + "assets/brand/favicon/favicon-48x48.png": { + "source": "brand/favicon/favicon-48x48.png", + "sha256": "4cc8d3cbb5b2a5c2906130dc44da4b773df33ae204cf8f9967aba6084c4f7c67" + }, + "assets/brand/favicon/favicon.ico": { + "source": "brand/favicon/favicon.ico", + "sha256": "6b857f20017a10eff8011cd08cce855fcc53adf6c0f1b53885852ca495c9ecca" + }, + "assets/brand/favicon/favicon.svg": { + "source": "brand/favicon/favicon.svg", + "sha256": "6c4b62896433102986f5db64938cb21ce512cd2738dac6e569ad0be8d6b5a39e" + }, + "assets/brand/favicon/head-snippet.html": { + "source": "brand/favicon/head-snippet.html", + "sha256": "ac6f50324f55f497c13cb780ee034be5702e1c010646bad7ce66a64e869f5de4" + }, + "assets/brand/favicon/icon-192.png": { + "source": "brand/favicon/icon-192.png", + "sha256": "831bc4146971cc0c4a2a635d1cf934eb8346de696ce87c9199f6c1887f40bf30" + }, + "assets/brand/favicon/icon-512.png": { + "source": "brand/favicon/icon-512.png", + "sha256": "d885a30c89f5340863a5e29853e21e91f60e3eb514ad045d78e059fea940e058" + }, + "assets/brand/favicon/icon-maskable-192.png": { + "source": "brand/favicon/icon-maskable-192.png", + "sha256": "a6510ec52256e25ada0d5cd1678298121f424c4c0d260256a151f7adcc2a74f7" + }, + "assets/brand/favicon/icon-maskable-512.png": { + "source": "brand/favicon/icon-maskable-512.png", + "sha256": "2a34c0b42090950cf99c6e392d2228619a5b4549428873cbb41b245ab16813a6" + }, + "assets/brand/favicon/site.webmanifest": { + "source": "brand/favicon/site.webmanifest", + "sha256": "a0595de67c8e2f2dbf1a3698f55341d43d94ffbf9e45a6b74a60e062fc1b6a40" + }, + "assets/brand/favicon/source/apple-touch-icon.svg": { + "source": "brand/favicon/source/apple-touch-icon.svg", + "sha256": "4d3e1680eb430c7f07a090f00e62085993808a108628d507b60ae16edc484d05" + }, + "assets/brand/favicon/source/favicon-tile-16.svg": { + "source": "brand/favicon/source/favicon-tile-16.svg", + "sha256": "51e126ad81f0519320c50bc6421305b7cecca5ec93ee2ee7aafe2e68cd757f45" + }, + "assets/brand/favicon/source/favicon-tile-32.svg": { + "source": "brand/favicon/source/favicon-tile-32.svg", + "sha256": "bb54cd4422fbb2ea1528a3c651ac52770720bef59c2d6a17edb9ca1773b3e916" + }, + "assets/brand/favicon/source/favicon-tile-48.svg": { + "source": "brand/favicon/source/favicon-tile-48.svg", + "sha256": "0a52ad7fa1328376cd8002171dd7164799b6e7ef4b00d41a766f029c3cb21abf" + }, + "assets/brand/favicon/source/icon-maskable.svg": { + "source": "brand/favicon/source/icon-maskable.svg", + "sha256": "3413d85c4aed8d3e2123703ed7d72b80c233b88f23959e9e0bb959d9384b3b6b" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-mono-black-1024.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-mono-black-1024.png", + "sha256": "cb6441de7744e7955d6b6050803fda1aec2b7df327e37f6c9742ab42fde2db8c" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-mono-black-2048.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-mono-black-2048.png", + "sha256": "1d3036cdd7cb8c64c9c51fcc35e132474ddb1458e6a4854854fb9657985a95e1" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-mono-black-512.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-mono-black-512.png", + "sha256": "1a9c5e64ff8c7ba3b2550cf95a8f6b7c4902f09d13dc95451bc3b3f53f41c51c" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-mono-white-1024.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-mono-white-1024.png", + "sha256": "b6a31e6b48169ec5bb079f6d7846114625a06ff25457a4d866c92805fef344db" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-mono-white-2048.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-mono-white-2048.png", + "sha256": "9d418f8778d49b9f0ac12ee6c8a11cffdd7a999e369ce5f8feaf08c59a799c75" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-mono-white-512.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-mono-white-512.png", + "sha256": "9dbfc55ce2aaae9278f21b8b8ce406f021a263849d5124619ae680bbf2f80b87" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-on-dark-1024.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-on-dark-1024.png", + "sha256": "e81105dbbc9af13df15361c67f23556e0a2b9b09109674ee710d7dafb80a82a3" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-on-dark-2048.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-on-dark-2048.png", + "sha256": "2551497507c9831fb5475d8ea6e0e6b85fd2b98e2756b36026168567fc3c34a4" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-on-dark-512.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-on-dark-512.png", + "sha256": "241309536e7f4197b21b433b501f5af14bd9a5e41e6b78cd87d655f3b20728e4" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-on-light-1024.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-on-light-1024.png", + "sha256": "be84dd9ad95688b53d5bf8d86a951e01020ec13e24a10fe6ce6ec1e3f4aa673b" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-on-light-2048.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-on-light-2048.png", + "sha256": "e9d0e728d7b1796038058b2dcd2ce5e4fc22b2db944262a8e577107049b349e6" + }, + "assets/brand/logo/png/nerdos-logo-descriptor-on-light-512.png": { + "source": "brand/logo/png/nerdos-logo-descriptor-on-light-512.png", + "sha256": "5fd4e6fa53f2d7587eda3fd5df06866bfe8064e649045219a35d336e42bb8113" + }, + "assets/brand/logo/png/nerdos-logo-mono-black-1024.png": { + "source": "brand/logo/png/nerdos-logo-mono-black-1024.png", + "sha256": "dbb9a93f7c06f1053a194cde32acdb93cd90e7ea6d053991d3f7ab7f1df2fa38" + }, + "assets/brand/logo/png/nerdos-logo-mono-black-2048.png": { + "source": "brand/logo/png/nerdos-logo-mono-black-2048.png", + "sha256": "3f245f2046de238cc1f600a72db6efc0430ba690abc6e08c87e91779d1ed94af" + }, + "assets/brand/logo/png/nerdos-logo-mono-black-512.png": { + "source": "brand/logo/png/nerdos-logo-mono-black-512.png", + "sha256": "2637ba93ff85d0df31ff550cefdfb54cfe428d6357a81eb54771ca5b96ac1fa0" + }, + "assets/brand/logo/png/nerdos-logo-mono-white-1024.png": { + "source": "brand/logo/png/nerdos-logo-mono-white-1024.png", + "sha256": "084f91b48c4f08c873e016459817adb437055b2906dabd3d2981b50f54537f93" + }, + "assets/brand/logo/png/nerdos-logo-mono-white-2048.png": { + "source": "brand/logo/png/nerdos-logo-mono-white-2048.png", + "sha256": "197099c0d3ede611e099cc54a28eecc2560474973a89b20a71cc0537def1ec8b" + }, + "assets/brand/logo/png/nerdos-logo-mono-white-512.png": { + "source": "brand/logo/png/nerdos-logo-mono-white-512.png", + "sha256": "2d69621e545b7a713796e4e2f9c43bf41e80231fa1ad200b9947b7fe46fe5290" + }, + "assets/brand/logo/png/nerdos-logo-on-dark-1024.png": { + "source": "brand/logo/png/nerdos-logo-on-dark-1024.png", + "sha256": "d4bcc5ca25d03ba38e5a11cbd201af3b192fbafca0fdb193824f6a3a72205102" + }, + "assets/brand/logo/png/nerdos-logo-on-dark-2048.png": { + "source": "brand/logo/png/nerdos-logo-on-dark-2048.png", + "sha256": "4f330305a70661343c1461ee6b15f25b9245d6e7e127804ddda4080cc8a6ba9c" + }, + "assets/brand/logo/png/nerdos-logo-on-dark-512.png": { + "source": "brand/logo/png/nerdos-logo-on-dark-512.png", + "sha256": "2eb30c23637f5e6cf156f34d5e84e46fb979b8f07c9345776d995e23ee02da2d" + }, + "assets/brand/logo/png/nerdos-logo-on-light-1024.png": { + "source": "brand/logo/png/nerdos-logo-on-light-1024.png", + "sha256": "6d6d5aeeb6e3d0d5c4a749cf8c1b599771f2e25979b21ec26d9343af5004dd91" + }, + "assets/brand/logo/png/nerdos-logo-on-light-2048.png": { + "source": "brand/logo/png/nerdos-logo-on-light-2048.png", + "sha256": "9c6c30ffa44326b44a504cf57fc7d1cff2147d6d20c3c1f60ca5529d2b592d47" + }, + "assets/brand/logo/png/nerdos-logo-on-light-512.png": { + "source": "brand/logo/png/nerdos-logo-on-light-512.png", + "sha256": "b4e5babbbbea73291f12608e89812778f1297e40e983f0cd352133273e6b96d0" + }, + "assets/brand/logo/svg/nerdos-logo-descriptor-mono-black.svg": { + "source": "brand/logo/svg/nerdos-logo-descriptor-mono-black.svg", + "sha256": "7e1e8d42434d45f5a6572af954a4d80d1bf3112a23b85d8cd768e2f081ce410e" + }, + "assets/brand/logo/svg/nerdos-logo-descriptor-mono-white.svg": { + "source": "brand/logo/svg/nerdos-logo-descriptor-mono-white.svg", + "sha256": "b37de20b32667b770767d72efa60987b32eeacaa9a21f34b3340c6557d303ddb" + }, + "assets/brand/logo/svg/nerdos-logo-descriptor-on-dark.svg": { + "source": "brand/logo/svg/nerdos-logo-descriptor-on-dark.svg", + "sha256": "8133f2a5c6158f619b90aa16908a88b2aeb0f5ed86939f5912d2e2a8e62c0076" + }, + "assets/brand/logo/svg/nerdos-logo-descriptor-on-light.svg": { + "source": "brand/logo/svg/nerdos-logo-descriptor-on-light.svg", + "sha256": "42e042f173aefdd1e201b2c8cb6221515b85b3f82efd6e16ff25ebad6b67dd0d" + }, + "assets/brand/logo/svg/nerdos-logo-mono-black.svg": { + "source": "brand/logo/svg/nerdos-logo-mono-black.svg", + "sha256": "e4875f3e5c1dedcf66df2b151eca1a9612417de5770769fe68e68b16ce0f76bb" + }, + "assets/brand/logo/svg/nerdos-logo-mono-white.svg": { + "source": "brand/logo/svg/nerdos-logo-mono-white.svg", + "sha256": "3b2a592f75fb270235f23f582d6102e54c009b9654722e49611107f1618cb6c0" + }, + "assets/brand/logo/svg/nerdos-logo-on-dark.svg": { + "source": "brand/logo/svg/nerdos-logo-on-dark.svg", + "sha256": "155559f61f6dfbea345c643e905ae1ff46f9681dad77839b99a4f36481a362b3" + }, + "assets/brand/logo/svg/nerdos-logo-on-light.svg": { + "source": "brand/logo/svg/nerdos-logo-on-light.svg", + "sha256": "30d9094ac663a5f6b2847b22aab100fe87482584a8e06f7fb244d4a0de12c724" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-black-1024.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-black-1024.png", + "sha256": "18bb5f21e354e0147252181a23578cea887a9a52636f6ac86894cbc4d228f5b1" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-black-128.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-black-128.png", + "sha256": "c0d6a2111eca8af64150a74554c690c1c1a5bf815a951920a01e84857d15dcef" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-black-256.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-black-256.png", + "sha256": "5e4540996b3b406b01702e1da909f0468932c848dddbf4b17a4cea798c0aa55b" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-black-512.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-black-512.png", + "sha256": "5660648ac30bd820acd852329ce7ca6ed4d19e63ecf6fe7f8d90444b8b3940f5" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-white-1024.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-white-1024.png", + "sha256": "3c3e5cac5690fdb1d97072f7edc3d7d426c6d5db8b7a1ad53c7aafe20c57d187" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-white-128.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-white-128.png", + "sha256": "955cef7b05afdcffef6771b70db7f95cd6c4e0037644965c00a4ef1841318d36" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-white-256.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-white-256.png", + "sha256": "1fbc76b6e735d7e28317b0fa35d8454dd47327ec9db154b11244ce032c4ebb11" + }, + "assets/brand/symbol/png/nerdos-symbol-mono-white-512.png": { + "source": "brand/symbol/png/nerdos-symbol-mono-white-512.png", + "sha256": "77624e3a90dc7de4fbecd4231448a9c4138cbebec17bd2e8367e72eaa61aa9ff" + }, + "assets/brand/symbol/png/nerdos-symbol-on-dark-1024.png": { + "source": "brand/symbol/png/nerdos-symbol-on-dark-1024.png", + "sha256": "c2267f9fe818e4d22f656230b0f3ca0d9ccca1898135e0188ea1ec270c20c229" + }, + "assets/brand/symbol/png/nerdos-symbol-on-dark-128.png": { + "source": "brand/symbol/png/nerdos-symbol-on-dark-128.png", + "sha256": "ea1da2bb11e3fadd6216f4c20b4e4cc2e04851e5967f6636ed16c27fb5ee14a6" + }, + "assets/brand/symbol/png/nerdos-symbol-on-dark-256.png": { + "source": "brand/symbol/png/nerdos-symbol-on-dark-256.png", + "sha256": "5888c492d48a35ab7d68835d7bf3615006edfec8840fa52cf56054269e316699" + }, + "assets/brand/symbol/png/nerdos-symbol-on-dark-512.png": { + "source": "brand/symbol/png/nerdos-symbol-on-dark-512.png", + "sha256": "8f60fa2e1f73c4556b66cbbaeba92eff2f83ca012b1c8e486dbc416d25f2e207" + }, + "assets/brand/symbol/png/nerdos-symbol-on-light-1024.png": { + "source": "brand/symbol/png/nerdos-symbol-on-light-1024.png", + "sha256": "02ab9ba9620fbf3f388ac8de466f3caad1ff6dcef676bb0c92a9ddf5cf1a62e3" + }, + "assets/brand/symbol/png/nerdos-symbol-on-light-128.png": { + "source": "brand/symbol/png/nerdos-symbol-on-light-128.png", + "sha256": "2075ae79b2308ca9302a1bd81f0626d05b25124d2990d8ff75f74283414b7364" + }, + "assets/brand/symbol/png/nerdos-symbol-on-light-256.png": { + "source": "brand/symbol/png/nerdos-symbol-on-light-256.png", + "sha256": "2927de4d51d7270a08c429decab7a0c0b31ab1ddca630e4dc23624cd53243523" + }, + "assets/brand/symbol/png/nerdos-symbol-on-light-512.png": { + "source": "brand/symbol/png/nerdos-symbol-on-light-512.png", + "sha256": "d4df37aa38696799f39e1a61de16c22da0b1d14daf35007405d3989bfa77e5b8" + }, + "assets/brand/symbol/png/small/nerdos-symbol-16px-on-dark.png": { + "source": "brand/symbol/png/small/nerdos-symbol-16px-on-dark.png", + "sha256": "836ff72564581743a1f18d3f1ec3445a03a80715358f500dc839e07a96875739" + }, + "assets/brand/symbol/png/small/nerdos-symbol-16px-on-light.png": { + "source": "brand/symbol/png/small/nerdos-symbol-16px-on-light.png", + "sha256": "6891cb1a69af95c5a5d73d311a47d528244d8d63ae662aeacdb78c27d10ffd29" + }, + "assets/brand/symbol/png/small/nerdos-symbol-32px-on-dark.png": { + "source": "brand/symbol/png/small/nerdos-symbol-32px-on-dark.png", + "sha256": "bef995cb1cde5f813c894ea7beb7b20c983bfcd5a5136e85d046c43029c9fcd6" + }, + "assets/brand/symbol/png/small/nerdos-symbol-32px-on-light.png": { + "source": "brand/symbol/png/small/nerdos-symbol-32px-on-light.png", + "sha256": "f7855bf944e8139619aba90792a72c75ee6dc951a7a23c1d0f37025a6daf63ca" + }, + "assets/brand/symbol/svg/nerdos-symbol-mono-black.svg": { + "source": "brand/symbol/svg/nerdos-symbol-mono-black.svg", + "sha256": "1e4a49670021e99c0ff3604bc91466a8706a19cfed6b4747c8fa9e818171538a" + }, + "assets/brand/symbol/svg/nerdos-symbol-mono-white.svg": { + "source": "brand/symbol/svg/nerdos-symbol-mono-white.svg", + "sha256": "c82b0cb43201a5cf4d74b134c11148929082e480068beae0e8f0d047bf7da1ad" + }, + "assets/brand/symbol/svg/nerdos-symbol-on-dark.svg": { + "source": "brand/symbol/svg/nerdos-symbol-on-dark.svg", + "sha256": "53814ae267a3a4a05d7484740d8111d55bf3d38b1017b86fca659172b013a34f" + }, + "assets/brand/symbol/svg/nerdos-symbol-on-light.svg": { + "source": "brand/symbol/svg/nerdos-symbol-on-light.svg", + "sha256": "c301fd8212a34ce506217381d316816707c217d2269360e112a40d771ba5631f" + }, + "assets/brand/symbol/svg/small/nerdos-symbol-16px-on-dark.svg": { + "source": "brand/symbol/svg/small/nerdos-symbol-16px-on-dark.svg", + "sha256": "08ba9fd9a0b63f353663a7f1e3f40bd174339c784be266c9682025bac5780134" + }, + "assets/brand/symbol/svg/small/nerdos-symbol-16px-on-light.svg": { + "source": "brand/symbol/svg/small/nerdos-symbol-16px-on-light.svg", + "sha256": "6eb0c274e02962dc1dd15616748c4d976b71a5664df626df9672df83724190e4" + }, + "assets/brand/symbol/svg/small/nerdos-symbol-32px-on-dark.svg": { + "source": "brand/symbol/svg/small/nerdos-symbol-32px-on-dark.svg", + "sha256": "2f5e2118b5fe668bfa5525771f900896dc73b58eff793703d37c9cde014d4241" + }, + "assets/brand/symbol/svg/small/nerdos-symbol-32px-on-light.svg": { + "source": "brand/symbol/svg/small/nerdos-symbol-32px-on-light.svg", + "sha256": "4e98e9fb8ab1f08a74f8da9cc35dc7aca0842525fe5526187dd6fcdd8bfc85eb" + }, + "desktop/plymouth/logo.png": { + "source": "derived/plymouth/logo.png", + "sha256": "d98d1d16eecc1ae3feaa3854a528ba9bc6dc5ccadf6f32167b401c1eeef0ae08" + }, + "desktop/plymouth/logo@2x.png": { + "source": "derived/plymouth/logo@2x.png", + "sha256": "c38adffdf829d0070c6028bdc5262760ee0605e66ccb26fc512adaa4146b826f" + }, + "desktop/quickshell/assets/nerdos-white.svg": { + "source": "derived/quickshell/nerdos-white.svg", + "sha256": "03151aacc479c421a52f92669e3801c45a7d4642e16728c2dfd5958594f5d668" + }, + "desktop/quickshell/assets/nerdos.svg": { + "source": "derived/quickshell/nerdos.svg", + "sha256": "ec82cf7f8a235a689c973158af084660ceb0b4875da5bd196634e0d4545b815a" + }, + "desktop/icons/hicolor/128x128/apps/nerdos.png": { + "source": "derived/hicolor/128x128/apps/nerdos.png", + "sha256": "ce944fd99af250afafa52428194b8826d7b91b2525747b4d5a5d430c426d5bc9" + }, + "desktop/icons/hicolor/16x16/apps/nerdos.png": { + "source": "derived/hicolor/16x16/apps/nerdos.png", + "sha256": "b869eda05d19e57c95d17db415e8c125f214b7715da864c84d5662c1ec7bf9a4" + }, + "desktop/icons/hicolor/256x256/apps/nerdos.png": { + "source": "derived/hicolor/256x256/apps/nerdos.png", + "sha256": "c86362dbe6b338a2e391a697f6d5f33ec2cd761ebecaa43ec5642b9997a782d4" + }, + "desktop/icons/hicolor/32x32/apps/nerdos.png": { + "source": "derived/hicolor/32x32/apps/nerdos.png", + "sha256": "f7603e818306af0d538119eb3f8c17d64fe9a6d032d1f15d10af991b7a59fbd0" + }, + "desktop/icons/hicolor/48x48/apps/nerdos.png": { + "source": "derived/hicolor/48x48/apps/nerdos.png", + "sha256": "4cc8d3cbb5b2a5c2906130dc44da4b773df33ae204cf8f9967aba6084c4f7c67" + }, + "desktop/icons/hicolor/512x512/apps/nerdos.png": { + "source": "derived/hicolor/512x512/apps/nerdos.png", + "sha256": "e41775c68992cf78c14605f9a90d18b13059b3c8af14ef19a9d3e76f4c4f43fa" + }, + "desktop/icons/hicolor/64x64/apps/nerdos.png": { + "source": "derived/hicolor/64x64/apps/nerdos.png", + "sha256": "267a41f605ff475ec53e8181bd9e2df110f00c39230119d875f81a41d492d6b7" + }, + "desktop/icons/hicolor/scalable/apps/nerdos.svg": { + "source": "derived/hicolor/scalable/apps/nerdos.svg", + "sha256": "2873aa6c576593fe6f6a0dc3b315615b352e27068f6ab407d78dc092120ea223" + }, + "desktop/fastfetch/nerdos-banner.txt": { + "source": "derived/fastfetch/nerdos-banner.txt", + "sha256": "65058fc7fb811fef59c09c77e8ba69fcadbacce7c3d6a4f00300189da90f0c29" + }, + "desktop/fastfetch/nerdos-compact.fastfetch.txt": { + "source": "derived/fastfetch/nerdos-compact.fastfetch.txt", + "sha256": "f014612980594d6bfd0508949a69746f141ae5ce17f6203bba539deadc9827dd" + }, + "desktop/fastfetch/nerdos-compact.txt": { + "source": "derived/fastfetch/nerdos-compact.txt", + "sha256": "d2c47db127df6f36952be4c4b09a40be8537bf3c89f94c0de35adc00e619a674" + }, + "desktop/fastfetch/nerdos-large.fastfetch.txt": { + "source": "derived/fastfetch/nerdos-large.fastfetch.txt", + "sha256": "5ccde93ad0438685b5a98e83c830d90db4763019061f09f91d35c8d3e4c23719" + }, + "desktop/fastfetch/nerdos-large.txt": { + "source": "derived/fastfetch/nerdos-large.txt", + "sha256": "6ae8b1831ab7f2ff51b6c8fedd919a79012d3e1d4166b5e3180513f58dbace0a" + }, + "web/public/apple-touch-icon.png": { + "source": "derived/web/apple-touch-icon.png", + "sha256": "0e2a93b95565706ae74bf253d95eefbac1c3830fa813532e552a9d98f30a05a6" + }, + "web/public/favicon.ico": { + "source": "derived/web/favicon.ico", + "sha256": "6b857f20017a10eff8011cd08cce855fcc53adf6c0f1b53885852ca495c9ecca" + }, + "web/public/favicon.svg": { + "source": "derived/web/favicon.svg", + "sha256": "6c4b62896433102986f5db64938cb21ce512cd2738dac6e569ad0be8d6b5a39e" + }, + "web/public/icon-192.png": { + "source": "derived/web/icon-192.png", + "sha256": "831bc4146971cc0c4a2a635d1cf934eb8346de696ce87c9199f6c1887f40bf30" + }, + "web/public/icon-512.png": { + "source": "derived/web/icon-512.png", + "sha256": "d885a30c89f5340863a5e29853e21e91f60e3eb514ad045d78e059fea940e058" + }, + "web/public/icon-maskable-192.png": { + "source": "derived/web/icon-maskable-192.png", + "sha256": "a6510ec52256e25ada0d5cd1678298121f424c4c0d260256a151f7adcc2a74f7" + }, + "web/public/icon-maskable-512.png": { + "source": "derived/web/icon-maskable-512.png", + "sha256": "2a34c0b42090950cf99c6e392d2228619a5b4549428873cbb41b245ab16813a6" + }, + "web/public/site.webmanifest": { + "source": "derived/web/site.webmanifest", + "sha256": "a0595de67c8e2f2dbf1a3698f55341d43d94ffbf9e45a6b74a60e062fc1b6a40" + }, + "web/public/nerdos-logo.svg": { + "source": "derived/welcome/nerdos-logo.svg", + "sha256": "155559f61f6dfbea345c643e905ae1ff46f9681dad77839b99a4f36481a362b3" + }, + "web/public/nerdos.svg": { + "source": "derived/quickshell/nerdos.svg", + "sha256": "ec82cf7f8a235a689c973158af084660ceb0b4875da5bd196634e0d4545b815a" + }, + "web/public/nerdos-white.svg": { + "source": "derived/quickshell/nerdos-white.svg", + "sha256": "03151aacc479c421a52f92669e3801c45a7d4642e16728c2dfd5958594f5d668" + } + } +} diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-1024.png b/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-1024.png new file mode 100644 index 00000000..067c814f Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-2048.png b/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-2048.png new file mode 100644 index 00000000..f3afaf01 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-512.png b/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-512.png new file mode 100644 index 00000000..79e980c2 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-mono-black-512.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-1024.png b/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-1024.png new file mode 100644 index 00000000..41e74946 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-2048.png b/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-2048.png new file mode 100644 index 00000000..dea0fcd1 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-512.png b/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-512.png new file mode 100644 index 00000000..08bc34ea Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-mono-white-512.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-1024.png b/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-1024.png new file mode 100644 index 00000000..0547e3fe Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-2048.png b/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-2048.png new file mode 100644 index 00000000..1329fe0d Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-512.png b/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-512.png new file mode 100644 index 00000000..081b4cc5 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-on-dark-512.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-on-light-1024.png b/assets/brand/logo/png/nerdos-logo-descriptor-on-light-1024.png new file mode 100644 index 00000000..6ac1d106 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-on-light-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-on-light-2048.png b/assets/brand/logo/png/nerdos-logo-descriptor-on-light-2048.png new file mode 100644 index 00000000..5dc6fbe1 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-on-light-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-descriptor-on-light-512.png b/assets/brand/logo/png/nerdos-logo-descriptor-on-light-512.png new file mode 100644 index 00000000..59c8115a Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-descriptor-on-light-512.png differ diff --git a/assets/brand/logo/png/nerdos-logo-mono-black-1024.png b/assets/brand/logo/png/nerdos-logo-mono-black-1024.png new file mode 100644 index 00000000..213f1535 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-mono-black-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-mono-black-2048.png b/assets/brand/logo/png/nerdos-logo-mono-black-2048.png new file mode 100644 index 00000000..de4c6713 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-mono-black-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-mono-black-512.png b/assets/brand/logo/png/nerdos-logo-mono-black-512.png new file mode 100644 index 00000000..87adb49d Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-mono-black-512.png differ diff --git a/assets/brand/logo/png/nerdos-logo-mono-white-1024.png b/assets/brand/logo/png/nerdos-logo-mono-white-1024.png new file mode 100644 index 00000000..746b9778 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-mono-white-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-mono-white-2048.png b/assets/brand/logo/png/nerdos-logo-mono-white-2048.png new file mode 100644 index 00000000..6e929317 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-mono-white-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-mono-white-512.png b/assets/brand/logo/png/nerdos-logo-mono-white-512.png new file mode 100644 index 00000000..32083a9a Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-mono-white-512.png differ diff --git a/assets/brand/logo/png/nerdos-logo-on-dark-1024.png b/assets/brand/logo/png/nerdos-logo-on-dark-1024.png new file mode 100644 index 00000000..a1cb4dcf Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-on-dark-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-on-dark-2048.png b/assets/brand/logo/png/nerdos-logo-on-dark-2048.png new file mode 100644 index 00000000..795fccbe Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-on-dark-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-on-dark-512.png b/assets/brand/logo/png/nerdos-logo-on-dark-512.png new file mode 100644 index 00000000..f9eef837 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-on-dark-512.png differ diff --git a/assets/brand/logo/png/nerdos-logo-on-light-1024.png b/assets/brand/logo/png/nerdos-logo-on-light-1024.png new file mode 100644 index 00000000..176fc151 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-on-light-1024.png differ diff --git a/assets/brand/logo/png/nerdos-logo-on-light-2048.png b/assets/brand/logo/png/nerdos-logo-on-light-2048.png new file mode 100644 index 00000000..8717bb2f Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-on-light-2048.png differ diff --git a/assets/brand/logo/png/nerdos-logo-on-light-512.png b/assets/brand/logo/png/nerdos-logo-on-light-512.png new file mode 100644 index 00000000..e77a50f1 Binary files /dev/null and b/assets/brand/logo/png/nerdos-logo-on-light-512.png differ diff --git a/assets/brand/logo/svg/nerdos-logo-descriptor-mono-black.svg b/assets/brand/logo/svg/nerdos-logo-descriptor-mono-black.svg new file mode 100644 index 00000000..5e277168 --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-descriptor-mono-black.svg @@ -0,0 +1,10 @@ + + NerdOS — Opinionated System + + + + + + + + diff --git a/assets/brand/logo/svg/nerdos-logo-descriptor-mono-white.svg b/assets/brand/logo/svg/nerdos-logo-descriptor-mono-white.svg new file mode 100644 index 00000000..df6f6081 --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-descriptor-mono-white.svg @@ -0,0 +1,10 @@ + + NerdOS — Opinionated System + + + + + + + + diff --git a/assets/brand/logo/svg/nerdos-logo-descriptor-on-dark.svg b/assets/brand/logo/svg/nerdos-logo-descriptor-on-dark.svg new file mode 100644 index 00000000..98ae26a2 --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-descriptor-on-dark.svg @@ -0,0 +1,10 @@ + + NerdOS — Opinionated System + + + + + + + + diff --git a/assets/brand/logo/svg/nerdos-logo-descriptor-on-light.svg b/assets/brand/logo/svg/nerdos-logo-descriptor-on-light.svg new file mode 100644 index 00000000..2e9303e8 --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-descriptor-on-light.svg @@ -0,0 +1,10 @@ + + NerdOS — Opinionated System + + + + + + + + diff --git a/assets/brand/logo/svg/nerdos-logo-mono-black.svg b/assets/brand/logo/svg/nerdos-logo-mono-black.svg new file mode 100644 index 00000000..2149f860 --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-mono-black.svg @@ -0,0 +1,9 @@ + + NerdOS + + + + + + + diff --git a/assets/brand/logo/svg/nerdos-logo-mono-white.svg b/assets/brand/logo/svg/nerdos-logo-mono-white.svg new file mode 100644 index 00000000..12acd3ac --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-mono-white.svg @@ -0,0 +1,9 @@ + + NerdOS + + + + + + + diff --git a/assets/brand/logo/svg/nerdos-logo-on-dark.svg b/assets/brand/logo/svg/nerdos-logo-on-dark.svg new file mode 100644 index 00000000..bbe2fab2 --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-on-dark.svg @@ -0,0 +1,9 @@ + + NerdOS + + + + + + + diff --git a/assets/brand/logo/svg/nerdos-logo-on-light.svg b/assets/brand/logo/svg/nerdos-logo-on-light.svg new file mode 100644 index 00000000..0367430a --- /dev/null +++ b/assets/brand/logo/svg/nerdos-logo-on-light.svg @@ -0,0 +1,9 @@ + + NerdOS + + + + + + + diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-black-1024.png b/assets/brand/symbol/png/nerdos-symbol-mono-black-1024.png new file mode 100644 index 00000000..1567e232 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-black-1024.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-black-128.png b/assets/brand/symbol/png/nerdos-symbol-mono-black-128.png new file mode 100644 index 00000000..d19fbbeb Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-black-128.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-black-256.png b/assets/brand/symbol/png/nerdos-symbol-mono-black-256.png new file mode 100644 index 00000000..765c3d04 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-black-256.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-black-512.png b/assets/brand/symbol/png/nerdos-symbol-mono-black-512.png new file mode 100644 index 00000000..80b33623 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-black-512.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-white-1024.png b/assets/brand/symbol/png/nerdos-symbol-mono-white-1024.png new file mode 100644 index 00000000..e3092b5a Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-white-1024.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-white-128.png b/assets/brand/symbol/png/nerdos-symbol-mono-white-128.png new file mode 100644 index 00000000..cb70c8a5 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-white-128.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-white-256.png b/assets/brand/symbol/png/nerdos-symbol-mono-white-256.png new file mode 100644 index 00000000..008159fb Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-white-256.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-mono-white-512.png b/assets/brand/symbol/png/nerdos-symbol-mono-white-512.png new file mode 100644 index 00000000..439e468a Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-mono-white-512.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-dark-1024.png b/assets/brand/symbol/png/nerdos-symbol-on-dark-1024.png new file mode 100644 index 00000000..72fe3a24 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-dark-1024.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-dark-128.png b/assets/brand/symbol/png/nerdos-symbol-on-dark-128.png new file mode 100644 index 00000000..2bfac0d0 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-dark-128.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-dark-256.png b/assets/brand/symbol/png/nerdos-symbol-on-dark-256.png new file mode 100644 index 00000000..1c79688e Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-dark-256.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-dark-512.png b/assets/brand/symbol/png/nerdos-symbol-on-dark-512.png new file mode 100644 index 00000000..286956fb Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-dark-512.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-light-1024.png b/assets/brand/symbol/png/nerdos-symbol-on-light-1024.png new file mode 100644 index 00000000..b31477f1 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-light-1024.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-light-128.png b/assets/brand/symbol/png/nerdos-symbol-on-light-128.png new file mode 100644 index 00000000..f28a630c Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-light-128.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-light-256.png b/assets/brand/symbol/png/nerdos-symbol-on-light-256.png new file mode 100644 index 00000000..9faafcdc Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-light-256.png differ diff --git a/assets/brand/symbol/png/nerdos-symbol-on-light-512.png b/assets/brand/symbol/png/nerdos-symbol-on-light-512.png new file mode 100644 index 00000000..84d31a60 Binary files /dev/null and b/assets/brand/symbol/png/nerdos-symbol-on-light-512.png differ diff --git a/assets/brand/symbol/png/small/nerdos-symbol-16px-on-dark.png b/assets/brand/symbol/png/small/nerdos-symbol-16px-on-dark.png new file mode 100644 index 00000000..03f52de3 Binary files /dev/null and b/assets/brand/symbol/png/small/nerdos-symbol-16px-on-dark.png differ diff --git a/assets/brand/symbol/png/small/nerdos-symbol-16px-on-light.png b/assets/brand/symbol/png/small/nerdos-symbol-16px-on-light.png new file mode 100644 index 00000000..5b1eb02c Binary files /dev/null and b/assets/brand/symbol/png/small/nerdos-symbol-16px-on-light.png differ diff --git a/assets/brand/symbol/png/small/nerdos-symbol-32px-on-dark.png b/assets/brand/symbol/png/small/nerdos-symbol-32px-on-dark.png new file mode 100644 index 00000000..b372344f Binary files /dev/null and b/assets/brand/symbol/png/small/nerdos-symbol-32px-on-dark.png differ diff --git a/assets/brand/symbol/png/small/nerdos-symbol-32px-on-light.png b/assets/brand/symbol/png/small/nerdos-symbol-32px-on-light.png new file mode 100644 index 00000000..c94205b1 Binary files /dev/null and b/assets/brand/symbol/png/small/nerdos-symbol-32px-on-light.png differ diff --git a/assets/brand/symbol/svg/nerdos-symbol-mono-black.svg b/assets/brand/symbol/svg/nerdos-symbol-mono-black.svg new file mode 100644 index 00000000..c37cd9cc --- /dev/null +++ b/assets/brand/symbol/svg/nerdos-symbol-mono-black.svg @@ -0,0 +1,8 @@ + + NerdOS glasses symbol + + + + + + diff --git a/assets/brand/symbol/svg/nerdos-symbol-mono-white.svg b/assets/brand/symbol/svg/nerdos-symbol-mono-white.svg new file mode 100644 index 00000000..52fadc8b --- /dev/null +++ b/assets/brand/symbol/svg/nerdos-symbol-mono-white.svg @@ -0,0 +1,8 @@ + + NerdOS glasses symbol + + + + + + diff --git a/assets/brand/symbol/svg/nerdos-symbol-on-dark.svg b/assets/brand/symbol/svg/nerdos-symbol-on-dark.svg new file mode 100644 index 00000000..a9b9005f --- /dev/null +++ b/assets/brand/symbol/svg/nerdos-symbol-on-dark.svg @@ -0,0 +1,8 @@ + + NerdOS glasses symbol + + + + + + diff --git a/assets/brand/symbol/svg/nerdos-symbol-on-light.svg b/assets/brand/symbol/svg/nerdos-symbol-on-light.svg new file mode 100644 index 00000000..b50f23e0 --- /dev/null +++ b/assets/brand/symbol/svg/nerdos-symbol-on-light.svg @@ -0,0 +1,8 @@ + + NerdOS glasses symbol + + + + + + diff --git a/assets/brand/symbol/svg/small/nerdos-symbol-16px-on-dark.svg b/assets/brand/symbol/svg/small/nerdos-symbol-16px-on-dark.svg new file mode 100644 index 00000000..37f00a62 --- /dev/null +++ b/assets/brand/symbol/svg/small/nerdos-symbol-16px-on-dark.svg @@ -0,0 +1,5 @@ + + NerdOS glasses symbol, 16px-hinted + + + diff --git a/assets/brand/symbol/svg/small/nerdos-symbol-16px-on-light.svg b/assets/brand/symbol/svg/small/nerdos-symbol-16px-on-light.svg new file mode 100644 index 00000000..882061d1 --- /dev/null +++ b/assets/brand/symbol/svg/small/nerdos-symbol-16px-on-light.svg @@ -0,0 +1,5 @@ + + NerdOS glasses symbol, 16px-hinted + + + diff --git a/assets/brand/symbol/svg/small/nerdos-symbol-32px-on-dark.svg b/assets/brand/symbol/svg/small/nerdos-symbol-32px-on-dark.svg new file mode 100644 index 00000000..30ac082c --- /dev/null +++ b/assets/brand/symbol/svg/small/nerdos-symbol-32px-on-dark.svg @@ -0,0 +1,5 @@ + + NerdOS glasses symbol, 32px-hinted + + + diff --git a/assets/brand/symbol/svg/small/nerdos-symbol-32px-on-light.svg b/assets/brand/symbol/svg/small/nerdos-symbol-32px-on-light.svg new file mode 100644 index 00000000..683c5fdc --- /dev/null +++ b/assets/brand/symbol/svg/small/nerdos-symbol-32px-on-light.svg @@ -0,0 +1,5 @@ + + NerdOS glasses symbol, 32px-hinted + + + diff --git a/assets/desktop-contract.json b/assets/desktop-contract.json index b5b7278e..2f9fe17a 100644 --- a/assets/desktop-contract.json +++ b/assets/desktop-contract.json @@ -8,16 +8,16 @@ "userHelpers": [ "screenshot", "screen-record", "quickshell-reminder", "screen-ocr", "clipboard-copy-image", "clipboard-image-to-file", "clipboard-history-store", - "localsend", "localsend-share", "cybexos-agent", "cybexos-firmware-update", - "cybexos-webapp", + "localsend", "localsend-share", "nerdos-agent", "nerdos-firmware-update", + "nerdos-webapp", "game-mode", - "cybexos-report", - "cybexos-backup", - "cybexos-theme", + "nerdos-report", + "nerdos-backup", + "nerdos-theme", "color-picker", "screenshot-stitch" ], "essentialPaths": [ - ".config/cybexos/shell.json", ".config/fastfetch", ".config/fish", + ".config/nerdos/shell.json", ".config/fastfetch", ".config/fish", ".config/kitty", ".config/oh-my-posh", ".config/voxtype/config.toml", ".config/mimeapps.list", ".local/share/applications", ".local/libexec", ".local/share/nautilus-python", "Pictures/Wallpapers", diff --git a/assets/distribution-review.json b/assets/distribution-review.json index 23b4b99e..b4f62e8b 100644 --- a/assets/distribution-review.json +++ b/assets/distribution-review.json @@ -1,12 +1,9 @@ { "schema": 1, - "publication": "PUBLIC", - "reviews": [], - "unresolved": { - "source": "Owner review of third-party artwork, code notices, Plymouth, wordmarks and trademark use is required for the actual archive.", - "desktop-rpm": "Owner review of the actual RPM payload, artwork, third-party notices and marks is required.", - "offline-rpm": "Vendor redistribution grants, SDK acceptance, font obligations and corresponding source are unresolved; inspect the actual payload.", - "iso": "Actual RPM/Flatpak inventories, firmware, proprietary payloads, codec patents, OpenH264, corresponding source and Fedora/vendor trademarks require owner/legal decisions.", - "web": "Review actual compiled web payload, dependency/font notices, wordmarks and provider/Fedora trademark use. Original wallpaper provenance does not resolve these decisions." + "status": "owner-review-required", + "scopes": { + "iso": "Review actual Nix closure, firmware, codecs, proprietary payloads, fonts, artwork, third-party notices and corresponding-source obligations.", + "source": "Review source/license notices and bundled extension redistribution.", + "web": "Review compiled web dependencies, fonts, marks and artwork." } } diff --git a/assets/scripts/brightness-control b/assets/scripts/brightness-control new file mode 100755 index 00000000..22254d3b --- /dev/null +++ b/assets/scripts/brightness-control @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Display brightness, for whichever panel is actually in front of you. +# +# A laptop panel is a sysfs backlight and brightnessctl writes it. An Apple +# Studio Display is not a backlight device at all — it takes brightness over +# USB HID, which is what asdcontrol speaks. Running brightnessctl while a +# Studio Display is the only screen moves the closed laptop panel instead and +# looks like nothing happened, so every caller comes through here rather than +# picking a tool for itself. +# +# up [step] raise by step percent (default 5) +# down [step] lower by step percent +# set set an absolute percent +# get print the current percent, and nothing else +# +# The three writers ping the shell's OSD. `get` stays silent: the shell calls +# it, and pinging the OSD from a read would loop. + +usage() { + printf 'usage: %s up|down [step] | set | get\n' "${0##*/}" >&2 + exit 2 +} + +verb=${1:-} +[[ -n $verb ]] || usage + +# Apple's HID brightness range, from the device table asdcontrol is built +# with — see nix/packages/asdcontrol.nix, where every supported +# display maps 0%..100% onto these bounds. +asd_min=400 +asd_max=60000 + +cache=${XDG_RUNTIME_DIR:-/tmp}/brightness-control.hiddev + +# The HID nodes detection walks. Overridable so tests/brightness-routing can +# point it at fixtures rather than at whatever is plugged into the machine +# running the suite. +hid_nodes=${BRIGHTNESS_HID_NODES:-"/dev/usb/hiddev* /dev/hiddev*"} + +# resolve_device fills both: the node to drive, and the level it read while +# proving that node. They are globals rather than output because a command +# substitution runs in a subshell, which would drop the reading and make every +# `get` pay for a second one. +device="" +raw="" + +clamp() { + local value=$1 + (( value < 0 )) && value=0 + (( value > 100 )) && value=100 + printf '%s' "$value" +} + +# Is the screen being looked at an Apple display we can drive at all? The +# tool check comes first: a held key runs this ~40 times a second, and without +# asdcontrol the answer is no before hyprctl and jq are worth starting. +apple_display() { + local make + command -v asdcontrol >/dev/null 2>&1 || return 1 + make=$(hyprctl monitors -j 2>/dev/null \ + | jq -r '.[] | select(.focused == true) | .make // ""') || return 1 + grep -qi apple <<<"$make" +} + +# Walk every HID device asking which one is a display. This is the expensive +# path — asdcontrol opens each node in turn, ~170ms — so its answer is cached. +detect_device() { + local device + # Deliberately unquoted: the value is a list of globs to expand. + # shellcheck disable=SC2086 + for device in $hid_nodes; do + [[ -e $device ]] || continue + if asdcontrol --silent --detect "$device" 2>/dev/null | grep -q SUPPORTED; then + printf '%s' "$device" >"$cache" 2>/dev/null || true + printf '%s' "$device" + return 0 + fi + done + rm -f "$cache" 2>/dev/null || true + return 1 +} + +# The node to drive. A cached one is confirmed by reading its brightness +# rather than by --detect: the read costs a third as much and a numeric answer +# is the same proof. It has to be proved either way, because writing to a node +# that turned out not to be the display is writing to some other HID device. +resolve_device() { + local candidate + if [[ -r $cache ]]; then + candidate=$(<"$cache") + if [[ -n $candidate && -e $candidate ]]; then + raw=$(asdcontrol --silent --brief "$candidate" 2>/dev/null) || raw="" + if [[ $raw =~ ^[0-9]+$ ]]; then + device=$candidate + return 0 + fi + raw="" + fi + fi + device=$(detect_device) || { device=""; return 1; } +} + +# asdcontrol reports the raw HID level, so the percent is ours to compute; it +# accepts a percent on the way in and does the same arithmetic itself. +asd_percent() { + [[ $raw =~ ^[0-9]+$ ]] || raw=$(asdcontrol --silent --brief "$1" 2>/dev/null) || return 1 + [[ $raw =~ ^[0-9]+$ ]] || return 1 + clamp $(( (raw - asd_min) * 100 / (asd_max - asd_min) )) +} + +# Nudge the shell's OSD. Neither backend emits anything watchable — sysfs +# backlight attributes deliver no inotify events and a HID write emits none — +# so the pill is shown, and the percentage re-read, over IPC instead. The +# shell runs by path, so a bare `qs ipc call` reaches nothing; nerdos-runtime +# names the active runtime. Every step is pinged, never rate-limited: a skipped +# last step would leave the pill showing the level before it. +ping_osd() { + local runtime=${HOME:-}/.local/bin/nerdos-runtime + [[ -x $runtime ]] && "$runtime" ipc osd brightness >/dev/null 2>&1 || true +} + +if apple_display; then + resolve_device || device="" +fi + +case $verb in +up|down) + step=${2:-5} + if [[ -n $device ]]; then + if [[ $verb == up ]]; then + asdcontrol --silent --brief "$device" "+$step%" >/dev/null + else + asdcontrol --silent --brief "$device" -- "-$step%" >/dev/null + fi + elif [[ $verb == up ]]; then + brightnessctl set "$step%+" >/dev/null + else + brightnessctl set "$step%-" >/dev/null + fi + ping_osd + ;; +set) + target=${2:?percent required} + [[ $target =~ ^[0-9]+$ ]] || usage + target=$(clamp "$target") + if [[ -n $device ]]; then + asdcontrol --silent --brief "$device" "$target%" >/dev/null + else + # A sysfs backlight at zero is a black screen with no way back from this + # control, so the floor there is one percent rather than none. The Apple + # range has its own floor built in: 0% maps to 400, not to off. + (( target < 1 )) && target=1 + brightnessctl set "$target%" >/dev/null + fi + ping_osd + ;; +get) + if [[ -n $device ]]; then + asd_percent "$device" + else + brightnessctl -m 2>/dev/null | head -1 | cut -d, -f4 | tr -d '%' + fi + ;; +*) + usage + ;; +esac diff --git a/assets/scripts/cybexos-agent b/assets/scripts/cybexos-agent deleted file mode 100644 index 1c90eeeb..00000000 --- a/assets/scripts/cybexos-agent +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env bash -# Select and launch one supported coding agent without owning its credentials -# or permission policy. Desktop callers use --window; terminal callers keep -# their current working directory and TTY. -set -uo pipefail - -umask 077 - -config_home=${XDG_CONFIG_HOME:-${HOME:?HOME is required}/.config} -defaults_dir="$config_home/cybexos/defaults" -preference_file="$defaults_dir/agent" -window_child=false -temporary= -chosen_agent= - -cleanup() { - [[ -z ${temporary:-} || ! -e $temporary ]] || rm -f -- "$temporary" -} -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - -pause_after_error() { - if [[ $window_child == true && -t 0 ]]; then - printf '\nPress Enter to close.' >&2 - IFS= read -r || true - fi -} - -fail() { - local status=$1 - shift - printf 'cybex agent: %s\n' "$*" >&2 - pause_after_error - exit "$status" -} - -usage() { - cat <<'EOF' -Usage: cybex agent [--window] [--pick] - cybex agent get - cybex agent set [opencode|claude|codex] - cybex agent unset - cybex agent list - cybex agent prompt - -With no subcommand, launch the selected agent in the current directory. The -first interactive launch asks for a default. --pick always asks before launch, -and --window opens the agent in a dedicated Kitty window rooted at ~/Code. - -The launcher does not add automatic-approval or permission-bypass flags. -Authentication and agent-specific settings remain owned by the selected CLI. -EOF -} - -supported_agent() { - case $1 in - opencode|claude|codex) return 0 ;; - *) return 1 ;; - esac -} - -agent_label() { - case $1 in - opencode) printf 'OpenCode' ;; - claude) printf 'Claude Code' ;; - codex) printf 'Codex CLI' ;; - esac -} - -agent_description() { - case $1 in - opencode) printf 'provider-neutral, hosted or local models' ;; - claude) printf 'Anthropic account' ;; - codex) printf 'ChatGPT account or OpenAI API key' ;; - esac -} - -agent_available() { - command -v -- "$1" >/dev/null 2>&1 -} - -load_default() { - local selected - [[ -f $preference_file && -r $preference_file ]] || return 1 - selected=$(<"$preference_file") - supported_agent "$selected" || return 2 - printf '%s\n' "$selected" -} - -store_default() { - local selected=$1 - supported_agent "$selected" \ - || fail 2 "unsupported agent '$selected' (choose opencode, claude, or codex)" - agent_available "$selected" \ - || fail 127 "$(agent_label "$selected") is not installed; run 'cybex configure'" - mkdir -p -- "$defaults_dir" \ - || fail 1 "could not create $defaults_dir" - temporary=$(mktemp "$defaults_dir/.agent.XXXXXX") \ - || fail 1 "could not stage the default-agent preference" - printf '%s\n' "$selected" >"$temporary" \ - || fail 1 "could not write the default-agent preference" - chmod 0600 "$temporary" \ - || fail 1 "could not protect the default-agent preference" - mv -fT -- "$temporary" "$preference_file" \ - || fail 1 "could not activate the default-agent preference" - temporary= - printf 'Default AI agent: %s\n' "$(agent_label "$selected")" -} - -pick_agent() { - local agent choice - local choices=() - - for agent in opencode claude codex; do - if agent_available "$agent"; then - choices+=("$agent"$'\t'"$(agent_label "$agent") - $(agent_description "$agent")") - fi - done - ((${#choices[@]} > 0)) \ - || fail 127 "no supported AI agent is installed; run 'cybex configure'" - # stdout is intentionally captured by the caller so only the selected ID - # flows into the adapter; stdin remains the reliable interactive boundary. - [[ -t 0 ]] \ - || fail 1 "no default is selected; run 'cybex agent set' in a terminal" - agent_available fzf \ - || fail 127 "fzf is required to choose a default AI agent" - - choice=$(printf '%s\n' "${choices[@]}" | fzf \ - --height=40% --layout=reverse --border \ - --delimiter=$'\t' --with-nth=2.. --prompt='Default AI agent> ') || return 130 - agent=${choice%%$'\t'*} - supported_agent "$agent" && agent_available "$agent" \ - || fail 1 "the agent picker returned an invalid selection" - printf '%s\n' "$agent" -} - -choose_default() { - local status - chosen_agent=$(pick_agent) - status=$? - ((status == 0)) || return "$status" - store_default "$chosen_agent" -} - -launch_agent() { - local force_pick=$1 prompt_mode=$2 prompt=${3:-} - local selected status - local command=() - - if [[ $force_pick == true ]]; then - choose_default - status=$? - ((status == 0)) || return "$status" - selected=$chosen_agent - else - selected=$(load_default) - status=$? - if ((status != 0)); then - if ((status == 2)); then - printf 'cybex agent: ignoring invalid preference in %s\n' \ - "$preference_file" >&2 - fi - choose_default - status=$? - ((status == 0)) || return "$status" - selected=$chosen_agent - fi - fi - agent_available "$selected" \ - || fail 127 "$(agent_label "$selected") is selected but its '$selected' command is missing" - - case $selected in - opencode) - command=(opencode) - [[ $prompt_mode == false ]] || command+=(--prompt "$prompt") - ;; - claude) - command=(claude) - [[ $prompt_mode == false ]] || command+=(-- "$prompt") - ;; - codex) - command=(codex) - [[ $prompt_mode == false ]] || command+=(-- "$prompt") - ;; - esac - - exec "${command[@]}" - fail 127 "could not launch $(agent_label "$selected")" -} - -launch_window() { - local workspace launcher - agent_available kitty || fail 127 "Kitty is not installed" - launcher=$(command -v cybex) \ - || fail 127 "the cybex command is not installed" - workspace=${CYBEXOS_AGENT_WORKSPACE:-$HOME/Code} - [[ -d $workspace ]] || workspace=$HOME - exec kitty --class cybexos-agent --directory "$workspace" \ - -e "$launcher" agent --window-child "$@" - fail 127 "could not open the AI agent terminal" -} - -list_agents() { - local agent selected='' status marker availability - selected=$(load_default) - status=$? - ((status == 0)) || selected= - for agent in opencode claude codex; do - marker=' ' - [[ $agent != "$selected" ]] || marker='*' - availability=missing - agent_available "$agent" && availability=installed - printf '%s %-8s %-9s %s\n' "$marker" "$agent" "$availability" \ - "$(agent_label "$agent")" - done -} - -join_prompt() { - local value=$1 part - shift - for part in "$@"; do - value+=" $part" - done - printf '%s' "$value" -} - -main() { - local selected status prompt - - if [[ ${1:-} == --window ]]; then - shift - launch_window "$@" - fi - if [[ ${1:-} == --window-child ]]; then - window_child=true - shift - fi - - case ${1:-} in - '' ) - launch_agent false false - ;; - --pick|pick) - shift - (($# == 0)) || fail 2 "--pick does not accept arguments" - launch_agent true false - ;; - get) - shift - (($# == 0)) || fail 2 "get does not accept arguments" - selected=$(load_default) - status=$? - case $status in - 0) printf '%s\n' "$selected" ;; - 1) fail 1 "no default AI agent is selected" ;; - 2) fail 1 "the preference in $preference_file is invalid" ;; - esac - ;; - set) - shift - (($# <= 1)) || fail 2 "set accepts at most one agent name" - if (($# == 1)); then - store_default "$1" - else - selected=$(pick_agent) - status=$? - ((status == 0)) || return "$status" - store_default "$selected" - fi - ;; - unset) - shift - (($# == 0)) || fail 2 "unset does not accept arguments" - if [[ -e $preference_file || -L $preference_file ]]; then - rm -f -- "$preference_file" \ - || fail 1 "could not remove the default-agent preference" - printf 'Default AI agent cleared.\n' - fi - ;; - list) - shift - (($# == 0)) || fail 2 "list does not accept arguments" - list_agents - ;; - prompt) - shift - (($# > 0)) || fail 2 "prompt requires text" - prompt=$(join_prompt "$@") - [[ -n $prompt ]] || fail 2 "prompt requires non-empty text" - launch_agent false true "$prompt" - ;; - help|-h|--help) - usage - ;; - *) - fail 2 "unknown option or subcommand '$1'" - ;; - esac -} - -main "$@" diff --git a/assets/scripts/cybexos-backup b/assets/scripts/cybexos-backup deleted file mode 100755 index 2a9b3d1a..00000000 --- a/assets/scripts/cybexos-backup +++ /dev/null @@ -1,1531 +0,0 @@ -#!/usr/bin/python3 -I -"""Encrypted personal-file backups with restic. - -`cybex backup` and Settings -> Backup & recovery both drive this helper: - - cybexos-backup setup local PATH [--existing] [--json] - cybexos-backup setup sftp HOST [PATH] [--existing] [--json] - cybexos-backup confirm-key - cybexos-backup recovery-key [--json] - cybexos-backup now [--notify] - cybexos-backup scheduled - cybexos-backup status [--json] - cybexos-backup snapshots [--json] - cybexos-backup restore [SNAPSHOT] [--path PATH] [--open] [--notify] - cybexos-backup automatic on|off - cybexos-backup excludes - -The repository is always encrypted. A random password kept in the GNOME -Keyring opens it on this computer; a separate recovery key, shown once at -setup, opens it anywhere. Losing both loses the backups, so setup asks the -user to save the key before the first backup. - -A restore always goes into a new folder under ~/Restored and never writes -over the home folder. `scheduled` runs from a user timer during the desktop -session: it backs up at most once a day, quietly skips while the destination -is unreachable, on battery below 30 % or on a metered network, and reminds -once a day when the last backup is more than a week old. - -Invoked as app-backup or browser-backup it is the earlier opt-in browser -profile export: an age-encrypted tarball for the recipient in AGE_RECIPIENT. - -The interpreter runs in isolated mode so a backup never imports modules from -the user's site directory or the working directory. -""" - -from __future__ import annotations - -import argparse -import collections -import contextlib -import fcntl -import getpass -import json -import os -import re -import secrets -import shutil -import signal -import socket -import subprocess -import sys -import tempfile -import threading -import time -import uuid -from datetime import datetime, timedelta, timezone -from pathlib import Path - -EXIT_OK = 0 -EXIT_FAILED = 1 -EXIT_USAGE = 2 -EXIT_NOT_CONFIGURED = 3 -# The destination already holds backups; connecting needs their recovery key. -EXIT_EXISTING = 4 -EXIT_BUSY = 75 - -TAG = "cybexos" -REPOSITORY_FOLDER = "cybexos-backup" -KEYRING_APPLICATION = "cybexos-backup" -# restic forget: what the history keeps. -RETENTION = (("--keep-daily", "7"), ("--keep-weekly", "4"), ("--keep-monthly", "6")) -# Daily, with an hour's slack so the hourly check does not push each day's -# backup an hour later than the last. -DUE_AFTER = timedelta(hours=23) -REMIND_AFTER = timedelta(days=7) -MAINTAIN_AFTER = timedelta(days=7) -BATTERY_THRESHOLD = 30 -POWER_SUPPLY = Path("/sys/class/power_supply") -# Crockford's base32: no I, L, O or U, so a written-down key reads back. -RECOVERY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" -RECOVERY_GROUPS = 6 -RECOVERY_GROUP_LENGTH = 5 -# The same trust rules as the Remote Server widget: the user's SSH keys and -# ~/.ssh/config, never a password prompt, never an unknown host key. -SSH_ARGS = ("-o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=15 " - "-o ServerAliveInterval=60 -o ServerAliveCountMax=240") -HOST_PATTERN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.@%-]{0,253}") -REMOTE_PATH_PATTERN = re.compile(r"/?[A-Za-z0-9_][A-Za-z0-9_. +/-]{0,250}") -SNAPSHOT_PATTERN = re.compile(r"latest|[0-9a-f]{8,64}") -# Top-level folders that never take a backup unless a drive is mounted there. -SYSTEM_FOLDERS = frozenset(("bin", "boot", "dev", "etc", "lib", "lib64", "proc", "root", "run", - "sbin", "sys", "usr")) - -# Relative to the home folder: caches, the trash and everything that is -# downloaded or rebuilt rather than made by the user. Personal files, -# settings and application data stay in. Add more, one restic pattern per -# line, in ~/.config/cybexos/backup-exclude. -HOME_EXCLUDES = ( - ".cache", - ".local/share/Trash", - ".var/app/*/cache", - # Image/build stores include disposable writable layers; named volumes - # remain personal data when readable by this user. Private subordinate-UID - # data needs a readable export; live databases also need consistency. - ".local/share/containers/storage/overlay", - ".local/share/containers/storage/overlay-images", - ".local/share/containers/storage/overlay-layers", - ".local/share/containers/storage/vfs", - ".local/share/containers/storage/vfs-images", - ".local/share/containers/storage/vfs-layers", - ".local/share/containers/cache", - ".local/share/docker/overlay2", - ".local/share/docker/image", - ".local/share/docker/buildkit", - ".local/share/docker/containerd", - ".local/share/flatpak", - ".local/share/cybexos/runtime", - ".local/share/cybexos/releases", - ".local/share/cybexos/current", - ".local/share/nvim/lazy", - ".local/share/nvim/mason", - ".local/share/voxtype/models", - ".cargo/registry", - ".cargo/git", - ".rustup", - ".npm", - ".npm-global", - ".bun/install/cache", - "go/pkg/mod", - ".m2/repository", - ".gradle/caches", - ".gradle/wrapper/dists", - "Android/Sdk", - ".android/avd", - ".ollama/models", - # Chromium, Brave, Chrome and Electron applications keep their caches - # beside the profile instead of under ~/.cache. - ".config/**/Cache", - ".config/**/Code Cache", - ".config/**/GPUCache", - ".config/**/DawnCache", - ".config/**/GrShaderCache", - ".config/**/ShaderCache", - ".config/**/Service Worker/CacheStorage", - ".config/**/component_crx_cache", -) + tuple( - root + "/" + relative - for root in (".local/share/Steam", ".steam", ".steam/steam", ".steam/root", - ".var/app/com.valvesoftware.Steam/.local/share/Steam") - for relative in ("appcache", "depotcache", "package", "logs", "config/htmlcache", - "steamapps/common", "steamapps/downloading", "steamapps/shadercache", - "steamapps/temp", "steamapps/workshop/downloads") -) -BACKUP_COVERAGE = ( - "Includes readable home files, Steam userdata and Proton saves, and readable rootless container volumes. " - "Skips caches, downloaded games, container images and writable layers, and mounted filesystems. " - "Run cybex backup excludes for all exclusions." -) -BACKUP_CONSISTENCY = ( - "Live database files may be inconsistent. Export databases to a home folder readable by your account " - "before backup (for example pg_dump or mysqldump), or stop the database for the whole backup. " - "Private container volumes owned by subordinate UIDs require readable exports; stopping the database " - "alone does not make those files readable. " - "Keep persistent container data in volumes or home bind mounts; system paths and mounts " - "outside the home filesystem are not included." -) -# Dependency trees and build output, wherever a project keeps them. Cargo's -# target/ and most tool caches carry CACHEDIR.TAG, which --exclude-caches -# already honours. -ANYWHERE_EXCLUDES = ( - "node_modules", - "__pycache__", - ".tox", - ".nox", - ".next", - ".nuxt", - ".svelte-kit", - ".turbo", - ".parcel-cache", - ".zig-cache", - "zig-cache", -) -BROWSER_PROFILES = ( - ".config/mozilla/firefox", ".mozilla/firefox", ".config/BraveSoftware/Brave", - ".config/BraveSoftware/Brave-Browser", ".config/BraveSoftware/Brave-Origin", - ".config/google-chrome", -) - - -class Failure(Exception): - """A failure to report in plain words, with its exit status.""" - - def __init__(self, message, status=EXIT_FAILED, *, result="failed"): - super().__init__(message) - self.message = message - self.status = status - self.result = result - - -class Context: - """Where this account keeps its backup configuration and state.""" - - def __init__(self, environ=None): - environ = os.environ if environ is None else environ - self.home = Path(environ.get("HOME") or Path.home()) - config = Path(environ.get("XDG_CONFIG_HOME") or self.home / ".config") - state = Path(environ.get("XDG_STATE_HOME") or self.home / ".local/state") - runtime = Path(environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") - self.config_path = config / "cybexos/backup.json" - self.user_excludes = config / "cybexos/backup-exclude" - self.state_path = state / "cybexos/backup.json" - self.runtime = runtime / "cybexos" - self.lock_path = self.runtime / "backup.lock" - self.progress_path = self.runtime / "backup-progress.json" - - -def utcnow(): - return datetime.now(timezone.utc) - - -def stamp(moment): - return moment.astimezone(timezone.utc).isoformat(timespec="seconds") - - -def parse_time(value): - if not isinstance(value, str) or not value: - return None - # restic writes nanoseconds; datetime takes at most microseconds. - value = re.sub(r"(\.\d{6})\d+", r"\1", value.strip()) - try: - moment = datetime.fromisoformat(value) - except ValueError: - return None - return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) - - -def read_json(path, default): - try: - data = json.loads(path.read_text()) - except (OSError, ValueError): - return default - return data if isinstance(data, type(default)) else default - - -def write_json(path, data, mode=0o600): - """Replace a file atomically, so a reader never sees half of it.""" - path.parent.mkdir(parents=True, exist_ok=True) - descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix="." + path.name + ".") - try: - with os.fdopen(descriptor, "w") as stream: - json.dump(data, stream, indent=2) - stream.write("\n") - os.chmod(temporary, mode) - os.replace(temporary, path) - except BaseException: - with contextlib.suppress(OSError): - os.unlink(temporary) - raise - - -def load_config(context): - config = read_json(context.config_path, {}) - if config.get("version") != 1 or not isinstance(config.get("destination"), dict) \ - or not isinstance(config.get("repository"), str) or not config.get("keyId"): - return None - return config - - -def require_config(context): - config = load_config(context) - if config is None: - raise Failure("Backups are not set up. Run cybex backup setup, or open Settings, " - "Backup & recovery.", EXIT_NOT_CONFIGURED) - return config - - -def load_state(context): - return read_json(context.state_path, {}) - - -def save_state(context, state): - write_json(context.state_path, state) - - -def update_state(context, **changes): - state = load_state(context) - state.update(changes) - save_state(context, state) - return state - - -# ---- Destinations ----------------------------------------------------------- - -def is_repository(path): - path = Path(path) - return (path / "config").is_file() and (path / "keys").is_dir() and (path / "data").is_dir() - - -def mount_point(path): - """The mount point holding `path`, or its nearest existing ancestor.""" - path = Path(os.path.abspath(path)) - while not path.exists(): - path = path.parent - while not os.path.ismount(path): - path = path.parent - return path - - -def drive_label(mount): - """A removable drive's name: udisks mounts it at /run/media/USER/LABEL.""" - parts = Path(mount).parts - if len(parts) == 5 and parts[1:3] == ("run", "media"): - return parts[4] - if len(parts) == 3 and parts[1] == "media": - return parts[2] - return "" - - -def local_destination(context, chosen): - """Validate a folder chosen for backups and name the repository in it.""" - if not isinstance(chosen, str) or not chosen.strip() or "\n" in chosen or "\0" in chosen: - raise Failure("Choose a folder for the backups.", EXIT_USAGE) - text = chosen.strip() - if text == "~" or text.startswith("~/"): - text = str(context.home) + text[1:] - path = Path(os.path.normpath(text)) - if not path.is_absolute(): - raise Failure("Choose the backup folder by its full path.", EXIT_USAGE) - if path == Path("/") or (path.parts[1] in SYSTEM_FOLDERS and not drive_label(mount_point(path))): - raise Failure(f"{path} is a system folder. Choose a folder on a backup drive.", EXIT_USAGE) - nearest = path - while not nearest.exists(): - nearest = nearest.parent - if not nearest.is_dir(): - raise Failure(f"{nearest} is not a folder.", EXIT_USAGE) - if is_repository(path) or path.name == REPOSITORY_FOLDER: - repository = path - else: - repository = path / REPOSITORY_FOLDER - mount = mount_point(repository) - home = context.home.resolve() - inside_home = repository == home or home in repository.parents - # A backup inside the home folder would back itself up and share the - # disk it is meant to survive. A separately mounted drive is fine. - if inside_home and mount == mount_point(home): - raise Failure("Choose a folder outside your home folder, such as an external drive.", - EXIT_USAGE) - label = drive_label(mount) - display = label if label else str(repository) - if label and repository != mount: - display = label + " · " + str(repository.relative_to(mount)) - destination = {"type": "local", "path": str(repository), "mount": str(mount), - "label": label, "display": display} - return destination, str(repository) - - -def remote_destination(host, path): - host = (host or "").strip() - path = (path or REPOSITORY_FOLDER).strip().rstrip("/") or REPOSITORY_FOLDER - if not HOST_PATTERN.fullmatch(host): - raise Failure("Enter an SSH alias or user@hostname; set ports in ~/.ssh/config.", - EXIT_USAGE) - if not REMOTE_PATH_PATTERN.fullmatch(path) or ".." in Path(path).parts: - raise Failure("Enter the folder on the server as a plain path, such as " - "backups/laptop.", EXIT_USAGE) - destination = {"type": "sftp", "host": host, "path": path, "label": "", - "display": host + ":" + path} - return destination, f"sftp:{host}:{path}" - - -def destination_reachable(destination): - """(reachable, reason) without touching the repository's contents. - - A removable drive must still be mounted where it was: an unmounted drive - leaves an empty directory behind on the root filesystem, and a backup - written there would fill the system disk instead. - """ - if destination.get("type") == "local": - mount = destination.get("mount") or "/" - name = destination.get("label") or destination.get("display") or mount - if not os.path.ismount(mount): - return False, f"{name} is not connected." - if not is_repository(destination.get("path", "")): - return False, f"The backup folder on {name} is missing." - return True, "" - return None, "" - - -def ssh_endpoint(host): - """Hostname and port from the user's SSH configuration, or None when - a proxy decides how the connection is made.""" - try: - result = subprocess.run(["ssh", "-G", "--", host], capture_output=True, text=True, - timeout=10, check=False) - except (OSError, subprocess.TimeoutExpired): - return None - if result.returncode != 0: - return None - options = {} - for line in result.stdout.splitlines(): - key, _, value = line.partition(" ") - options.setdefault(key.lower(), value.strip()) - for proxy in ("proxyjump", "proxycommand"): - if options.get(proxy, "none") not in ("", "none"): - return None - try: - return options["hostname"], int(options.get("port") or 22) - except (KeyError, ValueError): - return None - - -def remote_reachable(host, timeout=8): - """False only when the server's SSH port certainly does not answer.""" - endpoint = ssh_endpoint(host) - if endpoint is None: - return None - try: - with socket.create_connection(endpoint, timeout=timeout): - return True - except OSError: - return False - - -def low_battery(root=None, threshold=BATTERY_THRESHOLD): - """Running on battery below the threshold. Device batteries (a mouse, a - headset) report scope Device and say nothing about this computer.""" - root = POWER_SUPPLY if root is None else root - capacities = [] - external = False - try: - supplies = sorted(Path(root).iterdir()) - except OSError: - return False - - def read(path): - try: - return path.read_text().strip() - except OSError: - return "" - - for supply in supplies: - kind = read(supply / "type") - if kind == "Battery": - if read(supply / "scope") == "Device": - continue - if read(supply / "status") == "Charging": - external = True - with contextlib.suppress(ValueError): - capacities.append(int(read(supply / "capacity"))) - elif read(supply / "online") == "1": - external = True - if external or not capacities: - return False - return sum(capacities) / len(capacities) < threshold - - -def metered_network(): - """NetworkManager's verdict: 1 is metered, 3 guessed metered.""" - try: - result = subprocess.run( - ["busctl", "--system", "get-property", "org.freedesktop.NetworkManager", - "/org/freedesktop/NetworkManager", "org.freedesktop.NetworkManager", "Metered"], - capture_output=True, text=True, timeout=5, check=False) - except (OSError, subprocess.TimeoutExpired): - return False - parts = result.stdout.split() - return result.returncode == 0 and len(parts) == 2 and parts[1] in ("1", "3") - - -# ---- Keyring ---------------------------------------------------------------- - -def keyring_attributes(key_id): - return ["application", KEYRING_APPLICATION, "repository", key_id] - - -def keyring_store(key_id, password, display): - try: - result = subprocess.run( - ["secret-tool", "store", "--label=CybexOS backup (" + display + ")", - *keyring_attributes(key_id)], - input=password, capture_output=True, text=True, timeout=120, check=False) - except FileNotFoundError as error: - raise Failure("secret-tool is not installed (package libsecret).") from error - except subprocess.TimeoutExpired as error: - raise Failure("The keyring did not answer. Unlock it and try again.") from error - if result.returncode != 0: - raise Failure("The backup password could not be saved in the keyring. " - "Unlock the keyring and try again.") - - -def keyring_clear(key_id): - with contextlib.suppress(OSError, subprocess.TimeoutExpired): - subprocess.run(["secret-tool", "clear", *keyring_attributes(key_id)], - capture_output=True, timeout=30, check=False) - - -class KeyringLocked(Failure): - pass - - -def keyring_password(key_id, interactive): - """The repository password from the keyring. - - `lookup` asks to unlock a locked keyring. A scheduled run must not put - that prompt in front of the user, so it searches without unlocking: a - locked item is listed without its secret, and the run is skipped. - """ - try: - if interactive: - result = subprocess.run(["secret-tool", "lookup", *keyring_attributes(key_id)], - capture_output=True, text=True, timeout=120, check=False) - if result.returncode == 0 and result.stdout: - return result.stdout - # A declined or impossible unlock prompt also ends here, and the - # search tells a locked keyring apart from a missing password. - result = subprocess.run(["secret-tool", "search", *keyring_attributes(key_id)], - capture_output=True, text=True, timeout=30, check=False) - except FileNotFoundError as error: - raise Failure("secret-tool is not installed (package libsecret).") from error - except subprocess.TimeoutExpired as error: - raise KeyringLocked("The keyring did not answer. Unlock it and try again.") from error - listed = False - for line in result.stdout.splitlines(): - if line.startswith("["): - listed = True - elif line.startswith("secret = "): - return line[len("secret = "):] - if listed: - raise KeyringLocked("The keyring is locked. Unlock it and try again.") - raise Failure("The backup password is missing from the keyring. Connect the backup " - "again with its recovery key.") - - -def new_password(): - return secrets.token_urlsafe(32) - - -def new_recovery_key(): - characters = "".join(secrets.choice(RECOVERY_ALPHABET) - for _ in range(RECOVERY_GROUPS * RECOVERY_GROUP_LENGTH)) - return format_recovery_key(characters) - - -def format_recovery_key(characters): - size = RECOVERY_GROUP_LENGTH - return "-".join(characters[i:i + size] for i in range(0, len(characters), size)) - - -def normalize_recovery_key(text): - """A typed key in its canonical form; anything else is used as typed, - so a restic repository made elsewhere can still be connected.""" - raw = (text or "").strip() - compact = re.sub(r"[\s-]", "", raw).upper().translate(str.maketrans("OIL", "011")) - if len(compact) == RECOVERY_GROUPS * RECOVERY_GROUP_LENGTH \ - and all(character in RECOVERY_ALPHABET for character in compact): - return format_recovery_key(compact) - return raw - - -# ---- restic ----------------------------------------------------------------- - -def restic_environment(): - """restic must not pick up a repository or password from the session.""" - environment = {key: value for key, value in os.environ.items() - if not key.startswith(("RESTIC_", "AWS_", "B2_", "AZURE_", "GOOGLE_"))} - environment["RESTIC_PROGRESS_FPS"] = "1" - return environment - - -class Restic: - """One repository; secrets reach restic through pipes, never argv or the - environment, where other processes could read them.""" - - def __init__(self, config): - self.repository = config["repository"] - self.remote = config["destination"].get("type") == "sftp" - self.host = config["destination"].get("host", "") - - def command(self, arguments, password_path, new_password_path=None): - command = ["restic", "--repo", self.repository, "--password-file", password_path, - "--retry-lock", "1m"] - if self.remote: - command += ["-o", "sftp.args=" + SSH_ARGS] - command += list(arguments) - if new_password_path: - command += ["--new-password-file", new_password_path] - return command - - def run(self, arguments, password, *, new_password=None, on_line=None): - """(status, stdout lines, stderr tail).""" - descriptors = [] - - def secret(value): - read, write = os.pipe() - os.write(write, value.encode()) - os.close(write) - descriptors.append(read) - return f"/dev/fd/{read}" - - try: - command = self.command(arguments, secret(password), - secret(new_password) if new_password is not None else None) - try: - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - stdin=subprocess.DEVNULL, text=True, - env=restic_environment(), pass_fds=descriptors) - except FileNotFoundError as error: - raise Failure("restic is not installed.") from error - finally: - for descriptor in descriptors: - os.close(descriptor) - errors = collections.deque(maxlen=40) - lines = [] - # restic --json emits error records on stderr, beside its plain-text - # diagnostics. Deliver both streams without concurrently mutating Progress. - stream_lock = threading.Lock() - - def report(line): - with stream_lock: - on_line(line.rstrip("\n")) - - def read_errors(): - for line in process.stderr: - errors.append(line) - if on_line is not None: - report(line) - - with process: - # stderr drains beside stdout, so neither pipe can fill and stall restic. - drain = threading.Thread(target=read_errors, daemon=True) - drain.start() - for line in process.stdout: - line = line.rstrip("\n") - if on_line is None: - lines.append(line) - else: - report(line) - status = process.wait() - drain.join() - return status, lines, "".join(errors) - - def explain(self, status, stderr): - return explain(status, stderr, self.host) - - -def explain(status, stderr, host=""): - """restic's failure in plain words.""" - text = stderr or "" - lowered = text.lower() - server = host or "The server" - if status == 10 or "repository does not exist" in lowered: - return "No backup was found at the destination. Set up backups again." - if status == 11 or "unable to create lock" in lowered: - return "The backup is in use by another backup. Try again later." - if status == 12 or "wrong password" in lowered: - return "The saved password does not open this backup. Connect it again with its recovery key." - if "no space left on device" in lowered: - return "The backup destination is full." - if "host key verification failed" in lowered: - return f"This computer does not trust {server} yet. Connect once with ssh {host} in a terminal." - if "permission denied (publickey" in lowered or "permission denied, please try again" in lowered: - return f"{server} refused your SSH key." - if "could not resolve hostname" in lowered: - return f"{server} could not be found." - if any(phrase in lowered for phrase in ("connection refused", "connection timed out", - "no route to host", "network is unreachable", - "connection closed", "subsystem request failed")): - return f"{server} could not be reached." - if "read-only file system" in lowered: - return "The backup destination is read-only." - if "permission denied" in lowered: - return "CybexOS cannot write to the backup destination." - for line in reversed(text.splitlines()): - line = line.strip() - if line.startswith("Fatal: "): - line = line[len("Fatal: "):] - return line[:1].upper() + line[1:].rstrip(".") + "." - if status == 130 or status < 0: - return "The backup was interrupted." - return f"The backup failed (restic exit {status})." - - -def exclude_arguments(context, config): - home = str(context.home) - arguments = ["--exclude-caches", "--one-file-system"] - for pattern in HOME_EXCLUDES: - arguments += ["--exclude", home + "/" + pattern] - for pattern in ANYWHERE_EXCLUDES: - arguments += ["--exclude", pattern] - destination = config["destination"] - if destination.get("type") == "local": - arguments += ["--exclude", destination["path"]] - if context.user_excludes.is_file(): - arguments += ["--exclude-file", str(context.user_excludes)] - return arguments - - -def backup_arguments(context, config): - return ["backup", "--json", "--tag", TAG, "--host", config["host"], "--skip-if-unchanged", - *exclude_arguments(context, config), str(context.home)] - - -def forget_arguments(config): - arguments = ["forget", "--tag", TAG, "--host", config["host"]] - for flag, count in RETENTION: - arguments += [flag, count] - return arguments + ["--prune"] - - -# ---- Progress, lock and notifications ------------------------------------- - -@contextlib.contextmanager -def exclusive(context): - """One backup or restore at a time per account.""" - context.runtime.mkdir(parents=True, exist_ok=True, mode=0o700) - descriptor = os.open(context.lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - try: - try: - fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError as error: - raise Failure("A backup or restore is already running.", EXIT_BUSY) from error - try: - yield - finally: - # The progress file belongs to the holder of the lock. - with contextlib.suppress(OSError): - context.progress_path.unlink() - finally: - os.close(descriptor) - - -def busy(context): - try: - descriptor = os.open(context.lock_path, os.O_RDONLY | os.O_NOFOLLOW) - except OSError: - return False - try: - fcntl.flock(descriptor, fcntl.LOCK_SH | fcntl.LOCK_NB) - except BlockingIOError: - return True - finally: - os.close(descriptor) - return False - - -class Progress: - """restic's JSON stream, summarized at most once a second for Settings.""" - - def __init__(self, context, operation, echo=False): - self.context = context - self.operation = operation - self.echo = echo - self.started = stamp(utcnow()) - self.last_write = 0.0 - self.summary = None - self.errors = collections.deque(maxlen=40) - self.error_count = 0 - self.write({"percent": 0}) - - def write(self, values): - data = {"operation": self.operation, "started": self.started, "pid": os.getpid(), **values} - with contextlib.suppress(OSError): - write_json(self.context.progress_path, data) - - def __call__(self, line): - try: - message = json.loads(line) - except ValueError: - return - if not isinstance(message, dict): - return - kind = message.get("message_type") - if kind == "summary": - self.summary = message - elif kind == "error": - error = message.get("error") or {} - detail = error.get("message", "") if isinstance(error, dict) else str(error) - self.error_count += 1 - self.errors.append(f"{message.get('item', '')}: {detail}".strip(": ") - or "restic reported an unspecified read error") - elif kind == "status" and time.monotonic() - self.last_write >= 1: - self.last_write = time.monotonic() - percent = message.get("percent_done") or 0 - self.write({"percent": round(float(percent), 4), - "bytesDone": message.get("bytes_done", message.get("bytes_restored", 0)), - "totalBytes": message.get("total_bytes", 0)}) - if self.echo: - print(f"\r{self.operation.capitalize()}: {float(percent) * 100:5.1f} %", - end="", file=sys.stderr, flush=True) - - def finish(self): - if self.echo and self.last_write: - print(file=sys.stderr) - - -def runtime_command(context): - installed = context.home / ".local/bin/cybexos-runtime" - return str(installed) if installed.exists() else shutil.which("cybexos-runtime") - - -def notify(context, title, body, urgency="normal"): - """One notification; clicking it opens Settings at Backup & recovery.""" - if not shutil.which("notify-send"): - return - command = ["notify-send", "--app-name=Backups", "--urgency=" + urgency, - "--action=default=Open backup settings", title, body] - try: - # notify-send waits while it offers an action; bound that wait. - result = subprocess.run(command, capture_output=True, text=True, timeout=600, check=False) - except (OSError, subprocess.TimeoutExpired): - return - runtime = runtime_command(context) - if result.stdout.strip() == "default" and runtime: - with contextlib.suppress(OSError, subprocess.TimeoutExpired): - subprocess.run([runtime, "ipc", "settings", "open", "backup"], - capture_output=True, timeout=10, check=False) - - -def start_timer(): - """The session target starts the timer at login; after an update or a - first setup in a running session, start it now. Best effort: outside a - session there is no user manager to ask.""" - with contextlib.suppress(OSError, subprocess.TimeoutExpired): - subprocess.run(["systemctl", "--user", "start", "--no-block", "cybexos-backup.timer"], - capture_output=True, timeout=10, check=False) - - -def human_size(count): - value = float(count or 0) - for unit in ("B", "KB", "MB", "GB", "TB"): - if value < 1000 or unit == "TB": - return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}" - value /= 1000 - return f"{value:.1f} TB" - - -# ---- Setup ------------------------------------------------------------------ - -def destination_from(context, arguments): - if arguments.kind == "local": - return local_destination(context, arguments.target) - return remote_destination(arguments.target, arguments.path) - - -def key_ids(repository, password): - status, lines, stderr = repository.run(["key", "list", "--json"], password) - if status != 0: - raise Failure(repository.explain(status, stderr)) - try: - keys = json.loads("\n".join(lines)) - except ValueError as error: - raise Failure("restic listed its keys in an unexpected form.") from error - current = [key.get("id") for key in keys if isinstance(key, dict) and key.get("current")] - return current[0] if current else None - - -def existing_message(): - return Failure("This destination already holds CybexOS backups. Enter their recovery key to " - "use them.", EXIT_EXISTING) - - -def create_repository(config, repository): - """A new repository: the keyring password opens it here, the recovery key - anywhere. Returns the recovery key, or None when it could not be added.""" - password = new_password() - keyring_store(config["keyId"], password, config["destination"]["display"]) - try: - status, _, stderr = repository.run(["init"], password) - if status != 0: - if re.search(r"already (exists|initialized)", stderr, re.IGNORECASE): - raise existing_message() - raise Failure(repository.explain(status, stderr)) - except BaseException: - keyring_clear(config["keyId"]) - raise - recovery = new_recovery_key() - config["recoveryKeyConfirmed"] = False - status, _, _ = repository.run(["key", "add"], password, new_password=recovery) - if status != 0: - # The backups still work from this computer; Settings offers a new key. - return None - config["recoveryKeyId"] = key_ids(repository, recovery) - return recovery - - -def connect_repository(config, repository, recovery_text): - """Existing backups: their recovery key adds a new keyring password.""" - recovery_key = normalize_recovery_key(recovery_text) - if not recovery_key: - raise Failure("Enter the recovery key for these backups.", EXIT_USAGE) - status, _, stderr = repository.run(["cat", "config"], recovery_key) - if status == 12: - raise Failure("That recovery key does not open these backups.") - if status != 0: - raise Failure(repository.explain(status, stderr)) - password = new_password() - keyring_store(config["keyId"], password, config["destination"]["display"]) - status, _, stderr = repository.run(["key", "add"], recovery_key, new_password=password) - if status != 0: - keyring_clear(config["keyId"]) - raise Failure(repository.explain(status, stderr)) - config["recoveryKeyId"] = key_ids(repository, recovery_key) - config["recoveryKeyConfirmed"] = True - - -def setup(context, arguments, read_key): - """Point backups at a destination. Returns (config, recovery key or None).""" - destination, url = destination_from(context, arguments) - previous = load_config(context) - if previous and previous["repository"] == url: - # Already backing up there; the keyring password still opens it. - return previous, None - config = {"version": 1, "destination": destination, "repository": url, - "host": socket.gethostname() or "localhost", "keyId": uuid.uuid4().hex, - "automatic": previous.get("automatic", True) if previous else True, - "created": stamp(utcnow())} - repository = Restic(config) - local = destination["type"] == "local" - exists = is_repository(destination["path"]) if local else None - recovery = None - if arguments.existing: - if exists is False: - raise Failure("No CybexOS backups were found in that folder.") - connect_repository(config, repository, read_key()) - else: - if exists: - raise existing_message() - if local and not Path(destination["path"]).parent.is_dir(): - raise Failure(f"{Path(destination['path']).parent} does not exist.") - recovery = create_repository(config, repository) - write_json(context.config_path, config) - if previous: - keyring_clear(previous["keyId"]) - save_state(context, {}) - return config, recovery - - -def rotate_recovery_key(context): - config = require_config(context) - repository = Restic(config) - password = keyring_password(config["keyId"], interactive=True) - recovery = new_recovery_key() - status, _, stderr = repository.run(["key", "add"], password, new_password=recovery) - if status != 0: - raise Failure(repository.explain(status, stderr)) - new_id = key_ids(repository, recovery) - old_id = config.get("recoveryKeyId") - if old_id and old_id != new_id: - status, _, stderr = repository.run(["key", "remove", old_id], password) - if status != 0: - raise Failure("The new recovery key works, but the old one could not be removed: " - + repository.explain(status, stderr)) - config["recoveryKeyId"] = new_id - config["recoveryKeyConfirmed"] = False - write_json(context.config_path, config) - return recovery - - -# ---- Backing up ------------------------------------------------------------- - -def due(state, now): - last = parse_time(state.get("lastSuccess")) - return last is None or now - last >= DUE_AFTER - - -def next_due(config, state, now): - if not config.get("automatic", True): - return None - last = parse_time(state.get("lastSuccess")) - return now if last is None else max(now, last + DUE_AFTER) - - -def reminder_due(config, state, now): - """A week without a finished backup, reminded at most once a day.""" - since = parse_time(state.get("lastSuccess")) or parse_time(config.get("created")) - if since is None or now - since < REMIND_AFTER: - return False - return state.get("lastReminder") != now.astimezone().date().isoformat() - - -def reminder_text(config, state, now, reason=""): - last = parse_time(state.get("lastSuccess")) - if last is None: - since = parse_time(config.get("created")) or now - days = max(7, (now - since).days) - text = f"No backup has finished since you set up backups {days} days ago." - else: - days = max(7, (now - last).days) - text = f"Your last backup was {days} days ago." - if not config.get("automatic", True): - return text + " Automatic backups are off." - if reason: - return text + " " + reason - return text - - -def failure_notice_due(state, message, now): - """One notification per distinct failure and day; the hourly retries of - a failing backup must not repeat it.""" - notice = state.get("failureNotice") or {} - return not (notice.get("date") == now.astimezone().date().isoformat() - and notice.get("message") == message) - - -def run_backup(context, config, password, echo=False): - """Back up and, at most weekly, apply retention. Returns a summary line.""" - repository = Restic(config) - progress = Progress(context, "backup", echo=echo) - status, _, stderr = repository.run(backup_arguments(context, config), password, - on_line=progress) - progress.finish() - # 3: a snapshot can be saved, but files/directories were omitted. A denied - # directory can hide any number of files; count reported read errors only. - if status not in (0, 3): - raise Failure(repository.explain(status, stderr)) - summary = progress.summary or {} - now = utcnow() - incomplete = status == 3 or progress.error_count > 0 - errors = list(progress.errors) - if incomplete: - if progress.error_count: - detail = (f"{progress.error_count} read error(s) were reported. " - "Files or directories were omitted; the total number of missing files is unknown.") - else: - detail = "Some files or directories could not be read; the unreadable count is unknown." - if stderr.strip(): - errors = [stderr.strip()] - message = "Backup incomplete. " + detail - if summary.get("snapshot_id"): - message += " A partial snapshot was saved; its readable files can be restored." - else: - message += " Check the available snapshots for saved files." - if errors: - message += " Restic: " + errors[0] - else: - message = "" - state = update_state(context, lastAttempt=stamp(now), - lastResult="partial" if incomplete else "ok", lastError=message, lastSkip="", - lastSnapshot=summary.get("snapshot_id", ""), - lastSummary={"files": summary.get("total_files_processed", 0), - "bytes": summary.get("total_bytes_processed", 0), - "added": summary.get("data_added", 0), - "unreadable": (progress.error_count or None) if incomplete else 0, - "errors": errors}) - if incomplete: - # Preserve the last complete success and all snapshots. Do not apply - # retention after an incomplete run or suppress the next scheduled retry. - raise Failure(message, result="partial") - update_state(context, lastSuccess=stamp(now)) - line = (f"Backed up {summary.get('total_files_processed', 0):,} files " - f"({human_size(summary.get('data_added', 0))} new) to {config['destination']['display']}.") - last_maintenance = parse_time(state.get("lastMaintenance")) - if last_maintenance is None or now - last_maintenance >= MAINTAIN_AFTER: - maintenance = Progress(context, "cleanup") - status, _, stderr = repository.run(forget_arguments(config), password) - if status == 0: - status, _, stderr = repository.run(["check"], password) - maintenance.finish() - if status != 0: - raise Failure("The backup finished, but cleaning up old backups failed: " - + repository.explain(status, stderr)) - update_state(context, lastMaintenance=stamp(utcnow())) - return line - - -def record_failure(context, message, notify_user, now=None, *, result="failed"): - now = now or utcnow() - state = update_state(context, lastAttempt=stamp(now), lastResult=result, lastError=message) - if notify_user and failure_notice_due(state, message, now): - update_state(context, failureNotice={"date": now.astimezone().date().isoformat(), - "message": message}) - return True - return False - - -def back_up_now(context, notify_user): - """A backup the user asked for: no schedule, battery or network rules.""" - config = require_config(context) - with exclusive(context): - try: - reachable, reason = destination_reachable(config["destination"]) - if reachable is False: - raise Failure(reason) - password = keyring_password(config["keyId"], interactive=True) - return run_backup(context, config, password, echo=sys.stderr.isatty()) - except Failure as error: - failure = error - record_failure(context, error.message, False, result=error.result) - # Notified after the lock is released, so Settings already shows the result. - if notify_user: - notify(context, "Backup incomplete" if failure.result == "partial" else "Backup failed", - failure.message, "normal" if failure.result == "partial" else "critical") - raise failure - - -def skip_reason(config): - """Why a due scheduled backup waits: (skip code, words for a reminder).""" - destination = config["destination"] - reachable, reason = destination_reachable(destination) - if reachable is False: - if destination.get("label") and not os.path.ismount(destination.get("mount") or "/"): - reason = f"Connect {destination['label']} to back up." - return "unreachable", reason - if low_battery(): - return "battery", "" - if destination["type"] == "sftp": - if metered_network(): - return "metered", "" - if remote_reachable(destination["host"]) is False: - return "unreachable", f"{destination['host']} could not be reached." - return "", "" - - -def scheduled(context, now=None): - """The timer's hourly check. Quiet unless something needs the user.""" - config = load_config(context) - if config is None: - return EXIT_OK - now = now or utcnow() - notice = None - reason = "" - try: - with exclusive(context): - if config.get("automatic", True) and due(load_state(context), now): - skip, reason = skip_reason(config) - if not skip: - try: - password = keyring_password(config["keyId"], interactive=False) - run_backup(context, config, password) - except KeyringLocked: - skip, reason = "keyring", "Unlock the keyring to back up." - except Failure as error: - if record_failure(context, error.message, True, now, result=error.result): - notice = (("Backup incomplete", error.message, "normal") - if error.result == "partial" else - ("Backup failed", error.message, "critical")) - if skip: - update_state(context, lastSkip=skip, lastSkipAt=stamp(now)) - state = load_state(context) - if notice is None and reminder_due(config, state, now): - update_state(context, lastReminder=now.astimezone().date().isoformat()) - notice = ("Back up your files", reminder_text(config, state, now, reason), "normal") - except Failure as error: - if error.status != EXIT_BUSY: - raise - return EXIT_OK - if notice: - notify(context, *notice) - return EXIT_OK - - -# ---- Status ----------------------------------------------------------------- - -def progress_info(context): - if not busy(context): - return None - progress = read_json(context.progress_path, {}) - return {"operation": progress.get("operation", "backup"), - "percent": progress.get("percent", 0), - "started": progress.get("started", "")} - - -def media_root(): - """Where Settings starts browsing for a backup drive.""" - user = getpass.getuser() - for candidate in (f"/run/media/{user}", "/media", "/mnt"): - if os.path.isdir(candidate): - return candidate - return "/" - - -def status_report(context, now=None): - now = now or utcnow() - config = load_config(context) - state = load_state(context) - progress = progress_info(context) - report = {"configured": config is not None, "running": progress is not None, - "progress": progress, "lastRestore": state.get("lastRestore"), - "mediaRoot": media_root(), "coverage": BACKUP_COVERAGE, - "consistency": BACKUP_CONSISTENCY} - if config is None: - return report - reachable, reason = destination_reachable(config["destination"]) - upcoming = next_due(config, state, now) - report.update({ - "destination": {key: config["destination"].get(key, "") - for key in ("type", "display", "label", "path", "host")}, - "automatic": bool(config.get("automatic", True)), - "reachable": reachable, - "unreachableReason": reason, - "recoveryKeyConfirmed": bool(config.get("recoveryKeyConfirmed")), - "created": config.get("created", ""), - "lastSuccess": state.get("lastSuccess", ""), - "lastAttempt": state.get("lastAttempt", ""), - "lastResult": state.get("lastResult", ""), - "lastError": state.get("lastError", ""), - "lastSkip": state.get("lastSkip", ""), - "lastSummary": state.get("lastSummary"), - "nextDue": stamp(upcoming) if upcoming else "", - "stale": bool(config and reminder_due(config, {**state, "lastReminder": ""}, now)), - }) - return report - - -def print_status(report): - print(report["coverage"]) - print(report["consistency"]) - if not report["configured"]: - print("Backups are not set up. Run: cybex backup setup local PATH, or " - "cybex backup setup sftp HOST [PATH].") - return - destination = report["destination"] - print("Destination: " + destination["display"] - + ("" if report["reachable"] is not False else " (" + report["unreachableReason"] + ")")) - print("Automatic: " + ("daily" if report["automatic"] else "off")) - last = parse_time(report["lastSuccess"]) - print("Last complete backup: " + (last.astimezone().strftime("%Y-%m-%d %H:%M") if last else "never")) - if report["lastResult"] in ("failed", "partial"): - print("Last attempt: " + report["lastResult"] + ": " + report["lastError"]) - if report["running"]: - progress = report["progress"] - print(f"Running: {progress['operation']} {progress['percent'] * 100:.0f} %") - upcoming = parse_time(report["nextDue"]) - if upcoming: - print("Next backup: after " + upcoming.astimezone().strftime("%Y-%m-%d %H:%M")) - if not report["recoveryKeyConfirmed"]: - print("Recovery key: not saved. Run cybex backup recovery-key to make a new one.") - - -# ---- Restoring -------------------------------------------------------------- - -def snapshot_list(context, config, password): - repository = Restic(config) - status, lines, stderr = repository.run(["snapshots", "--json", "--tag", TAG], password) - if status != 0: - raise Failure(repository.explain(status, stderr)) - try: - snapshots = json.loads("\n".join(lines) or "[]") - except ValueError as error: - raise Failure("restic listed the backups in an unexpected form.") from error - result = [] - for snapshot in snapshots if isinstance(snapshots, list) else []: - if not isinstance(snapshot, dict) or not SNAPSHOT_PATTERN.fullmatch(snapshot.get("id", "")): - continue - paths = snapshot.get("paths") or [] - summary = snapshot.get("summary") or {} - result.append({"id": snapshot["id"], "time": snapshot.get("time", ""), - "host": snapshot.get("hostname", ""), - "path": paths[0] if paths else "", - "files": summary.get("total_files_processed", 0), - "bytes": summary.get("total_bytes_processed", 0)}) - result.sort(key=lambda entry: parse_time(entry["time"]) or datetime.min.replace( - tzinfo=timezone.utc), reverse=True) - return result - - -def restore_path(text): - """A path inside the backed-up home folder, relative to it.""" - if not text: - return "" - if "\n" in text or "\0" in text or any(character in text for character in "*?[\\"): - raise Failure("Enter a plain path inside your home folder, such as Documents/Taxes.", - EXIT_USAGE) - text = text.strip() - if text == "~" or text.startswith("~/"): - text = text[2:] - parts = [part for part in Path(text).parts if part not in ("/", ".")] - if ".." in parts: - raise Failure("Enter a path inside your home folder.", EXIT_USAGE) - return "/".join(parts) - - -def restore_target(context, moment): - """A folder that did not exist before, under ~/Restored, named by when the - backup was made. Created here, so a restore can never land in place.""" - parent = context.home / "Restored" - try: - parent.mkdir(mode=0o700) - except FileExistsError: - pass - if parent.is_symlink() or not parent.is_dir(): - raise Failure(f"{parent} must be a folder.") - name = moment.astimezone().strftime("%Y-%m-%d-%H%M") - for attempt in range(1, 100): - target = parent / (name if attempt == 1 else f"{name}-{attempt}") - try: - target.mkdir(mode=0o700) - except FileExistsError: - continue - return target - raise Failure("Too many restored folders share this name. Tidy up ~/Restored.") - - -def open_folder(path): - with contextlib.suppress(OSError): - subprocess.Popen(["xdg-open", str(path)], stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - start_new_session=True) - - -def restore(context, snapshot_id, relative, open_files, notify_user): - config = require_config(context) - if not SNAPSHOT_PATTERN.fullmatch(snapshot_id or ""): - raise Failure("Choose a backup to restore.", EXIT_USAGE) - relative = restore_path(relative) - try: - with exclusive(context): - reachable, reason = destination_reachable(config["destination"]) - if reachable is False: - raise Failure(reason) - password = keyring_password(config["keyId"], interactive=True) - snapshots = snapshot_list(context, config, password) - if snapshot_id == "latest": - own = [entry for entry in snapshots if entry["host"] == config["host"]] - chosen = (own or snapshots or [None])[0] - else: - chosen = next((entry for entry in snapshots - if entry["id"].startswith(snapshot_id)), None) - if chosen is None or not chosen["path"].startswith("/"): - raise Failure("That backup was not found.") - if not relative: - free = shutil.disk_usage(context.home).free - if chosen["bytes"] and chosen["bytes"] > free: - raise Failure(f"Restoring everything needs {human_size(chosen['bytes'])}, " - f"but only {human_size(free)} is free. Restore a folder instead.") - moment = parse_time(chosen["time"]) or utcnow() - target = restore_target(context, moment) - arguments = ["restore", chosen["id"] + ":" + chosen["path"], "--target", str(target), - "--overwrite", "never", "--json"] - if relative: - arguments += ["--include", "/" + relative] - progress = Progress(context, "restore", echo=sys.stderr.isatty()) - repository = Restic(config) - status, _, stderr = repository.run(arguments, password, on_line=progress) - progress.finish() - if status != 0: - with contextlib.suppress(OSError): - target.rmdir() - raise Failure(repository.explain(status, stderr).replace("backup failed", - "restore failed")) - restored = (progress.summary or {}).get("total_files", 0) - if progress.summary is not None and not restored: - with contextlib.suppress(OSError): - target.rmdir() - raise Failure(f"Nothing at {relative or 'that path'} is in that backup.") - update_state(context, lastRestore={"time": stamp(utcnow()), "ok": True, - "target": str(target), "error": ""}) - except Failure as error: - if error.status != EXIT_BUSY: - update_state(context, lastRestore={"time": stamp(utcnow()), "ok": False, - "target": "", "error": error.message}) - if notify_user: - notify(context, "Restore failed", error.message, "critical") - raise - shown = target - if relative and (target / relative).exists(): - shown = target / relative if (target / relative).is_dir() else (target / relative).parent - if open_files: - open_folder(shown) - return shown - - -# ---- Compatibility: app-backup --------------------------------------------- - -def browser_profiles(context, output, environ): - """The earlier app-backup: an age-encrypted tar of the browser profiles.""" - recipient = environ.get("AGE_RECIPIENT", "") - if not recipient: - print("Set AGE_RECIPIENT to an age public recipient.", file=sys.stderr) - return 2 - if not shutil.which("age"): - print("age is required", file=sys.stderr) - return 1 - home = context.home - output = output or str(home / "Downloads" / time.strftime("browser-profiles-%Y%m%d-%H%M%S.tar.age")) - paths = [path for path in BROWSER_PROFILES if os.path.lexists(home / path)] - if not paths: - print("No browser profiles found", file=sys.stderr) - return 1 - previous = os.umask(0o077) - try: - tar = subprocess.Popen(["tar", "-C", str(home), "-cf", "-", *paths], stdout=subprocess.PIPE) - age = subprocess.run(["age", "-r", recipient, "-o", output], stdin=tar.stdout, check=False) - tar.stdout.close() - if tar.wait() != 0 or age.returncode != 0: - return 1 - finally: - os.umask(previous) - print("Encrypted opt-in browser backup: " + output) - return 0 - - -# ---- Command line ----------------------------------------------------------- - -def parser(): - parser = argparse.ArgumentParser( - prog="cybex backup", description="Encrypted backups of your home folder.") - commands = parser.add_subparsers(dest="command") - setup_command = commands.add_parser("setup", help="choose where backups go") - setup_command.add_argument("kind", choices=("local", "sftp")) - setup_command.add_argument("target", help="a folder, or an SSH alias or user@host") - setup_command.add_argument("path", nargs="?", help="the folder on the SSH server") - setup_command.add_argument("--existing", action="store_true", - help="connect backups already there; the recovery key is read " - "from standard input") - setup_command.add_argument("--json", action="store_true") - commands.add_parser("confirm-key", help="record that the recovery key is saved") - key_command = commands.add_parser("recovery-key", help="replace the recovery key") - key_command.add_argument("--json", action="store_true") - now_command = commands.add_parser("now", help="back up now") - now_command.add_argument("--notify", action="store_true", help="report a failure as a notification") - commands.add_parser("scheduled", help="the timer's daily backup and weekly reminder") - status_command = commands.add_parser("status", help="show the last and next backup") - status_command.add_argument("--json", action="store_true") - snapshots_command = commands.add_parser("snapshots", help="list the backups") - snapshots_command.add_argument("--json", action="store_true") - restore_command = commands.add_parser("restore", help="restore into a new folder in ~/Restored") - restore_command.add_argument("snapshot", nargs="?", default="latest") - restore_command.add_argument("--path", default="", help="a file or folder inside your home folder") - restore_command.add_argument("--open", action="store_true", help="open the restored folder in Files") - restore_command.add_argument("--notify", action="store_true") - automatic_command = commands.add_parser("automatic", help="turn daily backups on or off") - automatic_command.add_argument("state", choices=("on", "off")) - commands.add_parser("excludes", help="list what backups leave out") - commands.add_parser("browser-profiles", help="the earlier app-backup browser export") \ - .add_argument("output", nargs="?") - return parser - - -def read_recovery_key(): - if sys.stdin.isatty(): - return getpass.getpass("Recovery key: ") - # One line: Settings keeps the pipe open after writing it. - return sys.stdin.readline() - - -def emit(arguments, data, text): - if getattr(arguments, "json", False): - print(json.dumps(data)) - elif text: - print(text) - - -def main(argv=None, environ=None, program=None): - environ = os.environ if environ is None else environ - argv = sys.argv[1:] if argv is None else argv - context = Context(environ) - if Path(program or sys.argv[0]).name in ("app-backup", "browser-backup"): - return browser_profiles(context, argv[0] if argv else "", environ) - arguments = parser().parse_args(argv or ["status"]) - try: - if arguments.command == "setup": - config, recovery = setup(context, arguments, read_recovery_key) - start_timer() - if getattr(arguments, "json", False): - print(json.dumps({"ok": True, "destination": config["destination"]["display"], - "recoveryKey": recovery or "", "coverage": BACKUP_COVERAGE, - "consistency": BACKUP_CONSISTENCY})) - elif recovery: - print(f"Backups go to {config['destination']['display']}.\n\nRecovery key:\n\n" - f" {recovery}\n\nSave it somewhere safe, such as a password manager. It " - "opens these backups on any computer.\nIf you lose both this computer's " - "keyring and this key, the backups cannot be opened.\nThen run: " - "cybex backup confirm-key") - else: - print(f"Connected the backups at {config['destination']['display']}.") - if not getattr(arguments, "json", False): - print(BACKUP_COVERAGE) - print(BACKUP_CONSISTENCY) - elif arguments.command == "confirm-key": - config = require_config(context) - config["recoveryKeyConfirmed"] = True - write_json(context.config_path, config) - elif arguments.command == "recovery-key": - recovery = rotate_recovery_key(context) - emit(arguments, {"ok": True, "recoveryKey": recovery}, - f"New recovery key:\n\n {recovery}\n\nThe previous key no longer opens these " - "backups. Save this one, then run: cybex backup confirm-key") - elif arguments.command == "now": - print(back_up_now(context, arguments.notify)) - elif arguments.command == "scheduled": - return scheduled(context) - elif arguments.command == "status": - report = status_report(context) - if arguments.json: - print(json.dumps(report)) - else: - print_status(report) - elif arguments.command == "snapshots": - config = require_config(context) - password = keyring_password(config["keyId"], interactive=True) - snapshots = snapshot_list(context, config, password) - if arguments.json: - print(json.dumps({"snapshots": snapshots})) - for entry in [] if arguments.json else snapshots: - moment = parse_time(entry["time"]) - when = moment.astimezone().strftime("%Y-%m-%d %H:%M") if moment else entry["time"] - print(f"{entry['id'][:8]} {when} {entry['host']} {human_size(entry['bytes'])}") - elif arguments.command == "restore": - shown = restore(context, arguments.snapshot, arguments.path, arguments.open, - arguments.notify) - print(f"Restored to {shown}") - elif arguments.command == "automatic": - config = require_config(context) - config["automatic"] = arguments.state == "on" - write_json(context.config_path, config) - if config["automatic"]: - start_timer() - elif arguments.command == "excludes": - print(BACKUP_COVERAGE) - print(BACKUP_CONSISTENCY) - config = load_config(context) or {"destination": {}} - arguments_list = exclude_arguments(context, config) - for flag, value in zip(arguments_list, arguments_list[1:]): - if flag in ("--exclude", "--exclude-file"): - print(value if flag == "--exclude" else "patterns in " + value) - elif arguments.command == "browser-profiles": - return browser_profiles(context, arguments.output or "", environ) - except Failure as error: - if getattr(arguments, "json", False): - print(json.dumps({"ok": False, "error": error.message, - "existing": error.status == EXIT_EXISTING})) - print("cybexos-backup: " + error.message, file=sys.stderr) - return error.status - return EXIT_OK - - -if __name__ == "__main__": - # A stopped unit or session ends a run through the cleanup paths. - signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) - sys.exit(main()) diff --git a/assets/scripts/cybexos-firmware-update b/assets/scripts/cybexos-firmware-update deleted file mode 100755 index 247bcc68..00000000 --- a/assets/scripts/cybexos-firmware-update +++ /dev/null @@ -1,662 +0,0 @@ -#!/usr/bin/python3 -I -"""Install fwupd device firmware for the durable CybexOS updater. - -cybexos-update-run runs `install` as root after the package phase. It picks -the same devices and releases as `fwupdmgr update`, but reports every step as -structured events that Quickshell renders, instead of prompting in a terminal: - - cybexos-firmware-update plan - cybexos-firmware-update install [--events PATH] - -`plan` is read-only and prints {"devices": [...]}. `install` appends one -compact JSON object per line to PATH, flushed as it happens, and writes -human-readable progress to stdout for the run log. It always ends the event -stream with exactly one `summary`. Exit status: 0 when nothing failed -(including nothing to do), 1 when any device failed, 2 when fwupd could not -be used at all. - -The interpreter is the system Python in isolated mode: PyGObject comes from -python3-gobject, and a root process must not import packages from the update -owner's user site directory (the worker keeps that user's HOME). -""" - -from __future__ import annotations - -import argparse -import json -import re -import sys - -EXIT_OK = 0 -EXIT_FAILED = 1 -EXIT_SETUP = 2 - -CONNECT_TIMEOUT_SECONDS = 30 -USER_AGENT = ("cybexos-firmware-update", "1") - -# fwupd only sends a request with its own text when it is device-specific; a -# generic request expects the client to phrase it, as fwupdmgr does. -REQUEST_MESSAGES = { - "org.freedesktop.fwupd.request.remove-replug": "Unplug the device and plug it back in.", - "org.freedesktop.fwupd.request.insert-usb-cable": "Plug the device's USB cable back in.", - "org.freedesktop.fwupd.request.remove-usb-cable": "Unplug the device's USB cable.", - "org.freedesktop.fwupd.request.press-unlock": "Press the unlock button on the device.", - "org.freedesktop.fwupd.replug-power": "Unplug the device's power and plug it back in.", - "org.freedesktop.fwupd.replug-install": - "Unplug the device and plug it back in to finish installing.", - "org.freedesktop.fwupd.request.do-not-power-off": - "Do not turn off the computer or unplug the device until the update finishes.", -} -DEFAULT_REQUEST_MESSAGE = "Follow the instructions on the device." - -FAILURE_MESSAGES = { - "ac-power": "Connect the power adapter to install this firmware.", - "battery": "Charge the battery before installing this firmware.", - "auth": "The firmware update was not authorized.", - "network": "Couldn't download the firmware. Check the network connection.", - "signature": "The downloaded firmware failed verification.", - "busy": "Another firmware update is in progress. Try again when it finishes.", - "user-action": "The device needs your attention before it can be updated.", - "other": "The firmware update failed.", -} -UNAVAILABLE_MESSAGE = "The firmware update service (fwupd) is not available." -MISSING_BINDINGS_MESSAGE = "The firmware updater cannot load fwupd's Python bindings." -ALREADY_PENDING_MESSAGE = "Already scheduled for the next restart." - -# FwupdError members this helper distinguishes, by name, so the numeric codes -# always come from the installed libfwupd. -FWUPD_ERROR_NAMES = ( - "AC_POWER_REQUIRED", "ALREADY_PENDING", "AUTH_EXPIRED", "AUTH_FAILED", - "BATTERY_LEVEL_TOO_LOW", "BUSY", "INTERNAL", "INVALID_FILE", - "NEEDS_USER_ACTION", "NOTHING_TO_DO", "NOT_FOUND", "NOT_REACHABLE", - "NOT_SUPPORTED", "PERMISSION_DENIED", "READ", "SIGNATURE_INVALID", - "TIMED_OUT", "VERSION_NEWER", "VERSION_SAME", -) -# Only these GIO errors mean the download could not reach its server. A -# closed D-Bus connection (a crashed daemon) is also a GIO error, but blaming -# the network for it would send the user to the wrong place. -NETWORK_IO_ERROR_NAMES = ( - "CONNECTION_CLOSED", "CONNECTION_REFUSED", "HOST_NOT_FOUND", - "HOST_UNREACHABLE", "NETWORK_UNREACHABLE", "NOT_CONNECTED", - "PROXY_AUTH_FAILED", "PROXY_FAILED", "PROXY_NEED_AUTH", "PROXY_NOT_ALLOWED", - "TIMED_OUT", -) -# get_upgrades() reports "no upgrade" as an error; these are all ordinary. -NO_UPGRADE_ERRORS = ("NOTHING_TO_DO", "NOT_FOUND", "NOT_SUPPORTED", - "VERSION_NEWER", "VERSION_SAME") - -STATUS_WORDS = { - "decompressing": "decompressing", - "device-busy": "waiting for the device", - "device-erase": "erasing", - "device-read": "reading", - "device-restart": "restarting the device", - "device-verify": "verifying", - "device-write": "writing", - "downloading": "downloading", - "loading": "loading", - "scheduling": "scheduling for the next restart", - "shutdown": "shutting down", - "waiting-for-auth": "waiting for authorization", - "waiting-for-user": "waiting for you", -} - - -class SetupError(Exception): - """fwupd cannot be used at all; nothing was attempted.""" - - -class Api: - """The parts of gi.repository this helper uses, resolved once. - - Keeping them on one object lets the tests substitute a fake libfwupd and - lets every lookup degrade instead of fail on an older or newer typelib. - """ - - def __init__(self, fwupd, glib, gio=None): - self.fwupd = fwupd - self.glib = glib - self.error_domain = quark_name(glib, getattr(fwupd, "error_quark", None), "FwupdError") - self.io_domain = quark_name(glib, getattr(gio, "io_error_quark", None), - "g-io-error-quark") - self.resolver_domain = quark_name(glib, getattr(gio, "resolver_error_quark", None), - "g-resolver-error-quark") - self.error_names = {} - for name in FWUPD_ERROR_NAMES: - member = getattr(fwupd.Error, name, None) - if member is not None: - self.error_names[int(member)] = name - io_errors = getattr(gio, "IOErrorEnum", None) - self.network_io_codes = { - int(getattr(io_errors, name)) for name in NETWORK_IO_ERROR_NAMES - if io_errors is not None and getattr(io_errors, name, None) is not None - } - - def fwupd_error_name(self, error): - if getattr(error, "domain", None) != self.error_domain: - return None - return self.error_names.get(getattr(error, "code", None)) - - def is_network_error(self, error): - domain = getattr(error, "domain", None) - return domain == self.resolver_domain or ( - domain == self.io_domain and getattr(error, "code", None) in self.network_io_codes) - - def feature_flags(self): - flags = 0 - for name in ("REQUESTS", "REQUESTS_NON_GENERIC", "UPDATE_ACTION", - "DETACH_ACTION", "FDE_WARNING"): - member = getattr(self.fwupd.FeatureFlags, name, None) - if member is not None: - flags = member if not flags else flags | member - return flags - - def status_name(self, status): - """fwupd's own status string (downloading, device-write, ...), or None.""" - try: - return self.fwupd.status_to_string(status) or None - except (TypeError, ValueError): - return None - - def request_kind(self, kind): - kinds = self.fwupd.RequestKind - if getattr(kinds, "POST", None) is not None and kind == kinds.POST: - return "post" - # IMMEDIATE, and an unknown kind that fwupd sends mid-update. - return "immediate" - - -def quark_name(glib, quark_function, fallback): - """PyGObject reports GLib.Error.domain as the quark's string.""" - if quark_function is None: - return fallback - try: - name = glib.quark_to_string(quark_function()) - except (AttributeError, TypeError, ValueError): - return fallback - return name or fallback - - -def load_api(): - """Import libfwupd lazily so the logic stays importable without PyGObject.""" - try: - import gi - - gi.require_version("Fwupd", "2.0") - from gi.repository import Fwupd, Gio, GLib - except Exception as error: # ImportError, or a missing/mismatched typelib. - raise SetupError(MISSING_BINDINGS_MESSAGE) from error - return Api(Fwupd, GLib, Gio) - - -def first_line(text, fallback=""): - """One trimmed line of fwupd's message, without a D-Bus error prefix.""" - for line in str(text or "").splitlines(): - line = re.sub(r"^GDBus\.Error:[\w.-]+:\s*", "", line.strip()) - if line: - return line - return fallback - - -def error_message(error): - return first_line(getattr(error, "message", None) or str(error)) - - -def device_flag_names(api, device): - """Return fwupd's names for the device's flags. - - Introspection truncates FwupdDeviceFlags to 32 bits, which loses newer - flags such as affects-fde, so the device's own JSON export (which names - all 64) is authoritative. The enum members fill in if that export fails. - """ - names = set() - try: - exported = json.loads(device.to_json_string(api.fwupd.CodecFlags.NONE)) - names.update(flag for flag in exported.get("Flags", []) if isinstance(flag, str)) - except Exception: # Best effort: the enum members below still apply. - pass - for name in ("updatable", "needs-reboot", "needs-shutdown", "affects-fde"): - member = getattr(api.fwupd.DeviceFlags, name.upper().replace("-", "_"), None) - try: - if member is not None and device.has_flag(member): - names.add(name) - except (TypeError, ValueError, OverflowError): - pass - return names - - -class Planned: - """One device, the release it will receive, and its `plan` event entry.""" - - def __init__(self, device, release, info): - self.device = device - self.release = release - self.info = info - - @property - def id(self): - return self.info["id"] - - @property - def needs_reboot(self): - return self.info["needsReboot"] - - @property - def label(self): - versions = " → ".join(value for value in (self.info["from"], self.info["to"]) if value) - return f"{self.info['name']} {versions}".strip() - - -def clean_text(value): - return str(value or "").strip() - - -def plan_entry(api, device, release): - flags = device_flag_names(api, device) - return Planned(device, release, { - "id": clean_text(device.get_id()), - "name": clean_text(device.get_name()) or "Unknown device", - "vendor": clean_text(device.get_vendor()), - # fwupd reports some versions with padding ("HPS0050 "). - "from": clean_text(device.get_version()), - "to": clean_text(release.get_version()), - # A capsule or other deferred update is staged now and applied by - # the next restart (or full shutdown); the panel must say so. - "needsReboot": bool({"needs-reboot", "needs-shutdown"} & flags), - "affectsFde": "affects-fde" in flags, - }) - - -def build_plan(api, client, warn=None): - """What `fwupdmgr update` would install: the newest upgrade per device.""" - try: - devices = client.get_devices(None) - except api.glib.Error as error: - if api.fwupd_error_name(error) == "NOTHING_TO_DO": - return [] - raise SetupError(UNAVAILABLE_MESSAGE) from error - plan = [] - for device in devices or []: - if "updatable" not in device_flag_names(api, device): - continue - try: - releases = client.get_upgrades(device.get_id(), None) - except api.glib.Error as error: - if warn is not None and api.fwupd_error_name(error) not in NO_UPGRADE_ERRORS: - warn(f"{device.get_name() or device.get_id()}: {error_message(error)}") - continue - if releases: - plan.append(plan_entry(api, device, releases[0])) - return plan - - -def run_main_loop_until(api, start, timeout=None): - """Run START(done) in a GLib main loop until it calls done(error). - - Returns the error (None on success). A fake client may complete - synchronously, in which case the loop never runs. - """ - loop = api.glib.MainLoop() - state = {"done": False, "error": None, "timer": None} - - def done(error=None): - if state["done"]: - return - state["done"] = True - state["error"] = error - loop.quit() - - def expired(): - state["timer"] = None - done(TimeoutError("fwupd did not respond")) - return False - - try: - start(done) - except Exception as error: # Reported as this step's failure. - done(error) - if not state["done"]: - if timeout is not None: - state["timer"] = api.glib.timeout_add_seconds(timeout, expired) - loop.run() - if state["timer"] is not None: - api.glib.source_remove(state["timer"]) - return state["error"] - - -def open_client(api): - """Connect to the daemon with the same client features as fwupdmgr. - - The daemon filters releases by client features (a release can require a - client that shows detach actions), so `plan` and `install` must declare - the same set. - """ - try: - client = api.fwupd.Client.new() - except Exception as error: - raise SetupError(UNAVAILABLE_MESSAGE) from error - - def start(done): - def connected(source, result, *_): - try: - source.connect_finish(result) - except Exception as error: - done(error) - else: - done() - - # Fwupd.Client.connect is shadowed by GObject's signal connect. - client.connect_async(None, connected) - - if run_main_loop_until(api, start, CONNECT_TIMEOUT_SECONDS) is not None: - raise SetupError(UNAVAILABLE_MESSAGE) - try: - client.set_feature_flags(api.feature_flags(), None) - except api.glib.Error as error: - raise SetupError(UNAVAILABLE_MESSAGE) from error - if hasattr(client, "set_user_agent_for_package"): - # libfwupd refuses to download without a user agent. - client.set_user_agent_for_package(*USER_AGENT) - return client - - -def classify_error(api, error): - """Map an install error to the event that reports it: (event, fields).""" - message = error_message(error) - name = api.fwupd_error_name(error) - - def failed(reason, text=None): - return "failed", {"reason": reason, "message": text or FAILURE_MESSAGES[reason]} - - if name == "AC_POWER_REQUIRED": - return failed("ac-power") - if name == "BATTERY_LEVEL_TOO_LOW": - return failed("battery") - if name in ("PERMISSION_DENIED", "AUTH_FAILED", "AUTH_EXPIRED"): - return failed("auth") - if name == "BUSY": - return failed("busy") - if name == "NEEDS_USER_ACTION": - return failed("user-action", message) - if name in ("NOTHING_TO_DO", "VERSION_SAME", "VERSION_NEWER"): - return "skipped", {"message": message or "This firmware is already installed."} - if name == "ALREADY_PENDING": - return "installed", {"needsReboot": True, "message": ALREADY_PENDING_MESSAGE} - if name in ("NOT_REACHABLE", "TIMED_OUT") or api.is_network_error(error): - return failed("network") - # libfwupd downloads in the client and reports a failed transfer as an - # invalid file ("Failed to download ..."); that is not a bad signature. - if name in ("INVALID_FILE", "INTERNAL", "NOT_FOUND", "READ") \ - and re.search(r"\bdownload\b", message, re.IGNORECASE): - return failed("network") - if name in ("SIGNATURE_INVALID", "INVALID_FILE"): - return failed("signature") - return failed("other", message) - - -class EventLog: - """Append-only JSON-lines sink; without a path, events go nowhere.""" - - def __init__(self, path=None): - # The worker pre-creates the file as the update owner so the panel can - # read it unprivileged; appending as root keeps that ownership. - self._stream = open(path, "a", encoding="utf-8") if path else None - - def emit(self, event, **fields): - record = {"event": event, **fields} - if self._stream is not None: - # ASCII-only output keeps every byte offset a character boundary - # for the panel's windowed `read-log` reads. - self._stream.write(json.dumps(record, separators=(",", ":")) + "\n") - self._stream.flush() - - def close(self): - if self._stream is not None: - self._stream.close() - self._stream = None - - def __enter__(self): - return self - - def __exit__(self, *_): - self.close() - - -class HumanOutput: - def __init__(self, stream): - self.stream = stream - - def line(self, text): - # The worker reads stdout through a pipe; flush every line so the - # run log follows the update live. - self.stream.write(text + "\n") - self.stream.flush() - - -def percentage(value): - """A 0-100 percentage, or None: fwupd reports "unknown" as G_MAXUINT.""" - try: - value = int(value) - except (TypeError, ValueError): - return None - return value if 0 <= value <= 100 else None - - -class ProgressFilter: - """Emit a progress event only when the status or percentage changes. - - An unknown status or percentage keeps the last known value. - """ - - def __init__(self): - self.reset() - - def reset(self): - self.status = None - self.percent = None - - def observe(self, status, percent): - status = status or self.status - if percent is None: - percent = self.percent if self.percent is not None else 0 - if status is None: - return None - if (status, percent) == (self.status, self.percent): - return None - self.status, self.percent = status, percent - return status, percent - - -class HumanProgress: - """Throttle stdout to status changes and every tenth percent.""" - - def __init__(self): - self.reset() - - def reset(self): - self.status = None - self.bucket = None - - def observe(self, label, status, percent): - if status == "idle": - return None - bucket = percent // 10 - if (status, bucket) == (self.status, self.bucket): - return None - self.status, self.bucket = status, bucket - words = STATUS_WORDS.get(status, status) - return f"{label}: {words} {percent}%" if percent > 0 else f"{label}: {words}" - - -def request_message(request): - message = (request.get_message() or "").strip() - return message or REQUEST_MESSAGES.get(request.get_id() or "", DEFAULT_REQUEST_MESSAGE) - - -class Installer: - """Install a plan sequentially, streaming events for each device.""" - - def __init__(self, api, client, events, out): - self.api = api - self.client = client - self.events = events - self.out = out - self.current = None - self.progress = ProgressFilter() - self.human = HumanProgress() - client.connect("notify::status", self._on_progress) - client.connect("notify::percentage", self._on_progress) - client.connect("device-request", self._on_request) - - def _on_progress(self, client, *_): - planned = self.current - if planned is None: - return - change = self.progress.observe(self.api.status_name(client.get_status()), - percentage(client.get_percentage())) - if change is None: - return - status, percent = change - self.events.emit("progress", id=planned.id, status=status, percent=percent) - text = self.human.observe(planned.label, status, percent) - if text is not None: - self.out.line(text) - - def _on_request(self, client, request, *_): - planned = self.current - if planned is None: - return - message = request_message(request) - self.events.emit("request", id=planned.id, - kind=self.api.request_kind(request.get_kind()), - requestId=request.get_id() or "", message=message) - self.out.line(f"{planned.info['name']}: {message}") - - def install(self, planned): - fwupd = self.api.fwupd - - def start(done): - def finished(source, result, *_): - try: - source.install_release_finish(result) - except Exception as error: - done(error) - else: - done() - - self.client.install_release_async( - planned.device, planned.release, fwupd.InstallFlags.NONE, - fwupd.ClientDownloadFlags.NONE, None, finished) - - # No deadline: the daemon owns the flash once it starts, and - # abandoning the client would not stop a write in progress. - return run_main_loop_until(self.api, start) - - def run(self, plan): - installed = failed = 0 - needs_reboot = False - self.events.emit("plan", devices=[planned.info for planned in plan]) - if not plan: - self.out.line("No firmware updates are available.") - try: - for index, planned in enumerate(plan, 1): - self.current = planned - self.progress.reset() - self.human.reset() - self.events.emit("device", id=planned.id, index=index, count=len(plan)) - self.out.line(f"Updating {planned.label} ({index} of {len(plan)})") - error = self.install(planned) - if error is None: - event, fields = "installed", { - "needsReboot": planned.needs_reboot, - "message": (planned.release.get_update_message() or "").strip(), - } - else: - event, fields = classify_error(self.api, error) - self.current = None - self.events.emit(event, id=planned.id, **fields) - if event == "installed": - installed += 1 - needs_reboot = needs_reboot or fields["needsReboot"] - suffix = "; restart to finish" if fields["needsReboot"] else "" - self.out.line(f"{planned.label}: installed{suffix}") - elif event == "failed": - failed += 1 - detail = error_message(error) - self.out.line(f"{planned.label}: failed: {detail or fields['message']}") - else: - self.out.line(f"{planned.label}: skipped: {fields['message']}") - except Exception as error: # A bug must still leave the panel a summary. - failed += 1 - planned_id = self.current.id if self.current is not None else "" - self.current = None - self.events.emit("failed", id=planned_id, reason="other", - message=error_message(error) or FAILURE_MESSAGES["other"]) - self.out.line(f"Firmware update stopped: {error_message(error)}") - self.events.emit("summary", installed=installed, failed=failed, needsReboot=needs_reboot) - if plan: - summary = f"Firmware: {installed} installed, {failed} failed" - self.out.line(summary + ("; restart to finish installing" if needs_reboot else "")) - return EXIT_FAILED if failed else EXIT_OK - - -def command_plan(out): - try: - api = load_api() - plan = build_plan(api, open_client(api), warn=warn) - except SetupError as error: - warn(str(error)) - return EXIT_SETUP - out.line(json.dumps({"devices": [planned.info for planned in plan]}, separators=(",", ":"))) - return EXIT_OK - - -def command_install(events_path, out): - try: - events = EventLog(events_path) - except OSError as error: - warn(f"cannot open {events_path}: {error.strerror or error}") - return EXIT_SETUP - with events: - try: - api = load_api() - client = open_client(api) - plan = build_plan(api, client, warn=warn) - except Exception as error: - if isinstance(error, SetupError): - reason, message = "unavailable", str(error) - else: # A bug must still leave the panel a terminal record. - warn(f"unexpected setup failure: {error!r}") - reason, message = "other", error_message(error) or FAILURE_MESSAGES["other"] - out.line(message) - events.emit("failed", id="", reason=reason, message=message) - events.emit("summary", installed=0, failed=1, needsReboot=False) - return EXIT_SETUP - return Installer(api, client, events, out).run(plan) - - -def warn(text): - print(f"cybexos-firmware-update: {text}", file=sys.stderr, flush=True) - - -def main(argv=None): - parser = argparse.ArgumentParser( - prog="cybexos-firmware-update", - description="Install fwupd firmware updates with structured progress.") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("plan", help="print the updates `install` would apply (read-only)") - install = commands.add_parser("install", help="install every available firmware update") - install.add_argument("--events", metavar="PATH", - help="append JSON-lines progress events to PATH") - args = parser.parse_args(argv) - if hasattr(sys.stdout, "reconfigure"): - # A system unit may run with a C locale; the run log is UTF-8. - sys.stdout.reconfigure(encoding="utf-8", errors="replace") - out = HumanOutput(sys.stdout) - if args.command == "plan": - return command_plan(out) - return command_install(args.events, out) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/assets/scripts/cybexos-migrate-layering b/assets/scripts/cybexos-migrate-layering deleted file mode 100755 index 50f6d88f..00000000 --- a/assets/scripts/cybexos-migrate-layering +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -"""Classify legacy managed desktop trees without mutating user-owned paths.""" - -from __future__ import annotations - -import argparse -from datetime import datetime, timezone -import json -import os -from pathlib import Path, PurePosixPath -import shutil -import stat -import sys -from typing import Any - - -LEGACY_HYPR_FILES = ( - "features.lua", - "monitors.lua", - "input.lua", - "looknfeel.lua", - "bindings.lua", - "autostart.lua", - "hyprland.lua", - "hypridle.conf", - "hyprlock.conf", -) -STATIC_HYPR_FILES = {"bindings.lua", "autostart.lua", "hyprland.lua"} - - -def within(path: Path, parent: Path) -> bool: - try: - path.relative_to(parent) - except ValueError: - return False - return True - - -def safe_relative(value: str) -> Path | None: - pure = PurePosixPath(value) - if not value or pure.is_absolute() or ".." in pure.parts or "." in pure.parts: - return None - return Path(*pure.parts) - - -def same_entry(current: Path, baseline: Path) -> bool: - try: - current_stat = current.lstat() - baseline_stat = baseline.lstat() - except OSError: - return False - if stat.S_ISREG(current_stat.st_mode) and stat.S_ISREG(baseline_stat.st_mode): - return current.read_bytes() == baseline.read_bytes() - if stat.S_ISLNK(current_stat.st_mode) and stat.S_ISLNK(baseline_stat.st_mode): - return os.readlink(current) == os.readlink(baseline) - return False - - -def copy_entry(source: Path, destination: Path) -> None: - destination.parent.mkdir(parents=True, exist_ok=True) - source_stat = source.lstat() - if stat.S_ISLNK(source_stat.st_mode): - destination.symlink_to(os.readlink(source)) - elif stat.S_ISREG(source_stat.st_mode): - shutil.copy2(source, destination, follow_symlinks=False) - - -def copy_missing(source: Path, destination: Path) -> bool: - """Copy legacy user state only when the new namespaced path is absent.""" - if destination.exists() or destination.is_symlink(): - return False - try: - source_stat = source.lstat() - except OSError: - return False - destination.parent.mkdir(parents=True, exist_ok=True) - if stat.S_ISDIR(source_stat.st_mode) and not stat.S_ISLNK(source_stat.st_mode): - shutil.copytree(source, destination, symlinks=True) - else: - copy_entry(source, destination) - return True - - -def walk_leaf_entries(root: Path) -> set[Path]: - found: set[Path] = set() - if not root.is_dir() or root.is_symlink(): - return found - for directory, directories, files in os.walk(root, followlinks=False): - current = Path(directory) - linked_directories = [name for name in directories if (current / name).is_symlink()] - directories[:] = [name for name in directories if name not in linked_directories] - for name in [*linked_directories, *files]: - found.add((current / name).relative_to(root)) - return found - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--home", required=True, type=Path) - parser.add_argument("--previous-source", required=True, type=Path) - parser.add_argument("--runtime-root", required=True, type=Path) - args = parser.parse_args() - - home = args.home.resolve(strict=True) - if not home.is_dir() or home.stat().st_uid != os.getuid(): - raise SystemExit("layering migration: home is not owned by the current user") - - state_root = home / ".local/state/cybexos" - marker = state_root / "migrations/ownership-v1.complete" - legacy_manifest = state_root / "quickshell-manifest.txt" - for guarded in (state_root, state_root / "migrations"): - if guarded.is_symlink(): - raise SystemExit(f"layering migration: refusing linked state path: {guarded}") - if marker.is_file() and not marker.is_symlink(): - print("Layering migration already completed.") - return 0 - - state_root.mkdir(parents=True, exist_ok=True, mode=0o700) - marker.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(state_root, 0o700) - os.chmod(marker.parent, 0o700) - - # This manifest was written only by the legacy deployment. Its absence on - # a fresh install means ~/.config/{quickshell,hypr} belongs wholly to the - # user and must not even be interpreted as an earlier CybexOS tree. - if not legacy_manifest.is_file() or legacy_manifest.is_symlink(): - marker.write_text("no-legacy-deployment\n", encoding="utf-8") - os.chmod(marker, 0o600) - print("Layering migration found no legacy CybexOS deployment.") - return 0 - - stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - migration_root = marker.parent / f"ownership-v1-{stamp}" - suffix = 0 - while migration_root.exists(): - suffix += 1 - migration_root = marker.parent / f"ownership-v1-{stamp}-{suffix}" - backup_root = migration_root / "preserved" - migration_root.mkdir(mode=0o700) - - previous_source = args.previous_source.resolve(strict=False) - runtime_root = args.runtime_root.resolve(strict=False) - entries: list[dict[str, Any]] = [] - backed_up: set[str] = set() - - legacy_state_moves = ( - (home / ".local/state/quickshell/shell-settings.json", home / ".config/cybexos/shell.json"), - (home / ".local/state/quickshell/notes.json", state_root / "shell/notes.json"), - (home / ".local/state/quickshell/t3-model-favorites.json", state_root / "shell/t3-model-favorites.json"), - (home / ".local/state/quickshell/wallpaper-palette.json", state_root / "shell/wallpaper-palette.json"), - (home / ".local/state/quickshell/reminders", state_root / "shell/reminders"), - (home / ".local/state/quickshell-github.json", state_root / "shell/github.json"), - ) - for old_path, new_path in legacy_state_moves: - if copy_missing(old_path, new_path): - entries.append({ - "path": str(new_path.relative_to(home)), - "classification": "legacy-state-copied", - "baseline": str(old_path), - }) - - def classify(relative_home: str, baseline: Path | None, *, managed: bool) -> None: - current = home / relative_home - try: - current.lstat() - except OSError: - return - if baseline is not None and same_entry(current, baseline): - classification = "vendor-unchanged" - elif managed and baseline is not None and baseline.exists(): - classification = "user-modified" - elif managed: - classification = "baseline-unavailable" - else: - classification = "user-added" - if classification != "vendor-unchanged": - copy_entry(current, backup_root / relative_home) - backed_up.add(relative_home) - entries.append({ - "path": relative_home, - "classification": classification, - "baseline": str(baseline) if baseline is not None else "", - }) - - quickshell_root = home / ".config/quickshell" - if quickshell_root.is_symlink() or (quickshell_root.exists() and not quickshell_root.is_dir()): - classify(".config/quickshell", None, managed=False) - else: - quickshell_entries = walk_leaf_entries(quickshell_root) - linked_directories = { - relative - for relative in quickshell_entries - if (quickshell_root / relative).is_symlink() - } - managed_quickshell: set[Path] = set() - for line in legacy_manifest.read_text(encoding="utf-8").splitlines(): - relative = safe_relative(line.strip()) - if relative is None: - continue - managed_quickshell.add(relative) - # Never follow a user-inserted directory link while inspecting a - # formerly managed descendant. The link itself is preserved below. - if any(parent in linked_directories for parent in relative.parents): - continue - classify( - str(Path(".config/quickshell") / relative), - previous_source / "roles/desktop/files/quickshell" / relative, - managed=True, - ) - for relative in sorted(quickshell_entries - managed_quickshell): - classify(str(Path(".config/quickshell") / relative), None, managed=False) - - hypr_root = home / ".config/hypr" - if hypr_root.is_symlink() or (hypr_root.exists() and not hypr_root.is_dir()): - classify(".config/hypr", None, managed=False) - else: - for name in LEGACY_HYPR_FILES: - if name in STATIC_HYPR_FILES: - baseline = previous_source / "roles/desktop/files" / name - else: - baseline = runtime_root / "hypr" / name - classify(str(Path(".config/hypr") / name), baseline, managed=True) - - counts: dict[str, int] = {} - for entry in entries: - key = str(entry["classification"]) - counts[key] = counts.get(key, 0) + 1 - report = { - "schemaVersion": 1, - "createdAt": datetime.now(timezone.utc).isoformat(), - "policy": "classify-and-preserve; legacy paths were not modified", - "previousSource": str(previous_source), - "runtimeRoot": str(runtime_root), - "preservedRoot": str(backup_root) if backed_up else "", - "counts": counts, - "entries": entries, - } - report_path = migration_root / "report.json" - report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") - os.chmod(report_path, 0o600) - marker.write_text(str(report_path) + "\n", encoding="utf-8") - os.chmod(marker, 0o600) - - if not within(report_path, home): - raise SystemExit("layering migration: report escaped the user home") - print(f"CHANGED: legacy desktop layering classified; report: {report_path}") - if backed_up: - print(f"Preserved {len(backed_up)} customized entries under {backup_root}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/assets/scripts/cybexos-release-update b/assets/scripts/cybexos-release-update deleted file mode 100755 index d4e24921..00000000 --- a/assets/scripts/cybexos-release-update +++ /dev/null @@ -1,521 +0,0 @@ -#!/usr/bin/env bash -# Resolve, verify, stage, and activate immutable CybexOS GitHub releases. -set -euo pipefail -umask 077 - -readonly updater_version=0.1.0 -github_repo=${CYBEXOS_GITHUB_REPO:-DigitalPals/CybexOS} -channel=${CYBEXOS_CHANNEL:-stable} -channel_explicit=false -[[ -n ${CYBEXOS_CHANNEL:-} ]] && channel_explicit=true -source_dir=${CYBEXOS_SOURCE_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)} -semver_helper=${CYBEXOS_SEMVER_HELPER:-$source_dir/scripts/semver} -config_file=${CYBEXOS_CONFIG_FILE:-/etc/cybexos/config.yml} -data_root=${XDG_DATA_HOME:-$HOME/.local/share}/cybexos -releases_root=$data_root/releases -backend=${CYBEXOS_UPDATE_BACKEND:-$HOME/.local/bin/cybexos-update-run} -command_name=run -with_packages=true -with_flatpak=true -with_firmware=false -json=false -start_only=false -backend_args=() - -usage() { - cat <<'EOF' -Usage: cybex update [--check] [--channel stable|beta] [--system-only] - [--no-packages] [--no-flatpak] [--firmware] - [--start] [--json] - -By default, check GitHub for a verified CybexOS release, stage and apply -it when newer, and then update Fedora packages and system Flatpaks. No GitHub -account or login is needed. When no release has been published on the channel -yet, the project step is skipped and the command succeeds. When a release -cannot be checked, verified, or staged (offline, a GitHub error or rate limit), -packages are still updated and the command exits 69 after a successful package -run; --start reports the reason as projectError. - - --check Only report whether a compatible project release is available - --channel Save and use the stable or beta release channel - --system-only Skip the project release check and update system packages only - --no-packages Apply only a project release; do not update Fedora/Flatpak - --no-flatpak Skip system Flatpak updates - --firmware Also install available device firmware through fwupd after - the package phase - --start Start the durable worker and return without attaching - --json Emit machine-readable output for --check/--start -EOF -} - -while (($#)); do - case $1 in - --check) command_name=check ;; - --channel) - shift - [[ $# -gt 0 ]] || { echo 'update: --channel needs stable or beta' >&2; exit 2; } - channel=$1 - channel_explicit=true - ;; - --system-only) command_name=system-only ;; - --no-packages) with_packages=false ;; - --no-flatpak) with_flatpak=false ;; - --firmware) with_firmware=true ;; - --start) start_only=true; json=true ;; - --json) json=true ;; - -h|--help) usage; exit 0 ;; - --) shift; backend_args+=("$@"); break ;; - *) backend_args+=("$1") ;; - esac - shift -done - -if [[ $command_name == check && $start_only == true ]]; then - echo 'update: --check and --start cannot be combined' >&2 - exit 2 -fi - -if [[ $command_name == system-only ]]; then - if [[ $start_only == true ]]; then - args=(start --json --system-unit) - else - args=(run) - fi - [[ $with_flatpak == true ]] || args+=(--no-flatpak) - [[ $with_firmware != true ]] || args+=(--firmware) - exec "$backend" "${args[@]}" "${backend_args[@]}" -fi - -# The project release needs GitHub and gh (verification only, never a login); -# Fedora and Flatpak updates must not. Only tools every path needs are hard -# requirements here. -for required_command in curl jq python3 sha256sum tar; do - command -v "$required_command" >/dev/null 2>&1 || { - printf 'update: required command is missing: %s\n' "$required_command" >&2 - exit 2 - } -done -[[ -x $semver_helper ]] || { - printf 'update: Semantic Version helper is missing: %s\n' "$semver_helper" >&2 - exit 2 -} -[[ -r $config_file ]] || { - printf 'update: saved installer configuration is missing: %s\n' "$config_file" >&2 - [[ ! -d /etc/fedora-config ]] || - printf 'update: run ./install from a CybexOS checkout to migrate this fedora-config installation\n' >&2 - exit 2 -} - -mkdir -p -- "$releases_root" "$data_root" -if [[ $channel_explicit == false && -r $data_root/channel ]]; then - channel=$(tr -d '[:space:]' <"$data_root/channel") -fi -[[ $channel == stable || $channel == beta ]] || { - printf 'update: unsupported channel %s\n' "$channel" >&2 - exit 2 -} -if [[ $channel_explicit == true || ! -e $data_root/channel ]]; then - printf '%s\n' "$channel" >"$data_root/channel" -fi - -current_version=0.0.0 -[[ -r $source_dir/VERSION ]] && current_version=$(tr -d '[:space:]' <"$source_dir/VERSION") -"$semver_helper" validate "$current_version" || { - printf 'update: installed VERSION is not strict SemVer: %s\n' "$current_version" >&2 - exit 2 -} - -release_json=$(mktemp) -archive=$(mktemp --suffix=.tar.zst) -# gh recognizes a bundle file by its extension. -bundle_file=$(mktemp --suffix=.jsonl) -stage= -cleanup() { - rm -f -- "$release_json" "$archive" "$bundle_file" - [[ -z $stage ]] || rm -rf -- "$stage" -} -trap cleanup EXIT - -# Bound the connection and abort a stalled transfer. The installed release -# tree does not ship the system helper library, so the limits are local here. -release_curl() { - curl --connect-timeout 15 --speed-limit 1024 --speed-time 60 \ - --retry 3 --retry-connrefused --fail --silent --show-error --location "$@" -} - -# Every step that depends on GitHub or on the downloaded candidate records why -# it could not continue instead of exiting, so that being offline, an API -# error, or a rate limit still leaves package updates working. These -# functions run in conditional context, where `set -e` does not apply; each -# step checks its own status. -project_error= -project_unavailable() { - project_error=$1 - return 1 -} - -# Anonymous GitHub API request: no login and no token. The response body goes -# to the output file and the final HTTP status to http_status ("000" without -# a response). A transport failure returns curl's status and leaves its own -# message on stderr, which is what an offline check shows. -http_status=000 -github_api() { - local url=$1 output=$2 status rc=0 - status=$(curl --connect-timeout 15 --speed-limit 1024 --speed-time 60 \ - --max-time 60 --retry 3 --retry-connrefused --retry-max-time 60 \ - --silent --show-error --location \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - -w '%{http_code}' -o "$output" "$url") || rc=$? - http_status=${status:-000} - return "$rc" -} - -# Turn a GitHub HTTP error into the reason the panel and terminal show. -github_http_error() { - local what=$1 message - message=$(jq -r '.message // empty' "$release_json" 2>/dev/null || true) - case $http_status in - 403|429) - if [[ $http_status == 429 || ${message,,} == *"rate limit"* ]]; then - project_unavailable "GitHub's API rate limit for anonymous requests was reached; try again later" - else - project_unavailable "GitHub refused the $what request (HTTP $http_status)" - fi - ;; - *) project_unavailable "GitHub returned HTTP $http_status for the $what request" ;; - esac -} - -# "no-release" when the channel has nothing published yet. That is not an -# error: the project step is skipped and package updates run normally. -project_status= - -fetch_release() { - local api="https://api.github.com/repos/$github_repo" rc=0 selected - if [[ $channel == stable ]]; then - github_api "$api/releases/latest" "$release_json" || rc=$? - else - github_api "$api/releases?per_page=30" "$release_json" || rc=$? - fi - if ((rc != 0)); then - project_unavailable "could not fetch the $channel release from GitHub" - return "$rc" - fi - if [[ $channel == stable && $http_status == 404 ]]; then - # GitHub answers 404 both when no release is published and when the - # repository does not exist; only the repository itself tells them apart. - rc=0 - github_api "$api" "$release_json" || rc=$? - if ((rc != 0)); then - project_unavailable "could not fetch the $channel release from GitHub" - return "$rc" - fi - case $http_status in - 200) project_status=no-release; return 0 ;; - 404) project_unavailable "the GitHub repository $github_repo was not found"; return ;; - *) github_http_error 'repository'; return ;; - esac - fi - if [[ $http_status == 404 ]]; then - project_unavailable "the GitHub repository $github_repo was not found" - return - fi - [[ $http_status == 200 ]] || { github_http_error "$channel release"; return; } - if [[ $channel == beta ]]; then - selected=$(jq -c '[.[] | select(.draft == false and .prerelease == true)] | first // empty' \ - "$release_json") || { project_unavailable 'GitHub returned invalid release data'; return; } - if [[ -z $selected ]]; then - project_status=no-release - return 0 - fi - printf '%s\n' "$selected" >"$release_json" \ - || { project_unavailable 'could not record the beta release'; return; } - fi - - tag=$(jq -er '.tag_name | strings | select(startswith("v"))' "$release_json") \ - || { project_unavailable 'GitHub returned no usable release tag'; return; } - release_version=${tag#v} - "$semver_helper" validate "$release_version" \ - || { project_unavailable "release tag is not strict SemVer: $tag"; return; } - asset_name="cybexos-$release_version.tar.zst" - asset_url=$(jq -er --arg name "$asset_name" \ - '.assets[] | select(.name == $name) | .browser_download_url' "$release_json") \ - || { project_unavailable "release $tag has no $asset_name asset"; return; } - asset_digest=$(jq -er --arg name "$asset_name" \ - '.assets[] | select(.name == $name) | .digest | select(test("^sha256:[0-9a-f]{64}$"))' "$release_json") \ - || { project_unavailable "release $tag publishes no SHA-256 digest for $asset_name"; return; } - # The provenance bundle lets gh verify the archive offline, without the - # GitHub API and therefore without a login. - bundle_url=$(jq -er --arg name "$asset_name.sigstore.jsonl" \ - '.assets[] | select(.name == $name) | .browser_download_url' "$release_json") \ - || { project_unavailable "release $tag has no $asset_name.sigstore.jsonl provenance bundle"; return; } - immutable=$(jq -r '.immutable // false' "$release_json") - [[ $immutable == true ]] \ - || { project_unavailable "refusing mutable GitHub release $tag"; return; } -} - -# gh verifies the release workflow's provenance attestation from the bundle -# published beside the archive. That is offline verification against the -# Sigstore trust root: it needs neither the GitHub API nor a login. -attestation_ready() { - local help - command -v gh >/dev/null 2>&1 || { - project_unavailable 'the GitHub CLI (gh) is not installed; it verifies CybexOS releases' - return - } - help=$(gh attestation verify --help 2>/dev/null) || help= - [[ $help == *--bundle* && $help == *--signer-workflow* && $help == *--source-ref* ]] || { - project_unavailable 'this GitHub CLI is too old to verify CybexOS releases; upgrade gh' - return - } -} - -version_newer() { - "$semver_helper" newer "$1" "$2" -} - -compatible_manifest() { - local tree=$1 expected_version=$2 fedora architecture schema minimum_updater - fedora=$(source /etc/os-release; printf '%s' "$VERSION_ID") - architecture=$(uname -m) - schema=$(python3 -c 'import sys,yaml; print(yaml.safe_load(open(sys.argv[1])).get("config_schema_version", 0))' "$config_file") \ - || return 1 - jq -e --arg version "$expected_version" --arg fedora "$fedora" \ - --arg architecture "$architecture" --argjson schema "$schema" \ - '.product == "cybexos" - and .version == $version - and (.configSchema | type == "number") and .configSchema >= $schema - and (.supportedFedora | index($fedora)) != null - and (.architectures | index($architecture)) != null' \ - "$tree/release-manifest.json" >/dev/null || return 1 - minimum_updater=$(jq -er '.minimumUpdater | strings' \ - "$tree/release-manifest.json") || return 1 - "$semver_helper" validate "$minimum_updater" || return 1 - [[ $minimum_updater == "$updater_version" ]] \ - || version_newer "$updater_version" "$minimum_updater" -} - -# Download, verify, and extract the candidate into a hidden stage directory. -stage_release() { - local actual_digest - attestation_ready || return - if [[ $start_only == true ]]; then - printf 'Downloading CybexOS %s...\n' "$release_version" >&2 - else - printf 'Downloading CybexOS %s...\n' "$release_version" - fi - release_curl "$asset_url" -o "$archive" \ - || { project_unavailable "could not download CybexOS $release_version"; return; } - actual_digest=sha256:$(sha256sum "$archive" | awk '{print $1}') - [[ $actual_digest == "$asset_digest" ]] || { - project_unavailable "the downloaded CybexOS $release_version archive does not match GitHub's digest" - return - } - release_curl "$bundle_url" -o "$bundle_file" \ - || { project_unavailable "could not download the CybexOS $release_version provenance bundle"; return; } - # Only this repository's release workflow, building this tag on a - # GitHub-hosted runner, can have produced an accepted archive. - timeout 120 env -u GH_HOST -u GH_ENTERPRISE_TOKEN -u GITHUB_ENTERPRISE_TOKEN \ - GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 \ - gh attestation verify "$archive" --bundle "$bundle_file" --repo "$github_repo" \ - --signer-workflow "$github_repo/.github/workflows/release.yml" \ - --source-ref "refs/tags/$tag" --deny-self-hosted-runners >/dev/null \ - || { - project_unavailable "the CybexOS $release_version provenance attestation could not be verified" - return - } - - stage=$(mktemp -d "$releases_root/.stage.$release_version.XXXXXX") \ - || { project_unavailable 'could not create a release staging directory'; return; } - tar --extract --zstd --file "$archive" --directory "$stage" --strip-components=1 \ - || { project_unavailable "could not extract CybexOS $release_version"; return; } - [[ -x $stage/install && -x $stage/verify \ - && -x $stage/scripts/manage-agent-skills \ - && -r $stage/agent-skills/cybexos/SKILL.md \ - && -r $stage/release-manifest.json ]] || { - project_unavailable "the CybexOS $release_version archive is incomplete" - return - } - [[ $(tr -d '[:space:]' <"$stage/VERSION") == "$release_version" ]] || { - project_unavailable "the CybexOS $release_version archive does not match its tag" - return - } - compatible_manifest "$stage" "$release_version" || { - project_unavailable "CybexOS $release_version is incompatible with this system or saved configuration" - return - } - [[ $stage == "$releases_root/.stage.$release_version."* ]] \ - && "$semver_helper" validate "$release_version" || { - project_unavailable 'unsafe release staging path' - return - } -} - -available=false -fetch_rc=0 -release_version= -fetch_release || fetch_rc=$? -if ((fetch_rc == 0)) && [[ $project_status != no-release ]]; then - version_newer "$release_version" "$current_version" && available=true -fi -no_release="No CybexOS release has been published on the $channel channel yet" - -if [[ $command_name == check ]]; then - # A failed check keeps its reason as the last stderr line for the panel: - # curl's own error when GitHub could not be reached, otherwise ours. - if ((fetch_rc != 0)); then - [[ $http_status == 000 || -z $project_error ]] \ - || printf 'update: %s\n' "$project_error" >&2 - exit "$fetch_rc" - fi - # A release that this machine cannot verify is still reported, with the - # reason, so the panel can say what is needed before it can be applied. - # Its notes travel with it for the Updates view, which shows them as - # plain text; they are bounded and never interpreted here. - release_notes= - if [[ $available == true ]]; then - attestation_ready || true - release_notes=$(jq -r '.body | strings | .[0:8000]' "$release_json" 2>/dev/null) \ - || release_notes= - fi - status=current - [[ $available != true ]] || status=available - [[ $project_status != no-release ]] || status=no-release - if [[ $json == true ]]; then - jq -cn --arg current "$current_version" --arg availableVersion "$release_version" \ - --arg channel "$channel" --argjson available "$available" \ - --arg projectError "$project_error" --arg status "$status" \ - --arg releaseNotes "$release_notes" \ - '{currentVersion:$current,availableVersion:$availableVersion,channel:$channel, - available:$available,status:$status,projectError:$projectError, - releaseNotes:$releaseNotes}' - elif [[ $status == no-release ]]; then - printf '%s (installed: %s).\n' "$no_release" "$current_version" - elif [[ $available == true ]]; then - printf 'CybexOS %s is available (installed: %s, channel: %s).\n' \ - "$release_version" "$current_version" "$channel" - [[ -z $project_error ]] || printf 'To apply it: %s.\n' "$project_error" - else - printf 'CybexOS %s is current on the %s channel.\n' "$current_version" "$channel" - fi - exit 0 -fi - -candidate= -if [[ $available == true ]] && stage_release; then - candidate=$stage - # Transfer cleanup ownership before asking the backend to launch. The worker - # promotes this unique verified tree to its canonical version path while it - # owns the global update lock; no competing client can replace a tree already - # being applied. An interruption during launch may leave a harmless hidden - # stage for explicit maintenance, but can never remove a worker-owned tree. - stage= -fi -if [[ -n $stage ]]; then - rm -rf -- "$stage" - stage= -fi - -if [[ -n $project_error ]]; then - printf 'update: CybexOS release skipped: %s\n' "$project_error" >&2 -elif [[ $project_status == no-release && $with_packages == true ]]; then - # Informational, not a skipped release: --start keeps stdout for its JSON. - if [[ $start_only == true ]]; then - printf '%s; updating Fedora packages and system Flatpaks.\n' "$no_release" >&2 - else - printf '%s; updating Fedora packages and system Flatpaks.\n' "$no_release" - fi -fi - -if [[ -n $candidate ]]; then - update_args=(start --json --system-unit --full --skip-tests --repo "$candidate" - --release-version "$release_version" --release-data-root "$data_root" - --release-config "$config_file") - [[ $with_packages == true ]] || update_args+=(--no-packages) - [[ $with_flatpak == true ]] || update_args+=(--no-flatpak) - [[ $with_firmware != true ]] || update_args+=(--firmware) - update_args+=(-- -e "@$config_file") -elif [[ $with_packages == true ]]; then - [[ -z $project_error ]] \ - || printf 'update: updating Fedora packages and system Flatpaks only\n' >&2 - if [[ $start_only == true ]]; then - update_args=(start --json --system-unit) - else - update_args=(run) - fi - [[ $with_flatpak == true ]] || update_args+=(--no-flatpak) - [[ $with_firmware != true ]] || update_args+=(--firmware) -else - [[ -z $project_error ]] || exit 1 - if [[ $start_only == true ]]; then - echo 'update: no project or package work was requested' >&2 - exit 4 - fi - if [[ $project_status == no-release ]]; then - printf '%s; nothing to apply.\n' "$no_release" - else - echo 'CybexOS is already current.' - fi - exit 0 -fi - -if [[ -z $candidate ]]; then - if [[ $start_only == true ]]; then - set +e - backend_output=$("$backend" "${update_args[@]}" "${backend_args[@]}") - backend_rc=$? - set -e - # The panel reads one JSON record. Carry the skipped release's reason in - # it, so a started package-only run can say why CybexOS was not updated. - if [[ -n $project_error ]] \ - && merged=$(jq -c --arg projectError "$project_error" \ - '. + {projectError: $projectError}' <<<"$backend_output" 2>/dev/null); then - backend_output=$merged - fi - printf '%s\n' "$backend_output" - exit "$backend_rc" - fi - # Stream the attached package run directly to the terminal. - set +e - "$backend" "${update_args[@]}" "${backend_args[@]}" - backend_rc=$? - set -e - if [[ -n $project_error ]] && ((backend_rc == 0)); then - printf 'update: system packages were updated, but CybexOS was not: %s\n' \ - "$project_error" >&2 - exit 69 - fi - exit "$backend_rc" -fi - -set +e -backend_output=$("$backend" "${update_args[@]}" "${backend_args[@]}") -backend_rc=$? -set -e -if ((backend_rc == 3)); then - active_id=$(jq -r '.id // empty' <<<"$backend_output" 2>/dev/null || true) - printf 'update: another update is active%s; retry after it finishes\n' \ - "${active_id:+ ($active_id)}" >&2 - [[ $candidate == "$releases_root/.stage.$release_version."* ]] && rm -rf -- "$candidate" - exit 75 -fi -if ((backend_rc != 0)); then - [[ -z $backend_output ]] || printf '%s\n' "$backend_output" >&2 - [[ $candidate == "$releases_root/.stage.$release_version."* ]] && rm -rf -- "$candidate" - exit "$backend_rc" -fi -run_id=$(jq -er 'select(.existing == false) | .id' <<<"$backend_output") || { - echo 'update: backend did not return a new durable transaction' >&2 - exit 1 -} -if [[ $start_only == true ]]; then - printf '%s\n' "$backend_output" - exit 0 -fi -printf 'Started durable release update %s. Ctrl+C detaches without cancelling it.\n' "$run_id" -set +e -"$backend" attach "$run_id" -backend_rc=$? -set -e -exit "$backend_rc" diff --git a/assets/scripts/cybexos-report b/assets/scripts/cybexos-report deleted file mode 100755 index 0736bb13..00000000 --- a/assets/scripts/cybexos-report +++ /dev/null @@ -1,467 +0,0 @@ -#!/usr/bin/env python3 -"""Collect a redacted CybexOS diagnostic bundle for a bug report. - -cybex report gathers the doctor result, versions, a hardware summary, recent -journal excerpts and shell health into one local archive, removes personal -data, shows what it collected, and can open a prefilled GitHub issue. It -never uploads anything: the user reviews the archive and attaches it. -""" -import argparse -import datetime -import gzip -import hashlib -import io -import ipaddress -import json -import os -from pathlib import Path -import pwd -import re -import shutil -import socket -import subprocess -import sys -import tarfile -import urllib.parse - -ISSUE_URL = 'https://github.com/DigitalPals/CybexOS/issues/new' -# GitHub accepts .gz/.tgz/.zip attachments but not .zst, so the default -# archive is a gzip tarball the user can attach directly. -DEFAULT_SUFFIX = '.tar.gz' -COMMAND_TIMEOUT = 20 -DOCTOR_TIMEOUT = 240 -JOURNAL_LINES = 300 -# Hostnames that identify nothing; redacting them would erase "Fedora". -GENERIC_HOSTS = {'localhost', 'localhost.localdomain', 'fedora', 'cybexos', 'linux', 'host'} -KEY_PACKAGES = ('cybexos-desktop', 'kernel', 'hyprland', 'quickshell', 'aquamarine', 'mesa-dri-drivers', - 'xorg-x11-drv-nvidia', 'akmod-nvidia', 'systemd', 'sddm', 'pipewire', 'wireplumber', - 'NetworkManager', 'flatpak', 'xdg-desktop-portal-hyprland', 'linux-firmware') - - -# ---- redaction --------------------------------------------------------- - -SECRET_PATTERNS = ( - ('key', re.compile(r'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----', re.S), - '[private key]'), - ('token', re.compile(r'\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{22,})\b'), '[token]'), - ('token', re.compile(r'\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}'), '[token]'), - ('token', re.compile(r'\b(?:AKIA|ASIA)[0-9A-Z]{16}\b'), '[token]'), - ('token', re.compile(r'\bAIza[0-9A-Za-z_-]{35}\b'), '[token]'), - ('token', re.compile(r'\bxox[abposr]-[A-Za-z0-9-]{10,}'), '[token]'), - ('token', re.compile(r'\bglpat-[A-Za-z0-9_-]{20,}'), '[token]'), - ('token', re.compile(r'\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}'), '[token]'), - ('token', re.compile(r'(?i)(\bbearer\s+)[A-Za-z0-9._~+/=-]{8,}'), r'\1[token]'), - ('token', re.compile(r'(?i)(\b[a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@'), r'\1[credentials]@'), - ('token', re.compile(r'(?i)([?&](?:access_token|token|key|api_key|code|sig|signature|secret)=)[^&\s"\']+'), - r'\1[token]'), - # name = value / name: value / "name": "value" for secret-bearing names. - ('token', re.compile(r'(?i)(\b[\w.-]*(?:password|passwd|passphrase|secret|token|api[_-]?key|access[_-]?key' - r'|private[_-]?key|credentials?|cookie|session[_-]?id|auth(?:orization)?)["\']?\s*[:=]\s*' - r'["\']?)(?!\[)([^\s"\',;&}]+)'), r'\1[token]'), -) -# user@1000.service and friends are systemd unit instances, not addresses. -EMAIL = re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.(?!(?:service|socket|target|timer|mount|slice|scope' - r'|path|device|swap)\b)[A-Za-z]{2,}\b') -MAC = re.compile(r'(?= 1000 and entry.pw_name not in ('nobody', 'nfsnobody'): - users.add(entry.pw_name) - homes.add(entry.pw_dir) - full = entry.pw_gecos.split(',', 1)[0].strip() - if full: - names.add(full) - names.update(part for part in full.split() if len(part) >= 3) - users.add(os.environ.get('USER', '')) - hosts = {socket.gethostname(), read('/etc/hostname').strip()} - hosts |= {host.split('.', 1)[0] for host in hosts if host} - for line in read('/etc/machine-info').splitlines(): - if line.startswith('PRETTY_HOSTNAME='): - hosts.add(line.split('=', 1)[1].strip().strip('"')) - ssids = set() - rc, output = run(['nmcli', '-t', '-f', 'NAME,TYPE', 'connection', 'show']) - if rc == 0: - for line in output.splitlines(): - name, _, kind = line.rpartition(':') - if kind == '802-11-wireless' and name: - ssids.add(name.replace('\\:', ':')) - code, ssid = run(['nmcli', '-g', '802-11-wireless.ssid', 'connection', 'show', name.replace('\\:', ':')]) - if code == 0 and ssid: - ssids.add(ssid) - machine = {read('/etc/machine-id').strip(), read('/proc/sys/kernel/random/boot_id').strip()} - # Hyprland's monitor descriptions end in the panel's serial number. - serials = set() - rc, output = run(['hyprctl', 'monitors', 'all', '-j']) - monitors = json_or_text(output) if rc == 0 else None - for monitor in monitors if isinstance(monitors, list) else []: - if isinstance(monitor, dict) and isinstance(monitor.get('serial'), str): - serials.add(monitor['serial']) - return {'users': users, 'names': names, 'hosts': hosts, 'homes': homes, 'ssids': ssids, - 'machine_ids': {value for value in machine if value}, 'serials': serials} - - -def json_or_text(output): - try: - return json.loads(output) - except ValueError: - return None - - -def hardware_summary(): - """Model and devices; DMI serial numbers and UUIDs are never read.""" - lines = [] - for field in ('sys_vendor', 'product_name', 'product_version', 'board_vendor', 'board_name', 'bios_version'): - value = read(f'/sys/class/dmi/id/{field}').strip() - if value: - lines.append(f'{field}: {value}') - model = next((line.split(':', 1)[1].strip() for line in read('/proc/cpuinfo').splitlines() - if line.startswith('model name')), '') - lines.append(f'cpu: {model}') - memory = next((line.split(':', 1)[1].strip() for line in read('/proc/meminfo').splitlines() - if line.startswith('MemTotal')), '') - lines.append(f'memory: {memory}') - sections = ['\n'.join(lines)] - for title, argv in (('Secure Boot', ['mokutil', '--sb-state']), - ('PCI devices', ['lspci', '-nnk']), - ('USB devices', ['lsusb']), - ('Block devices', ['lsblk', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINTS']), - ('Monitors', ['hyprctl', 'monitors', 'all']), - ('Kernel command line', ['cat', '/proc/cmdline'])): - rc, output = run(argv) - sections.append(f'## {title}\n{output if rc == 0 else f"unavailable ({output})"}') - for path in ('/etc/cybexos/hardware.json', '/var/lib/cybexos/hardware-status.json', - '/var/lib/cybexos/nvidia-status.json'): - if Path(path).is_file(): - sections.append(f'## {path}\n{read(path).strip()}') - return '\n\n'.join(sections) + '\n' - - -def gpu_summary(): - rc, output = run(['lspci', '-nnk']) - gpus, current = [], None - if rc == 0: - for line in output.splitlines(): - if not line.startswith(('\t', ' ')): - current = None - if re.search(r'(VGA compatible|3D|Display) controller', line): - current = {'name': line.split(': ', 1)[-1], 'driver': ''} - gpus.append(current) - elif current is not None and 'Kernel driver in use:' in line: - current['driver'] = line.split(':', 1)[1].strip() - return [f"{gpu['name']} ({gpu['driver'] or 'no driver'})" for gpu in gpus] - - -def journal(arguments, user=False): - argv = ['journalctl'] + (['--user'] if user else []) + ['-b', '--no-pager', '-o', 'short-iso', - '-n', str(JOURNAL_LINES)] + arguments - rc, output = run(argv) - return output if rc == 0 else f'unavailable ({output})' - - -def collect(doctor_timeout=DOCTOR_TIMEOUT): - """Unredacted sections, by archive member name. Redaction happens once.""" - cybex = tool('cybex') - sections = {} - rc, doctor = run([cybex, 'doctor', '--json'], timeout=doctor_timeout) - sections['doctor.json'] = doctor if json_or_text(doctor) is not None else json.dumps( - {'available': False, 'exit_code': rc, 'output': doctor[-4000:]}) - release = os_release() - version_lines = [f'installation: {installation()}', f"os: {release.get('PRETTY_NAME', 'unknown')}", - f'kernel: {os.uname().release}', f'architecture: {os.uname().machine}', - f"session: {os.environ.get('XDG_CURRENT_DESKTOP', '')} {os.environ.get('XDG_SESSION_TYPE', '')}".strip()] - rc, version = run([cybex, 'version']) - version_lines.insert(0, f"cybexos: {version if rc == 0 else 'unknown'}") - rc, packages = run(['rpm', '-q', *KEY_PACKAGES]) - version_lines += ['', '## Packages', packages] - for title, argv in (('Hyprland', ['hyprctl', 'version']), ('Quickshell', ['qs', '--version'])): - rc, output = run(argv) - version_lines += ['', f'## {title}', output if rc == 0 else f'unavailable ({output})'] - sections['versions.txt'] = '\n'.join(version_lines) + '\n' - sections['hardware.txt'] = hardware_summary() - rc, shell = run([cybex, 'shell', 'status']) - sections['shell-health.json'] = shell if rc == 0 and json_or_text(shell) is not None else json.dumps( - {'available': False, 'output': shell[-2000:]}) - failed = [] - for scope, argv in (('system', ['systemctl', '--failed', '--plain', '--no-legend']), - ('user', ['systemctl', '--user', '--failed', '--plain', '--no-legend'])): - rc, output = run(argv) - failed.append(f'## {scope}\n{output if rc == 0 else f"unavailable ({output})"}'.rstrip()) - sections['failed-units.txt'] = '\n\n'.join(failed) + '\n' - rc, errors = run(['hyprctl', 'configerrors']) - session_log = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hyprland/session.log' - sections['journal/hyprland.txt'] = (f'## hyprctl configerrors\n{errors}\n\n## {session_log}\n' - f'{read(session_log, JOURNAL_LINES)}\n') - sections['journal/quickshell.txt'] = journal(['-u', 'quickshell.service'], user=True) + '\n' - sections['journal/sddm.txt'] = journal(['-u', 'sddm.service']) + '\n' - rc, update = run([tool('cybexos-update-run'), 'status', '--json']) - sections['journal/update-worker.txt'] = (f'## cybexos-update-run status\n{update}\n\n## user units\n' - f"{journal(['-u', 'cybexos-update-*'], user=True)}\n\n" - f"## system units\n{journal(['-u', 'cybexos-update-*'])}\n") - sections['journal/hardware-setup.txt'] = '\n\n'.join( - f'## {unit}\n{journal(["-u", unit])}' for unit in - ('cybexos-hardware-setup.service', 'akmods.service', 'nvidia-fallback.service')) + '\n' - return sections - - -def summary(sections, redactions, name): - release = os_release() - doctor = json_or_text(sections.get('doctor.json', '')) or {} - failing = [check.get('name', '') for check in doctor.get('checks', []) - if isinstance(check, dict) and check.get('state') in ('fail', 'warning', 'pending')] - version = next((line.split(': ', 1)[1] for line in sections['versions.txt'].splitlines() - if line.startswith('cybexos: ')), 'unknown') - return { - 'version': 1, - 'archive': name, - 'cybexos': version, - 'installation': installation(), - 'fedora': release.get('VERSION_ID', 'unknown'), - 'kernel': os.uname().release, - 'gpus': gpu_summary(), - 'doctor': doctor.get('overall', 'unavailable') if isinstance(doctor, dict) else 'unavailable', - 'attention': failing[:12], - 'members': sorted(sections), - 'redactions': dict(sorted(redactions.items())), - } - - -def issue_url(info, redact): - """A short, redacted environment block; the logs stay in the archive.""" - lines = [f"- CybexOS: {info['cybexos']} ({info['installation']} installation)", - f"- Fedora {info['fedora']}, kernel {info['kernel']}"] - lines += [f'- GPU: {gpu}' for gpu in info['gpus'][:3]] - attention = ', '.join(info['attention']) if info['attention'] else 'none' - lines += [f"- cybex doctor: {info['doctor']} (needs attention: {attention})", - f"- Diagnostic bundle: {info['archive']} (attach it to this issue)"] - query = {'template': 'bug_report.yml', 'labels': 'bug', 'environment': redact('\n'.join(lines))} - return ISSUE_URL + '?' + urllib.parse.urlencode(query, quote_via=urllib.parse.quote) - - -def default_directory(): - """The XDG download folder, where a browser's file picker starts.""" - home = Path.home() - for line in read(Path(os.environ.get('XDG_CONFIG_HOME', home / '.config')) / 'user-dirs.dirs').splitlines(): - if line.startswith('XDG_DOWNLOAD_DIR='): - path = Path(line.split('=', 1)[1].strip().strip('"').replace('$HOME', str(home))) - if path.is_dir(): - return path - return home / 'Downloads' if (home / 'Downloads').is_dir() else home - - -def output_path(requested, stamp): - name = f'cybexos-report-{stamp}{DEFAULT_SUFFIX}' - if not requested: - return default_directory() / name - path = Path(requested).expanduser() - if path.is_dir(): - return path / name - if not path.name.endswith(('.tar.gz', '.tgz', '.tar.zst')): - raise SystemExit('cybex report: --output must be a directory or end in .tar.gz, .tgz or .tar.zst') - return path - - -def write_archive(path, members, stamp): - """Owner-only archive with fixed metadata; .tar.zst needs the zstd tool.""" - root = path.name.split('.tar', 1)[0].removesuffix('.tgz') - buffer = io.BytesIO() - with tarfile.open(fileobj=buffer, mode='w', format=tarfile.PAX_FORMAT) as archive: - for name, text in sorted(members.items()): - data = text.encode() - info = tarfile.TarInfo(f'{root}/{name}') - info.size, info.mode, info.mtime = len(data), 0o600, int(stamp.timestamp()) - archive.addfile(info, io.BytesIO(data)) - raw = buffer.getvalue() - if path.name.endswith('.tar.zst'): - result = subprocess.run(['zstd', '-q', '-19', '-c'], input=raw, capture_output=True, check=False) - if result.returncode: - raise SystemExit('cybex report: zstd is needed for a .tar.zst archive') - data = result.stdout - else: - data = gzip.compress(raw, mtime=int(stamp.timestamp())) - path.parent.mkdir(parents=True, exist_ok=True) - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, 'wb') as stream: - stream.write(data) - os.chmod(path, 0o600) - return hashlib.sha256(data).hexdigest(), len(data) - - -def readme(info): - return ('CybexOS diagnostic bundle\n\n' - 'Created by `cybex report`. Personal data was replaced with placeholders such as\n' - '[user], [hostname], /home/[user], [mac], [ipv4], [ipv6], [serial], [uuid], [ssid],\n' - '[email] and [token]. Review the files before attaching the archive to a public issue;\n' - 'automatic redaction cannot recognize every private value.\n\n' - f"Redactions by kind: {json.dumps(info['redactions'], sort_keys=True)}\n") - - -def main(argv=None): - parser = argparse.ArgumentParser(prog='cybex report', description=__doc__.splitlines()[0]) - parser.add_argument('--output', help='archive path (.tar.gz, .tgz or .tar.zst) or a directory') - parser.add_argument('--json', action='store_true', help='print a machine-readable summary; never prompt') - parser.add_argument('--no-browser', action='store_true', help='do not offer to open a GitHub issue') - args = parser.parse_args(argv) - - stamp = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) - path = output_path(args.output, stamp.strftime('%Y%m%d-%H%M%S')) - if not args.json: - print('Collecting diagnostics; cybex doctor can take a minute…', file=sys.stderr, flush=True) - found = identities() - redact = Redactor(**found) - members = {name: redact(text) for name, text in collect().items()} - info = summary(members, redact.counts, path.name) - members['summary.json'] = json.dumps(info, indent=2, sort_keys=True) + '\n' - members['README.txt'] = readme(info) - digest, size = write_archive(path, members, stamp) - url = issue_url(info, redact) - if args.json: - print(json.dumps({'path': str(path), 'sha256': digest, 'bytes': size, 'issueUrl': url, - 'summary': info}, sort_keys=True)) - return 0 - print(f'Diagnostic bundle: {path} ({size} bytes)') - print(f"CybexOS {info['cybexos']} ({info['installation']}), Fedora {info['fedora']}, kernel {info['kernel']}") - for gpu in info['gpus']: - print(f'GPU: {gpu}') - print(f"cybex doctor: {info['doctor']}" + (f" (needs attention: {', '.join(info['attention'])})" - if info['attention'] else '')) - print('Files: ' + ', '.join(info['members'])) - removed = ', '.join(f'{count} {kind}' for kind, count in info['redactions'].items()) or 'nothing found' - print(f'Redacted: {removed}') - print('Review the archive before sharing it; nothing has been uploaded.') - if args.no_browser or not sys.stdin.isatty(): - print(f'Report the issue at {ISSUE_URL} and attach {path}.') - return 0 - try: - answer = input('Open a prefilled GitHub issue in your browser? [y/N] ').strip().lower() - except EOFError: - answer = '' - if answer in ('y', 'yes'): - rc, output = run(['xdg-open', url]) - if rc: - print(f'Could not open the browser ({output}). Open this address instead:\n{url}') - else: - print(f'Attach {path.name} to the issue; the browser does not upload it for you.') - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/assets/scripts/cybexos-runtime b/assets/scripts/cybexos-runtime deleted file mode 100755 index a03d19d8..00000000 --- a/assets/scripts/cybexos-runtime +++ /dev/null @@ -1,413 +0,0 @@ -#!/usr/bin/env bash -# Resolve the active CybexOS runtime without making a checkout mutable. -set -euo pipefail -umask 077 - -config_root=${XDG_CONFIG_HOME:-$HOME/.config}/cybexos -data_root=${XDG_DATA_HOME:-$HOME/.local/share}/cybexos -runtime_root=$data_root/runtime -dev_source_file=$config_root/dev-source - -usage() { - cat <<'EOF' -Usage: - cybexos-runtime path quickshell|hypr-source|hypr-generated|hypr-config - cybexos-runtime exec quickshell|hypridle|hyprlock - cybexos-runtime ipc TARGET FUNCTION [ARGUMENT...] - cybexos-runtime dev enable CHECKOUT - cybexos-runtime dev disable - cybexos-runtime dev status - cybexos-runtime shell status|safe|recover - cybexos-runtime plugin add|update|clone|remove|list|enable|disable|set|reload|restart [arguments] - -The development switch only records a validated checkout. It never fetches, -resets, merges, or otherwise changes that checkout. - -ipc calls an IpcHandler function on the running shell of the selected -runtime. It never starts a shell. -EOF -} - -validate_checkout() { - local requested=${1:-} canonical top mode - [[ -n $requested && $requested == /* && -d $requested ]] || return 1 - canonical=$(readlink -f -- "$requested") || return 1 - [[ -n $canonical && $canonical == /* && -d $canonical ]] || return 1 - [[ $canonical != *$'\n'* && $canonical != *$'\r'* ]] || return 1 - [[ $(stat -c %u -- "$canonical") == "$(id -u)" ]] || return 1 - mode=$(stat -c %a -- "$canonical") || return 1 - # A runtime sourced from a world-writable checkout lets another account - # replace code immediately before the desktop starts. - (( (8#$mode & 0002) == 0 )) || return 1 - [[ -e $canonical/.git ]] || return 1 - top=$(git -C "$canonical" rev-parse --show-toplevel 2>/dev/null) || return 1 - top=$(readlink -f -- "$top") || return 1 - [[ $top == "$canonical" ]] || return 1 - [[ -f $canonical/roles/desktop/files/quickshell/shell.qml - && -f $canonical/roles/desktop/files/hyprland.lua - && -f $canonical/roles/desktop/files/bindings.lua - && -f $canonical/roles/desktop/files/autostart.lua ]] || return 1 - printf '%s\n' "$canonical" -} - -configured_dev_source() { - local value - local -a values=() - [[ -f $dev_source_file && ! -L $dev_source_file ]] || return 1 - mapfile -t values <"$dev_source_file" - [[ ${#values[@]} -eq 1 ]] || return 1 - value=${values[0]} - [[ -n $value ]] || return 1 - validate_checkout "$value" -} - -active_source() { - local source - if source=$(configured_dev_source); then - printf '%s\n' "$source" - return 0 - fi - if [[ -e $dev_source_file || -L $dev_source_file ]]; then - printf 'cybexos-runtime: ignoring invalid development source: %s\n' \ - "$dev_source_file" >&2 - fi - return 1 -} - -runtime_path() { - local kind=$1 source - case $kind in - quickshell) - if source=$(active_source); then - printf '%s/roles/desktop/files/quickshell\n' "$source" - else - printf '%s/quickshell\n' "$runtime_root" - fi - ;; - hypr-source) - if source=$(active_source); then - printf '%s/roles/desktop/files\n' "$source" - else - printf '%s/hypr\n' "$runtime_root" - fi - ;; - hypr-generated) printf '%s/hypr\n' "$runtime_root" ;; - hypr-config) printf '%s/hypr/hyprland.lua\n' "$runtime_root" ;; - *) printf 'cybexos-runtime: unknown runtime path %s\n' "$kind" >&2; return 2 ;; - esac -} - -restart_desktop() { - [[ ${CYBEXOS_RUNTIME_TESTING:-0} == 1 ]] && return 0 - systemctl --user try-restart quickshell.service || { - printf 'cybexos-runtime: Quickshell could not be restarted\n' >&2 - return 1 - } - if [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]] && command -v hyprctl >/dev/null; then - hyprctl reload >/dev/null || { - printf 'cybexos-runtime: Hyprland could not reload the selected source\n' >&2 - return 1 - } - fi -} - -dev_command() { - local action=${1:-} source temporary - shift || true - case $action in - enable) - [[ $# -eq 1 ]] || { usage >&2; return 2; } - source=$(validate_checkout "$1") || { - printf 'cybex dev: %s is not a safe CybexOS checkout\n' "$1" >&2 - return 2 - } - if [[ -e $config_root || -L $config_root ]]; then - [[ -d $config_root && ! -L $config_root ]] || { - printf 'cybex dev: refusing linked or non-directory config root: %s\n' \ - "$config_root" >&2 - return 2 - } - else - mkdir -p -- "$config_root" - fi - if [[ -e $dev_source_file || -L $dev_source_file ]]; then - [[ -f $dev_source_file && ! -L $dev_source_file ]] || { - printf 'cybex dev: refusing linked or non-file switch: %s\n' \ - "$dev_source_file" >&2 - return 2 - } - fi - temporary=$(mktemp "$config_root/.dev-source.XXXXXX") - trap 'rm -f -- "${temporary:-}"' RETURN - printf '%s\n' "$source" >"$temporary" - chmod 0600 "$temporary" - mv -f -- "$temporary" "$dev_source_file" - temporary= - restart_desktop - printf 'Development runtime enabled: %s\n' "$source" - ;; - disable) - [[ $# -eq 0 ]] || { usage >&2; return 2; } - if [[ -e $dev_source_file || -L $dev_source_file ]]; then - rm -f -- "$dev_source_file" - restart_desktop - printf 'Development runtime disabled.\n' - else - printf 'Development runtime is already disabled.\n' - fi - ;; - status) - [[ $# -eq 0 ]] || { usage >&2; return 2; } - if source=$(configured_dev_source); then - printf 'enabled\t%s\n' "$source" - elif [[ -e $dev_source_file || -L $dev_source_file ]]; then - printf 'invalid\t%s\n' "$dev_source_file" - return 1 - else - printf 'disabled\t%s\n' "$runtime_root" - fi - ;; - *) usage >&2; return 2 ;; - esac -} - -# Applies the shell's idle timeouts (shell.json) to the vendor hypridle -# configuration. Prints the rendered file, or the vendor file when rendering is -# impossible, so a broken settings file never leaves the session without idle -# locking. -render_hypridle_config() { - local vendor=$1 script output_dir output temporary session_action - script=$(runtime_path quickshell)/scripts/hypridle-config.py - session_action=/usr/local/libexec/cybexos-session-action - [[ -x $session_action || ! -x /usr/libexec/cybexos-session-action ]] || - session_action=/usr/libexec/cybexos-session-action - output_dir=${XDG_RUNTIME_DIR:-}/cybexos - output=$output_dir/hypridle.conf - if [[ -z ${XDG_RUNTIME_DIR:-} || ! -f $script ]] || - ! mkdir -p -- "$output_dir" || - ! temporary=$(mktemp "$output_dir/.hypridle.conf.XXXXXX"); then - printf 'cybexos-runtime: using default idle timeouts\n' >&2 - printf '%s\n' "$vendor" - return 0 - fi - if python3 "$script" "$config_root/shell.json" "$session_action" >"$temporary" && - mv -f -- "$temporary" "$output"; then - printf '%s\n' "$output" - else - rm -f -- "$temporary" - printf 'cybexos-runtime: could not apply idle settings; using defaults\n' >&2 - printf '%s\n' "$vendor" - fi -} - -# Prints the lock screen the system theme rendered in the shell's colours -# (docs/system-theme.md), or fails so the vendor file is used. Only a regular -# file whose first line is the renderer's header counts: the renderer replaces -# it atomically, and hyprlock skips entries it cannot parse rather than -# refusing to lock, so a damaged file costs the look, never the lock. -themed_hyprlock_config() { - local file first - file=${XDG_STATE_HOME:-$HOME/.local/state}/cybexos/theme/hyprlock.conf - [[ -f $file && ! -L $file && -s $file ]] || return 1 - IFS= read -r first <"$file" || return 1 - [[ $first == '# CybexOS lock screen, rendered by the system theme (theme_hyprlock.py).' ]] || - return 1 - printf '%s\n' "$file" -} - -# Whether this account registered a security key in the pam_u2f mapping the -# lock screen's PAM service reads (docs/security.md). -registered_security_key() { - local mapping=$HOME/.config/Yubico/u2f_keys user line - [[ -f $mapping && ! -L $mapping ]] || return 1 - user=$(id -un) || return 1 - while IFS= read -r line || [[ -n $line ]]; do - [[ $line == "$user:"?* ]] && return 0 - done <"$mapping" - return 1 -} - -# Adds the sign-in factors to a CybexOS lock screen (docs/security.md): the -# cybexos-lock PAM service, which has no pam_fprintd step and tries a -# registered security key before the password, and hyprlock's own fprintd -# check beside it while authselect offers fingerprints. Prints the rendered -# file, or the input file when that PAM service is absent: a lock naming a -# missing service would refuse every password, and hyprlock's default service -# already asks fprintd itself. -render_hyprlock_config() { - local selected=$1 lock_pam=/etc/pam.d/cybexos-lock - local authselect_conf=/etc/authselect/authselect.conf - local output_dir output temporary fingerprint=false keys=false - output_dir=${XDG_RUNTIME_DIR:-}/cybexos - output=$output_dir/hyprlock.conf - if [[ -z ${XDG_RUNTIME_DIR:-} || ! -f $lock_pam ]] || - ! mkdir -p -- "$output_dir" || - ! temporary=$(mktemp "$output_dir/.hyprlock.conf.XXXXXX"); then - printf '%s\n' "$selected" - return 0 - fi - grep -qx 'with-fingerprint' "$authselect_conf" 2>/dev/null && fingerprint=true - registered_security_key && keys=true - if { - if [[ $keys == true ]]; then - # PAM tries the key when the field is submitted, so an empty Enter - # must reach it, and the wait for a touch needs words. Both edits - # match only CybexOS's own lock layouts. - sed -e 's|>Password|>Password or security key|' \ - -e '/^ *placeholder_text = /a\ check_text = Touch your security key if it flashes' \ - -- "$selected" && - printf '\ngeneral:ignore_empty_input = false\n' - else - cat -- "$selected" - fi - } >"$temporary" && cat >>"$temporary" <&2 - printf '%s\n' "$selected" - fi -} - -exec_runtime() { - local kind=$1 selected user_config themed - case $kind in - quickshell) - selected=$(runtime_path quickshell) - [[ -f $selected/shell.qml ]] || { - printf 'cybexos-runtime: Quickshell runtime is incomplete: %s\n' \ - "$selected" >&2 - return 1 - } - # The authentication UI belongs to the selected shell. Stop the old - # agent synchronously before QML registers its listener; this also - # handles an upgrade while the previous agent is still running. - # Older releases remain usable when rolling back or leaving dev mode. - if [[ ${CYBEXOS_RUNTIME_TESTING:-0} != 1 ]] && { - systemctl --user is-active --quiet hyprpolkitagent.service || - [[ $(systemctl --user show hyprpolkitagent.service -p LoadState --value 2>/dev/null) == loaded ]]; - }; then - if [[ -f $selected/PolkitWindow.qml ]]; then - systemctl --user stop hyprpolkitagent.service - else - systemctl --user start --no-block hyprpolkitagent.service - fi - fi - export CYBEXOS_USER_CONFIG_ROOT=$config_root - export CYBEXOS_THEME_ROOT=$data_root/themes - export CYBEXOS_PLUGIN_ROOT=$data_root/plugins - export OMARCHY_PATH=$selected/compat/omarchy - export PATH=$OMARCHY_PATH/bin:$PATH - if [[ -f $selected/scripts/shell-recovery.py ]]; then - exec python3 "$selected/scripts/shell-recovery.py" run "$selected" - fi - exec /usr/bin/qs -p "$selected" - ;; - hypridle|hyprlock) - user_config=$config_root/hypr/$kind.conf - selected=$runtime_root/hypr/$kind.conf - [[ ! -f $user_config || -L $user_config ]] || selected=$user_config - if [[ $kind == hyprlock && $selected != "$user_config" ]] && - themed=$(themed_hyprlock_config); then - selected=$themed - fi - [[ -f $selected ]] || { - printf 'cybexos-runtime: %s configuration is missing: %s\n' \ - "$kind" "$selected" >&2 - return 1 - } - if [[ $kind == hypridle && $selected != "$user_config" ]]; then - selected=$(render_hypridle_config "$selected") - fi - # A person's own lock file keeps exactly the authentication it sets. - if [[ $kind == hyprlock && $selected != "$user_config" ]]; then - selected=$(render_hyprlock_config "$selected") - fi - if [[ $kind == hypridle ]]; then - exec /usr/bin/hypridle --config "$selected" - else - exec /usr/bin/hyprlock --config "$selected" \ - --immediate-render --no-fade-in --quiet - fi - ;; - *) printf 'cybexos-runtime: unknown executable %s\n' "$kind" >&2; return 2 ;; - esac -} - -# The shell runs as `qs -p `, not as a named configuration, so a bare -# `qs ipc call` finds no "default" config and reaches nothing. Instances are -# matched by config path, which must be the one exec_runtime selected, the -# development checkout included. The timeout keeps a hung shell from -# stranding one client per key press; with no shell running, qs fails at once. -ipc_call() { - local selected - selected=$(runtime_path quickshell) - if [[ -f $selected/scripts/shell-recovery.py ]]; then - selected=$(python3 "$selected/scripts/shell-recovery.py" path "$selected") - fi - # --any-display: a caller started by systemd (a reminder timer) need not - # carry the session's display. The -- keeps function names that shadow qs - # subcommands (e.g. show) positional. - exec timeout --kill-after=1s 2s \ - qs ipc --any-display -p "$selected" call -- "$@" -} - -command_name=${1:-} -[[ $# -eq 0 ]] || shift -case $command_name in - path) - [[ $# -eq 1 ]] || { usage >&2; exit 2; } - runtime_path "$1" - ;; - exec) - [[ $# -eq 1 ]] || { usage >&2; exit 2; } - exec_runtime "$1" - ;; - ipc) - [[ $# -ge 2 ]] || { usage >&2; exit 2; } - ipc_call "$@" - ;; - dev) dev_command "$@" ;; - shell) - [[ $# -eq 1 ]] || { usage >&2; exit 2; } - case $1 in status|safe|recover|record-stop) ;; *) usage >&2; exit 2 ;; esac - selected=$(runtime_path quickshell) - # A previous release without recovery support remains rollback-compatible. - [[ -f $selected/scripts/shell-recovery.py ]] || { - [[ $1 != record-stop ]] || exit 0 - printf 'cybexos-runtime: this desktop release has no recovery mode\n' >&2 - exit 1 - } - python3 "$selected/scripts/shell-recovery.py" "$1" "$selected" - if [[ $1 == safe || $1 == recover ]]; then - exec systemctl --user restart quickshell.service - fi - ;; - plugin) - selected=$(runtime_path quickshell) - if [[ ${1:-} == reload ]]; then - exec "$selected/compat/omarchy/bin/omarchy-shell" shell rescanPlugins - fi - if [[ ${1:-} == restart ]]; then - exec systemctl --user restart quickshell.service - fi - exec python3 "$selected/scripts/user-plugins.py" "$@" - ;; - -h|--help|help) usage ;; - *) usage >&2; exit 2 ;; -esac diff --git a/assets/scripts/cybexos-theme b/assets/scripts/cybexos-theme deleted file mode 100755 index c4aa27ab..00000000 --- a/assets/scripts/cybexos-theme +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# `cybex theme`: list, apply, install, export and remove named appearance -# themes (docs/shell-appearance.md#themes). The work happens in the active -# shell runtime's scripts/theme-packs.py, which Settings → Appearance uses as -# well, so both read the same theme schema; this wrapper only finds that -# runtime, a development checkout included, and the IPC helper beside it. -set -euo pipefail - -usage() { - cat <<'USAGE' -Usage: cybex theme list [--json] - cybex theme apply ID - cybex theme install SOURCE [--replace] - cybex theme export ID|CURRENT [DIR] [--name NAME] - cybex theme remove ID - -SOURCE is a theme folder, a .tar.gz or .zip archive, or an https:// Git URL. -Themes are data: theme.json and one optional PNG, JPEG or WebP wallpaper. -USAGE -} - -case ${1:-} in - -h|--help|help) usage; exit 0 ;; - '') usage >&2; exit 2 ;; -esac - -helper_dir=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")") -runtime=$helper_dir/cybexos-runtime -if [[ ! -x $runtime ]]; then - runtime=$(command -v cybexos-runtime) || { - printf 'cybex theme: cybexos-runtime is not installed\n' >&2 - exit 1 - } -fi -quickshell=$("$runtime" path quickshell) -script=$quickshell/scripts/theme-packs.py -if [[ ! -f $script ]]; then - printf 'cybex theme: the active desktop release has no theme support\n' >&2 - exit 1 -fi -export CYBEXOS_RUNTIME_HELPER=$runtime -exec python3 -B "$script" "$@" diff --git a/assets/scripts/cybexos-update-run b/assets/scripts/cybexos-update-run deleted file mode 100755 index 031834dd..00000000 --- a/assets/scripts/cybexos-update-run +++ /dev/null @@ -1,1760 +0,0 @@ -#!/usr/bin/env bash -# Durable updater shared by the repository CLI and Quickshell. - -set -uo pipefail -umask 077 - -state_root="${XDG_STATE_HOME:-$HOME/.local/state}/cybexos/update" -log_shelf="$state_root/logs" -current_file="$state_root/current" -runtime_root="${XDG_RUNTIME_DIR:-$state_root/runtime}" -lock_file="$runtime_root/update.lock" -boot_id_file="${CYBEXOS_UPDATE_BOOT_ID_FILE:-/proc/sys/kernel/random/boot_id}" -script_path="$(readlink -f -- "$0")" -mkdir -p -- "$log_shelf" "$runtime_root" -chmod 0700 -- "$state_root" "$log_shelf" "$runtime_root" 2>/dev/null || true - -usage() { - cat <<'EOF' -Usage: - cybexos-update-run run [OPTIONS] [ANSIBLE_ARGS...] - cybexos-update-run start [--json] [OPTIONS] [ANSIBLE_ARGS...] - cybexos-update-run status [--json] [RUN_ID] - cybexos-update-run attach [RUN_ID] - cybexos-update-run cancel [RUN_ID] - cybexos-update-run dismiss [RUN_ID] - cybexos-update-run log-dir [RUN_ID] - cybexos-update-run history [--json] [--limit N] - -Update Fedora packages and system Flatpaks. The worker runs as a transient -service, so closing a terminal or reloading Quickshell does not abandon the -transaction. Only one CLI or panel update can run at a time. `cancel` never -interrupts a running package transaction, firmware update, or Ansible play: -the worker stops at the next safe step boundary. `history` lists finished -runs, newest first, with the recovery point each one created. - -Options for run/start: - -f, --full Also run site.yml with --skip-tags boot - --skip-tests Skip repository checks before a full configuration run - --no-packages Skip Fedora and Flatpak updates (requires --full or - --firmware) - --no-flatpak Skip the system Flatpak update - --firmware Also install available device firmware through fwupd - after the package phase - --release-version VERSION - --release-data-root PATH - --release-config PATH - Finalize a staged release in the durable worker (internal) - -h, --help Show this help message and exit - -ANSIBLE_ARGS require --full and are passed verbatim to ansible-playbook. - -Examples: - ./update - ./update --full - ./update --full --check --diff - cybexos-update-run status - cybexos-update-run attach - cybexos-update-run cancel -EOF -} - -valid_id() { [[ ${1:-} =~ ^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$ ]]; } - -current_id() { - local id="" - [[ -r $current_file ]] && IFS= read -r id < "$current_file" - valid_id "$id" && printf '%s\n' "$id" -} - -resolve_id() { - local id=${1:-} - [[ -n $id ]] || id=$(current_id) - valid_id "$id" || { - printf 'cybexos-update-run: no update run is available\n' >&2 - return 1 - } - printf '%s\n' "$id" -} - -status_path() { printf '%s/%s/status.json\n' "$log_shelf" "$1"; } - -current_boot_id() { - local id="" - [[ -r $boot_id_file ]] && IFS= read -r id < "$boot_id_file" - [[ -n $id ]] || return 1 - printf '%s\n' "$id" -} - -valid_reboot_recommendation() { - case ${1:-} in - pending|checking|recommended|not-needed|unavailable) return 0 ;; - *) return 1 ;; - esac -} - -# Recommendations belong to one boot. Missing fields are legacy records, and -# a record from an earlier boot is no longer evidence that this boot should be -# restarted. Normalize both cases at the status boundary without rewriting a -# status file that an active worker may be replacing concurrently. -status_reboot_recommendation() { - local file=$1 expected_boot=$2 recorded_boot recommendation - recommendation="unavailable" - if [[ -n $expected_boot && -r $file ]]; then - recorded_boot=$(jq -r '.bootId // empty' "$file" 2>/dev/null || true) - if [[ $recorded_boot == "$expected_boot" ]]; then - recommendation=$(jq -r '.rebootRecommendation // "unavailable"' \ - "$file" 2>/dev/null || printf unavailable) - valid_reboot_recommendation "$recommendation" \ - || recommendation="unavailable" - fi - fi - printf '%s\n' "$recommendation" -} - -# A capsule staged by fwupd waits for the next boot, and dnf's -# needs-restarting knows nothing about it. Carry that fact to every later run -# of the same boot so a package-only run cannot downgrade the advice to -# not-needed; a record from an earlier boot no longer applies. -status_firmware_reboot() { - local file=$1 expected_boot=$2 recorded_boot staged=false - if [[ -n $expected_boot && -r $file ]]; then - recorded_boot=$(jq -r '.bootId // empty' "$file" 2>/dev/null || true) - if [[ $recorded_boot == "$expected_boot" ]] \ - && jq -e '.firmwareReboot == true' "$file" >/dev/null 2>&1; then - staged=true - fi - fi - printf '%s\n' "$staged" -} - -status_state() { - local file - file=$(status_path "$1") - [[ -r $file ]] || return 1 - jq -er '.state | strings' "$file" 2>/dev/null -} - -is_active() { [[ ${1:-} == queued || ${1:-} == running ]]; } - -unit_active_state() { - local unit=$1 system_unit=${2:-false} - if [[ $system_unit == true ]]; then - systemctl show "$unit" --property=ActiveState --value 2>/dev/null - else - systemctl --user show "$unit" --property=ActiveState --value 2>/dev/null - fi -} - -system_manager_command() { - local command_name=$1 command_path - shift - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 ]]; then - command_path=$(command -v -- "$command_name") || { - printf 'cybexos-update-run: required command is missing: %s\n' \ - "$command_name" >&2 - return 127 - } - else - command_path="/usr/bin/$command_name" - fi - - # A terminal invocation may already have a sudo timestamp, in which case - # preserve its non-interactive path. Quickshell deliberately requests a - # system unit without priming sudo: systemd then authorizes its own - # manage-units action through the graphical session's Polkit agent. - if ((EUID != 0)) && sudo -n true >/dev/null 2>&1; then - sudo -n "$command_path" "$@" - else - "$command_path" "$@" - fi -} - -report_existing_run() { - local id=$1 state=$2 json_mode=$3 - if [[ $json_mode == true ]]; then - json_status "$id" | jq '. + {existing: true}' - else - printf 'An update is already %s.\n%s\n' "$state" "$id" - fi -} - -repair_stale_status() ( - local id=$1 caller_holds_lock=${2:-false} - local file state unit active_state started now temporary system_unit - - # The worker holds this lock until after publishing its terminal status. - # Take the same lock before synthesizing an abandoned result so a fast - # failure cannot be mistaken for a vanished worker between its last - # `running` write and its final write. start_run already owns the lock in - # its stale-record path and tells us not to acquire it a second time. - if [[ $caller_holds_lock != true ]]; then - exec 8>"$lock_file" - flock -n 8 || return 0 - fi - - file=$(status_path "$id") - [[ -r $file ]] || return 0 - state=$(jq -r '.state // "unknown"' "$file" 2>/dev/null || printf unknown) - is_active "$state" || return 0 - unit=$(jq -r '.unit // empty' "$file" 2>/dev/null || true) - system_unit=$(jq -r '.systemUnit // false' "$file" 2>/dev/null || printf false) - active_state="" - if [[ -n $unit ]] && ! active_state=$(unit_active_state "$unit" "$system_unit"); then - printf 'cybexos-update-run: cannot inspect %s; preserving %s status\n' \ - "$unit" "$state" >&2 - return 0 - fi - case $active_state in - active|activating|reloading|deactivating) return 0 ;; - esac - # Allow the small interval between publishing "queued" and systemd-run - # registering the transient unit. A stale record from a crash/reboot is - # older than this and must not block every subsequent update forever. - started=$(jq -r '.startedAt // 0' "$file" 2>/dev/null || printf 0) - now=$(date +%s) - if [[ $state == queued && $started =~ ^[0-9]+$ ]] && (( now - started < 10 )); then - return 0 - fi - temporary="$file.$$.abandoned" - jq --argjson now "$now" \ - '.state = "failed" | .phase = "abandoned" | - .message = "The update worker disappeared before recording completion" | - .finishedAt = $now | .exitCode = 125' "$file" > "$temporary" - mv -f -- "$temporary" "$file" -) - -prune_logs() { - local keep_id=${1:-} dir id - local -a ids=() - while IFS= read -r id; do - valid_id "$id" && ids+=("$id") - done < <(find "$log_shelf" -mindepth 1 -maxdepth 1 -type d \ - -printf '%f\n' 2>/dev/null | sort -r) - # Keep the twenty newest runs. Old malformed directories and the current - # run are never inferred as deletion targets. - for (( index=20; index<${#ids[@]}; index++ )); do - id=${ids[index]} - [[ $id == "$keep_id" ]] && continue - dir="$log_shelf/$id" - [[ -d $dir && $dir != "$log_shelf" ]] && rm -rf -- "$dir" - done -} - -atomic_text() { - local target=$1 value=$2 temporary - temporary="${target}.$$.tmp" - printf '%s\n' "$value" > "$temporary" - mv -f -- "$temporary" "$target" -} - -json_status() { - local id=${1:-} file dnf_bytes=0 flatpak_bytes=0 firmware_event_bytes=0 boot_id - local recommendation firmware_reboot - boot_id=$(current_boot_id || true) - [[ -n $id ]] || id=$(current_id || true) - if ! valid_id "$id"; then - jq -cn --arg bootId "$boot_id" \ - '{state: "idle", id: "", bootId: $bootId, - rebootRecommendation: "unavailable"}' - return - fi - repair_stale_status "$id" - file=$(status_path "$id") - if [[ ! -r $file ]]; then - jq -n --arg id "$id" --arg bootId "$boot_id" \ - '{id: $id, state: "unknown", bootId: $bootId, - rebootRecommendation: "unavailable"}' - return 1 - fi - [[ ! -r $log_shelf/$id/dnf.log ]] || dnf_bytes=$(wc -c < "$log_shelf/$id/dnf.log") - [[ ! -r $log_shelf/$id/flatpak.log ]] || flatpak_bytes=$(wc -c < "$log_shelf/$id/flatpak.log") - [[ ! -r $log_shelf/$id/firmware-events.log ]] \ - || firmware_event_bytes=$(wc -c < "$log_shelf/$id/firmware-events.log") - recommendation=$(status_reboot_recommendation "$file" "$boot_id") - # Like the recommendation, a staged capsule belongs to the boot it waits - # for: once that boot has ended, the firmware has been applied. - firmware_reboot=$(status_firmware_reboot "$file" "$boot_id") - # A deferred cancellation is visible while the worker finishes its step. - local cancel_pending=false - [[ ! -e $log_shelf/$id/cancel.pending ]] || cancel_pending=true - jq --argjson dnfBytes "$dnf_bytes" --argjson flatpakBytes "$flatpak_bytes" \ - --argjson firmwareEventBytes "$firmware_event_bytes" \ - --arg bootId "$boot_id" --arg rebootRecommendation "$recommendation" \ - --argjson firmwareReboot "$firmware_reboot" \ - --argjson cancelPending "$cancel_pending" \ - '. + {dnfBytes: $dnfBytes, flatpakBytes: $flatpakBytes, - firmwareEventBytes: $firmwareEventBytes, firmwareReboot: $firmwareReboot, - bootId: $bootId, rebootRecommendation: $rebootRecommendation, - cancelRequested: ($cancelPending - and (.state == "queued" or .state == "running"))}' "$file" -} - -human_status() { - json_status "${1:-}" | jq -r 'if .state == "idle" then "No update run is recorded." - else "\(.id): \(.state)\(if .phase then " (" + .phase + ")" else "" end)\nlogs: \(.logDir)" end' -} - -write_status() { - local state=$1 phase=$2 message=${3:-} exit_code=${4:-0} finished=0 - local temporary="$run_dir/.status.$$.tmp" - local status_boot_id=${boot_id:-} status_reboot=${reboot_recommendation:-unavailable} - valid_reboot_recommendation "$status_reboot" || status_reboot="unavailable" - [[ $state == queued || $state == running ]] || finished=$(date +%s) - jq -n --arg id "$run_id" --arg state "$state" --arg phase "$phase" \ - --arg message "$message" --arg logDir "$run_dir" --arg unit "$unit_name" \ - --arg releaseVersion "${release_version:-}" \ - --arg bootId "$status_boot_id" --arg rebootRecommendation "$status_reboot" \ - --argjson startedAt "$started_at" --argjson finishedAt "$finished" \ - --argjson systemUnit "${system_unit:-false}" \ - --argjson exitCode "$exit_code" --argjson full "$full" \ - --argjson flatpak "$with_flatpak" --argjson dnfDone "$dnf_done" \ - --argjson flatpakDone "$flatpak_done" --argjson dnfRc "$dnf_rc" \ - --argjson flatpakRc "$flatpak_rc" --argjson testsRc "$tests_rc" \ - --argjson ansibleRc "$ansible_rc" --arg snapshotId "${snapshot_id:-}" \ - --argjson mixedState "${mixed_state:-false}" \ - --arg transactionProtection "${transaction_protection:-unavailable}" \ - --arg rollbackState "${rollback_state:-none}" \ - --argjson firmware "${with_firmware:-false}" \ - --argjson firmwareDone "${firmware_done:-false}" \ - --argjson firmwareRc "${firmware_rc:-0}" \ - --argjson firmwareReboot "${firmware_reboot:-false}" \ - '{id: $id, state: $state, phase: $phase, message: $message, - logDir: $logDir, unit: $unit, systemUnit: $systemUnit, startedAt: $startedAt, - finishedAt: $finishedAt, exitCode: $exitCode, full: $full, - flatpak: $flatpak, dnfDone: $dnfDone, flatpakDone: $flatpakDone, - dnfRc: $dnfRc, flatpakRc: $flatpakRc, testsRc: $testsRc, - ansibleRc: $ansibleRc, firmware: $firmware, firmwareDone: $firmwareDone, - firmwareRc: $firmwareRc, firmwareReboot: $firmwareReboot, - snapshotId: $snapshotId, bootId: $bootId, - releaseVersion: $releaseVersion, mixedState: $mixedState, - transactionProtection: $transactionProtection, rollbackState: $rollbackState, - deferredCancel: true, - rebootRecommendation: $rebootRecommendation}' > "$temporary" - mv -f -- "$temporary" "$run_dir/status.json" - if [[ -n ${CYBEXOS_UPDATE_OWNER_UID:-} ]]; then - chown "${CYBEXOS_UPDATE_OWNER_UID}:${CYBEXOS_UPDATE_OWNER_GID}" \ - "$run_dir/status.json" 2>/dev/null || true - fi -} - -parse_options() { - full=false; skip_tests=false; with_packages=true; with_flatpak=true; json=false; repo="" - with_firmware=false - release_version=""; release_data_root=""; release_config=""; release_target="" - release_transaction=false - system_unit=${CYBEXOS_UPDATE_SYSTEM_UNIT:-false} - ansible_args=() - while (( $# )); do - case $1 in - -f|--full) full=true ;; - --skip-tests) skip_tests=true ;; - --no-packages) with_packages=false; with_flatpak=false ;; - --no-flatpak) with_flatpak=false ;; - --firmware) with_firmware=true ;; - --json) json=true ;; - --system-unit) system_unit=true ;; - --release-version|--release-data-root|--release-config) - option=$1 - shift - [[ $# -gt 0 ]] || { - printf 'cybexos-update-run: %s needs a value\n' "$option" >&2 - return 2 - } - case $option in - --release-version) release_version=$1 ;; - --release-data-root) release_data_root=$1 ;; - --release-config) release_config=$1 ;; - esac - ;; - --repo) - shift - [[ $# -gt 0 ]] || { printf 'cybexos-update-run: --repo needs a path\n' >&2; return 2; } - repo=$1 - ;; - -h|--help) usage; return 10 ;; - --) shift; ansible_args+=("$@"); break ;; - *) ansible_args+=("$1") ;; - esac - shift - done - if (( ${#ansible_args[@]} > 0 )) && [[ $full != true ]]; then - printf 'cybexos-update-run: Ansible arguments require --full\n\n' >&2 - usage >&2 - return 2 - fi - # A run needs some work: skipping packages leaves the configuration run or - # a firmware-only update. - if [[ $with_packages == false && $full != true && $with_firmware != true ]]; then - printf 'cybexos-update-run: --no-packages requires --full or --firmware\n' >&2 - return 2 - fi - if [[ $full == true ]]; then - [[ -n $repo ]] || repo=$PWD - repo=$(readlink -f -- "$repo") - if [[ ! -f $repo/site.yml || ! -x $repo/tests/run ]]; then - printf 'cybexos-update-run: --full requires the configuration repository path\n' >&2 - return 2 - fi - fi - if [[ -n $release_version || -n $release_data_root || -n $release_config ]]; then - if [[ $full != true || -z $release_version || -z $release_data_root \ - || -z $release_config ]]; then - printf 'cybexos-update-run: a release transaction requires --full and all release options\n' >&2 - return 2 - fi - release_data_root=$(readlink -f -- "$release_data_root") || return 2 - release_config=$(readlink -f -- "$release_config") || return 2 - release_target="$release_data_root/releases/$release_version" - release_parent=$(readlink -f -- "$(dirname -- "$repo")") || return 2 - release_name=${repo##*/} - if [[ ! -d $release_data_root/releases || ! -f $release_config \ - || ! -r $repo/agent-skills/cybexos/SKILL.md \ - || ! -x $repo/scripts/manage-agent-skills \ - || ! -x $repo/scripts/migrate-config || ! -x $repo/scripts/semver \ - || ! -r $repo/VERSION ]] \ - || ! "$repo/scripts/semver" validate "$release_version" \ - || [[ $(tr -d '[:space:]' <"$repo/VERSION") != "$release_version" ]] \ - || { [[ $repo != "$release_target" ]] \ - && [[ $release_parent != "$release_data_root/releases" \ - || $release_name != ".stage.$release_version."* ]]; }; then - printf 'cybexos-update-run: invalid staged release transaction\n' >&2 - return 2 - fi - release_transaction=true - fi -} - -# A transient unit has no terminal. sudo's default timestamp_type=tty keys a -# cached authorization to the terminal, so a ticket this client holds does not -# reach a user-unit worker; only sudo that works without any terminal (NOPASSWD -# or a global timestamp) does. Probe exactly that condition. -detached_sudo_available() { - setsid -w sudo -n true /dev/null 2>&1 -} - -start_run() { - parse_options "$@" - local parse_rc=$? - [[ $parse_rc -eq 0 ]] || { [[ $parse_rc -eq 10 ]] && return 0; return "$parse_rc"; } - if [[ ${CYBEXOS_UPDATE_ASSUME_USER_UNIT:-0} != 1 \ - && $system_unit != true ]] && ! detached_sudo_available; then - if ! sudo -n true >/dev/null 2>&1; then - if [[ $json == true || ! -t 0 ]]; then - printf 'cybexos-update-run: authentication is required; run `cybex update` in a terminal\n' >&2 - return 77 - fi - printf 'Authenticate once to start the durable system update worker.\n' - sudo -v || return - fi - # The terminal's ticket authorizes launching a root system unit now; the - # worker itself then needs no further authentication. - system_unit=true - fi - exec 9>"$lock_file" - local active_id active_state="" - - # The worker owns this lock for its complete package/test/Ansible lifetime. - # A client must never wait for that transaction and then launch another one; - # resolve the already-published run immediately and let callers attach. - if ! flock -n 9; then - active_id=$(current_id || true) - if [[ -n $active_id ]]; then - repair_stale_status "$active_id" - active_state=$(status_state "$active_id" || true) - fi - if is_active "$active_state"; then - report_existing_run "$active_id" "$active_state" "$json" - return 3 - fi - # install and uninstall hold the same lock for their whole playbook. - printf 'cybexos-update-run: the update lock is held by another CybexOS operation (install, uninstall, or a launching update); retry after it finishes\n' >&2 - return 4 - fi - - active_id=$(current_id || true) - if [[ -n $active_id ]]; then - repair_stale_status "$active_id" true - active_state=$(status_state "$active_id" || true) - fi - if is_active "$active_state"; then - report_existing_run "$active_id" "$active_state" "$json" - return 3 - fi - - local run_id unit_name run_dir started_at dnf_done=false flatpak_done=false - local dnf_rc=0 flatpak_rc=0 tests_rc=0 ansible_rc=0 - local firmware_done=false firmware_rc=0 firmware_reboot=false - local boot_id reboot_recommendation snapshot_id="" - boot_id=$(current_boot_id || true) - reboot_recommendation="pending" - if [[ -n $active_id ]]; then - reboot_recommendation=$(status_reboot_recommendation \ - "$(status_path "$active_id")" "$boot_id") - [[ $reboot_recommendation == recommended ]] \ - || reboot_recommendation="pending" - firmware_reboot=$(status_firmware_reboot \ - "$(status_path "$active_id")" "$boot_id") - fi - run_id="$(date +%Y%m%d-%H%M%S)-$$-$RANDOM" - unit_name="cybexos-update-$run_id.service" - run_dir="$log_shelf/$run_id" - mkdir -p -- "$run_dir" - : > "$run_dir/dnf.log"; : > "$run_dir/flatpak.log"; : > "$run_dir/run.log" - # Root appends to the firmware logs; creating them here keeps them owned by - # this user, so the unprivileged panel can still read them with read-log. - : > "$run_dir/firmware.log"; : > "$run_dir/firmware-events.log" - started_at=$(date +%s) - write_status queued queued "Waiting for the update worker" 0 - atomic_text "$current_file" "$run_id" - prune_logs "$run_id" - - local worker_args=("$script_path" _worker "$run_id") - # SELinux keeps the system manager (init_t) from executing a release copy - # under ~/.local/share (data_home_t), so the system unit starts bash, which - # may read the script, rather than executing the script itself. - [[ $system_unit == true ]] && worker_args=(/usr/bin/bash "${worker_args[@]}" --system-unit) - [[ $full == true ]] && worker_args+=(--full --repo "$repo") - if [[ $release_transaction == true ]]; then - worker_args+=(--release-version "$release_version" - --release-data-root "$release_data_root" --release-config "$release_config") - fi - [[ $skip_tests == true ]] && worker_args+=(--skip-tests) - [[ $with_packages == true ]] || worker_args+=(--no-packages) - [[ $with_flatpak == true ]] || worker_args+=(--no-flatpak) - [[ $with_firmware != true ]] || worker_args+=(--firmware) - (( ${#ansible_args[@]} == 0 )) || worker_args+=(-- "${ansible_args[@]}") - # KillMode=mixed sends a stop's SIGTERM to the worker alone. It lets a - # running dnf transaction or Ansible play finish and records the - # cancellation at the next step boundary; everything left is killed only - # after TimeoutStopSec. The worker also yields CPU and I/O to the desktop. - local -a unit_properties=( - --property=Type=exec - --property=KillMode=mixed - --property=TimeoutStopSec=30min - --property=Nice=10 - --property=IOWeight=20 - --property=CPUWeight=20 - ) - local launch_rc=0 - if [[ $system_unit == true ]]; then - system_manager_command systemd-run --system --quiet --collect \ - --unit="$unit_name" \ - "${unit_properties[@]}" \ - --setenv="HOME=$HOME" \ - --setenv="XDG_STATE_HOME=${XDG_STATE_HOME:-$HOME/.local/state}" \ - --setenv="XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ - --setenv="CYBEXOS_UPDATE_OWNER_UID=$(id -u)" \ - --setenv="CYBEXOS_UPDATE_OWNER_GID=$(id -g)" \ - -- "${worker_args[@]}" || launch_rc=$? - else - systemd-run --user --quiet --collect --unit="$unit_name" \ - "${unit_properties[@]}" -- "${worker_args[@]}" || launch_rc=$? - fi - if ((launch_rc != 0)); then - write_status failed launch "Could not start the update worker" 125 - [[ $json == true ]] && json_status "$run_id" | jq '. + {existing: false}' \ - || printf 'cybexos-update-run: could not start the update worker\n' >&2 - return 1 - fi - [[ $json == true ]] && json_status "$run_id" | jq '. + {existing: false}' \ - || printf '%s\n' "$run_id" -} - -tagged_command() { - local tag=$1 logfile=$2 rcfile=$3 - shift 3 - set +e - stdbuf -oL -eL "$@" 2>&1 | tee "$logfile" \ - | sed -u "s/^/[$tag] /" >> "$run_dir/run.log" - local -a result=("${PIPESTATUS[@]}") - local code=${result[0]} - if ((result[1] != 0 || result[2] != 0)); then code=125; fi - printf '%s\n' "$code" > "$rcfile" || return 125 - return 0 -} - -phase_result() { - local value='' - if [[ -r $1 ]] && IFS= read -r value <"$1" \ - && [[ $value =~ ^[0-9]{1,3}$ ]] && ((10#$value <= 255)); then - printf '%s\n' "$((10#$value))" - else - # An empty/missing result must never mean success (Bash arithmetic treats - # the empty string as zero), especially when log storage filled up. - printf '125\n' - fi -} - -worker_finalized=false -release_config_changed=false -release_activated=false -release_backup="" -cancel_requested=false -packages_ran=false -mixed_state=false -transaction_started=false -transaction_applying=false -transaction_helper="" -transaction_protection=unavailable -rollback_state=none -firmware_done=false -firmware_rc=0 -firmware_reboot=false -inhibitor_pid="" - -# SIGTERM from `cybexos-update-run cancel` (systemctl stop) only records the -# request. Interrupting dnf/rpm or an Ansible play midway can leave duplicate -# packages or a half-applied configuration, so the worker lets the running -# step finish and stops at the next boundary through honor_cancellation. -request_cancellation() { cancel_requested=true; } - -honor_cancellation() { - [[ $cancel_requested == true ]] || return 0 - worker_cancelled -} - -# wait_for_job PID -# A trapped signal makes `wait` return early while the job is still running; -# keep waiting until it has really exited. -wait_for_job() { - local pid=$1 - until wait "$pid"; do - kill -0 "$pid" 2>/dev/null || return 0 - done -} - -# Hold a logind block inhibitor for the worker's lifetime so shutdown and -# sleep requests are refused while packages or configuration change. The -# worker must stay the unit's main process (it alone receives a stop's -# SIGTERM), so a child holds the lock and exits together with the worker. A -# user unit may be refused by Polkit; the update then continues unprotected. -# logind's default LidSwitchIgnoreInhibited=yes still suspends on lid close. -acquire_inhibitor() { - local ready="$run_dir/.inhibit.$$.ready" error_file="$run_dir/inhibit.err" - if ! command -v systemd-inhibit >/dev/null 2>&1; then - printf '[inhibit] systemd-inhibit is unavailable; shutdown and sleep are not blocked\n' \ - >>"$run_dir/run.log" - return 0 - fi - rm -f -- "$ready" - systemd-inhibit --no-ask-password --what=shutdown:sleep --mode=block \ - --who=CybexOS --why='Applying updates' \ - sh -c ': >"$1" && exec tail --pid="$2" -f /dev/null' \ - cybexos-update-inhibit "$ready" "$$" \ - /dev/null 2>"$error_file" & - inhibitor_pid=$! - for _ in {1..100}; do - if [[ -e $ready ]]; then - rm -f -- "$ready" - printf '[inhibit] shutdown and sleep are blocked until the update finishes\n' \ - >>"$run_dir/run.log" - return 0 - fi - kill -0 "$inhibitor_pid" 2>/dev/null || break - sleep 0.05 - done - release_inhibitor - rm -f -- "$ready" - printf '[inhibit] could not block shutdown and sleep; continuing: %s\n' \ - "$(tr -s '\n' ' ' <"$error_file" 2>/dev/null | cut -c1-200)" >>"$run_dir/run.log" -} - -release_inhibitor() { - [[ -n $inhibitor_pid ]] || return 0 - kill "$inhibitor_pid" 2>/dev/null || true - wait "$inhibitor_pid" 2>/dev/null || true - inhibitor_pid="" -} - -restore_release_configuration() { - [[ ${release_transaction:-false} == true && $release_config_changed == true \ - && $release_activated != true ]] || return 0 - [[ -r $release_backup ]] || return 1 - if ((EUID == 0)); then - install -m 0644 "$release_backup" "$release_config" - else - sudo -n install -m 0644 "$release_backup" "$release_config" - fi - printf '[release] restored the pre-update installer configuration\n' \ - >>"$run_dir/run.log" - release_config_changed=false -} - -# Files on /home are outside the root recovery point. The shared helper owns -# a manifest of vendor-only integration paths and journals its checkpoint in -# the top-level Btrfs store. Settings, plugins and personal data never rewind. -drain_installed_provisioning() { - [[ -x /usr/libexec/cybexos-configure-installed ]] || return 0 - # Older baseline helpers predate containment and do not accept --drain. - # The newly installed helper advertises the interface before it is used. - local interface - interface=$(/usr/libexec/cybexos-configure-installed --help) || return 1 - [[ $interface == *--drain* ]] || return 0 - "${privileged_package[@]}" /usr/libexec/cybexos-configure-installed --drain \ - >>"$run_dir/run.log" 2>&1 -} - -finish_failed_transaction() { - # The installed helper uses its own systemd cgroup. A killed reconciler - # must not allow recovery to race that independently supervised workload. - # Early cancellation can run this finalizer before privilege setup. At that - # point no provisioning workload has been launched by this worker. - if [[ -n ${privileged_package[0]:-} ]]; then - drain_installed_provisioning || return 1 - fi - [[ $transaction_started == true ]] || return 0 - local action=abort - [[ $transaction_applying != true ]] || action=rollback - local journal journal_state - journal=$("${privileged_package[@]}" "$transaction_helper" status "$run_id" 2>/dev/null) || journal='' - journal_state=$(jq -r '.state // empty' <<<"$journal" 2>/dev/null) || journal_state='' - case $journal_state in - prepared) action=abort ;; - applying|validating|rolling-back|rollback-failed|awaiting-desktop) action=rollback ;; - committed|aborted) transaction_started=false; return 0 ;; - esac - if "${privileged_package[@]}" "$transaction_helper" "$action" "$run_id" \ - >>"$run_dir/run.log" 2>&1; then - transaction_started=false - if [[ $action == rollback ]]; then - rollback_state=restart-required - reboot_recommendation=recommended - # The currently mounted root remains the failed generation until boot. - mixed_state=true - fi - else - rollback_state=failed - return 1 - fi -} - -worker_failed() { - local phase=$1 message=$2 rc=$3 - # A capsule staged before a later step failed still waits for a restart. - apply_firmware_reboot_recommendation - if ! restore_release_configuration; then - phase=rollback - message="$message; restoring the previous installer configuration also failed" - rc=125 - fi - if ! finish_failed_transaction; then - phase=rollback - message="$message; automatic recovery failed; use the recovery boot menu" - rc=125 - elif [[ $rollback_state == restart-required ]]; then - message="$message; the previous system and vendor desktop were restored; restart to use them" - fi - write_status failed "$phase" "$message" "$rc" - worker_finalized=true - exit "$rc" -} - -prepare_release_configuration() { - [[ $release_transaction == true ]] || return 0 - local migrated="$run_dir/config.migrated.yml" - local migration_error="$run_dir/config-migration.err" - if ! "$repo/scripts/migrate-config" "$release_config" \ - >"$migrated" 2>"$migration_error"; then - return 1 - fi - if cmp -s -- "$release_config" "$migrated"; then - printf '[release] installer configuration already uses the target schema\n' \ - >>"$run_dir/run.log" - return 0 - fi - release_backup="$run_dir/config.before.yml" - cp --preserve=mode,timestamps -- "$release_config" "$release_backup" || return 1 - release_config_changed=true - "${privileged_package[@]}" install -m 0644 "$migrated" "$release_config" || return 1 - printf '[release] migrated installer configuration for %s\n' "$release_version" \ - >>"$run_dir/run.log" -} - -run_as_update_owner() { - local owner_uid=${CYBEXOS_UPDATE_OWNER_UID:-$EUID} - local owner_gid=${CYBEXOS_UPDATE_OWNER_GID:-$(id -g)} - [[ $owner_uid =~ ^[0-9]+$ && $owner_gid =~ ^[0-9]+$ ]] || { - printf 'cybexos-update-run: invalid update-owner identity\n' >&2 - return 1 - } - if [[ $owner_uid == "$EUID" && $owner_gid == "$(id -g)" ]]; then - "$@" - return - fi - if ((EUID != 0)) || [[ ! -x /usr/bin/sudo ]]; then - printf 'cybexos-update-run: cannot become update owner %s:%s\n' \ - "$owner_uid" "$owner_gid" >&2 - return 1 - fi - /usr/bin/sudo -n -u "#$owner_uid" -g "#$owner_gid" -- "$@" -} - -activate_release() { - [[ $release_transaction == true ]] || return 0 - local releases_root="$release_data_root/releases" expected - local current_link="$release_data_root/current" previous_target="" - local next_link="$release_data_root/.current.$run_id.new" - local restore_link="$release_data_root/.current.$run_id.restore" - local previous_present=false - expected=$(readlink -f -- "$releases_root/$release_version") || return 1 - [[ $expected == "$repo" ]] || return 1 - - if [[ -e $current_link || -L $current_link ]]; then - [[ -L $current_link ]] || return 1 - previous_target=$(readlink -- "$current_link") || return 1 - previous_present=true - fi - - rm -f -- "$next_link" "$restore_link" || return 1 - ln -s -- "releases/$release_version" "$next_link" || return 1 - mv -Tf -- "$next_link" "$current_link" || return 1 - - # The first release containing the skill cannot provision it during Ansible: - # current still names the prior release at that point. Adopt the discovery - # slots immediately after the atomic swap. The helper restores every slot to - # its pre-call state on failure; restore current here before failing the - # release transaction as well. - if ! run_as_update_owner \ - "$repo/scripts/manage-agent-skills" provision --home "$HOME" \ - --canonical "$current_link/agent-skills/cybexos" \ - >>"$run_dir/run.log" 2>&1; then - printf '[release] agent skill provisioning failed; restoring prior activation\n' \ - >>"$run_dir/run.log" - if [[ $previous_present == true ]]; then - ln -s -- "$previous_target" "$restore_link" || return 1 - mv -Tf -- "$restore_link" "$current_link" || return 1 - else - rm -f -- "$current_link" || return 1 - fi - return 1 - fi - - release_activated=true - printf '[release] activated CybexOS %s\n' "$release_version" \ - >>"$run_dir/run.log" - -} - -prune_releases() { - [[ $release_transaction == true ]] || return 0 - local releases_root="$release_data_root/releases" old_release old_path - local -a installed_releases=() - - mapfile -t installed_releases < <( - find "$releases_root" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \ - | "$repo/scripts/semver" sort - ) - if ((${#installed_releases[@]} > 3)); then - for old_release in "${installed_releases[@]:3}"; do - old_path="$releases_root/$old_release" - [[ $old_path == "$releases_root/"* && $old_release != "$release_version" ]] \ - || continue - rm -rf -- "$old_path" \ - || printf '[release] warning: could not prune %s\n' "$old_release" \ - >>"$run_dir/run.log" - done - fi -} - -promote_release_candidate() { - [[ $release_transaction == true ]] || return 0 - [[ $repo != "$release_target" ]] || return 0 - local parent name - parent=$(readlink -f -- "$(dirname -- "$repo")") || return 1 - name=${repo##*/} - [[ $parent == "$release_data_root/releases" \ - && $name == ".stage.$release_version."* ]] || return 1 - [[ ! -e $release_target && ! -L $release_target ]] || rm -rf -- "$release_target" - mv -- "$repo" "$release_target" || return 1 - repo=$release_target -} - -worker_cancelled() { - local state=cancelled phase=cancelled message="Update cancelled" rc=130 - if [[ $packages_ran == true ]]; then - # The finished transaction may still need a reboot; keep that advice. - check_reboot_recommendation - message="Update cancelled after the package transaction finished" - fi - if [[ $firmware_done == true ]]; then - message="Update cancelled after the firmware update finished" - fi - apply_firmware_reboot_recommendation - if ! restore_release_configuration; then - state=failed - phase=rollback - message="Update cancelled, but restoring the previous installer configuration failed" - rc=125 - fi - if ! finish_failed_transaction; then - state=failed; phase=rollback; rc=125 - message="$message; automatic recovery failed" - elif [[ $rollback_state == restart-required ]]; then - message="$message; previous system selected; restart required" - fi - write_status "$state" "$phase" "$message" "$rc" - worker_finalized=true - exit "$rc" -} - -worker_exit_fallback() { - local rc=$? - release_inhibitor - [[ $worker_finalized == true ]] && return - apply_firmware_reboot_recommendation - # This catches interpreter/tool failures and SIGKILL's next status read - # repairs the one case in which no shell trap can run. - local message="Update worker exited unexpectedly (status $rc)" - if ! restore_release_configuration; then - message="$message; restoring the previous installer configuration also failed" - fi - finish_failed_transaction || message="$message; automatic recovery failed" - write_status failed internal "$message" \ - "$([[ $rc -eq 0 ]] && printf 125 || printf '%s' "$rc")" 2>/dev/null || true -} - -check_reboot_recommendation() { - local carried_recommendation=false output_file error_file rc parsed="" - [[ $reboot_recommendation == recommended ]] \ - && carried_recommendation=true - - if [[ -z $boot_id ]]; then - reboot_recommendation="unavailable" - return 0 - fi - - reboot_recommendation="checking" - write_status running reboot-check \ - "Checking whether Fedora recommends a reboot" 0 - output_file="$run_dir/reboot.json" - error_file="$run_dir/reboot.err" - timeout 30s env LC_ALL=C dnf --quiet --cacheonly needs-restarting --json \ - > "$output_file" 2> "$error_file" - rc=$? - if [[ $rc -eq 0 || $rc -eq 1 ]]; then - parsed=$(jq -r ' - if type != "array" then error("expected an array") else - [.[] | select(type == "object" and .type == "reboot" - and (.reboot_required | type) == "boolean") - | .reboot_required] - | if length == 1 then .[0] else error("expected one reboot result") end - end - ' "$output_file" 2>/dev/null) || parsed="" - fi - - if [[ $rc -eq 1 && $parsed == true ]]; then - reboot_recommendation="recommended" - elif [[ $rc -eq 0 && $parsed == false ]]; then - reboot_recommendation="not-needed" - elif [[ $carried_recommendation == true ]]; then - # A missing plugin, timeout, or malformed response cannot erase a - # recommendation already established during this boot. - reboot_recommendation="recommended" - else - reboot_recommendation="unavailable" - fi -} - -# dnf's needs-restarting knows nothing about a staged capsule, so a firmware -# update that waits for the next boot always recommends the restart. -apply_firmware_reboot_recommendation() { - [[ ${firmware_reboot:-false} != true ]] || reboot_recommendation="recommended" -} - -firmware_helper_path() { - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ - && -n ${CYBEXOS_UPDATE_FIRMWARE_HELPER:-} ]]; then - printf '%s\n' "$CYBEXOS_UPDATE_FIRMWARE_HELPER" - else - printf '%s/cybexos-firmware-update\n' "$(dirname -- "$script_path")" - fi -} - -# start_run creates the run's logs as the update owner. A worker launched by -# an older client can find the firmware logs missing: create them and hand -# them to that owner, because root appends to them while the panel reads them -# unprivileged through read-log. -ensure_owner_log() { - local file=$1 - [[ ! -e $file ]] || return 0 - : >"$file" || return 0 - if [[ -n ${CYBEXOS_UPDATE_OWNER_UID:-} ]]; then - chown "${CYBEXOS_UPDATE_OWNER_UID}:${CYBEXOS_UPDATE_OWNER_GID}" \ - "$file" 2>/dev/null || true - fi -} - -# Firmware follows the package phase, so a failed or cancelled package -# transaction never leaves a capsule staged behind it. It never fails the run: -# each device's outcome (on battery, already scheduled, a download error) is -# in firmware-events.log for the panel, and a nonzero helper status is added -# to the final message like Flatpak's. The helper runs to completion even when -# a cancellation arrives, because fwupd owns a flash once it has started. -run_firmware_phase() { - local helper events="$run_dir/firmware-events.log" - helper=$(firmware_helper_path) - ensure_owner_log "$run_dir/firmware.log" - ensure_owner_log "$events" - write_status running firmware "Updating device firmware" 0 - if [[ -x $helper ]]; then - tagged_command firmware "$run_dir/firmware.log" "$run_dir/firmware.rc" \ - "${privileged_package[@]}" "$helper" install --events "$events" - firmware_rc=$(phase_result "$run_dir/firmware.rc") - else - firmware_rc=127 - printf '%s\n' "$firmware_rc" >"$run_dir/firmware.rc" - printf '[firmware] the firmware updater is not installed: %s\n' "$helper" \ - >>"$run_dir/run.log" - { - jq -cn '{event: "failed", id: "", reason: "unavailable", - message: "The firmware updater is not installed"}' - jq -cn '{event: "summary", installed: 0, failed: 1, needsReboot: false}' - } >>"$events" - fi - [[ $firmware_rc =~ ^[0-9]+$ ]] || firmware_rc=1 - firmware_done=true - # A malformed line (say, from a helper killed mid-write) is skipped rather - # than hiding the capsules staged before it. - if jq -Rne 'any(inputs | fromjson? | objects; - .event == "installed" and .needsReboot == true)' \ - "$events" >/dev/null 2>&1; then - firmware_reboot=true - fi - write_status running firmware "Device firmware update finished" 0 -} - -# Publish the recovery point as soon as it exists, so the run's record (and -# the history and Updates view built from it) names it even when the worker -# dies before its next step. -record_recovery_point() { - printf '[snapshot] recovery point %s\n' "$snapshot_id" >>"$run_dir/run.log" - write_status running snapshot "Created recovery point $snapshot_id" 0 -} - -# The background download (cybexos-update-download.service, off by default) -# shares dnf's package cache. Stop it before this run snapshots or changes -# anything; what it already fetched stays cached and is reused. Never fatal. -stop_background_download() { - local state - state=$(systemctl show cybexos-update-download.service --property=ActiveState \ - --value 2>/dev/null) || return 0 - case $state in - active|activating|reloading) ;; - *) return 0 ;; - esac - printf '[download] stopping the background update download\n' >>"$run_dir/run.log" - "${privileged_package[@]}" systemctl stop cybexos-update-download.service \ - >>"$run_dir/run.log" 2>&1 \ - || printf '[download] could not stop the background download; continuing\n' \ - >>"$run_dir/run.log" -} - -# Always address the local system manager, independently of the invoking -# session's bus/host redirects (including SYSTEMCTL_FORCE_BUS). With root and -# --system, systemctl uses /run/systemd/private rather than the public bus. -# daemon-reload ignores the usual bus timeout, so bound the process itself. -bounded_manager_command() { - local deadline=$1 command_name=$2 command_path - shift 2 - command_path="/usr/bin/$command_name" - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 ]]; then - command_path=$(command -v -- "$command_name") || return 127 - fi - "${privileged_package[@]}" env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ - timeout --kill-after=5s "$deadline" "$command_path" "$@" -} - -public_manager_ready() { - local reply rc - reply=$(bounded_manager_command 15s busctl --system --timeout=10s --json=short \ - get-property org.freedesktop.systemd1 /org/freedesktop/systemd1 \ - org.freedesktop.systemd1.Manager Version 2>>"$run_dir/run.log") - rc=$? - ((rc == 0)) || return "$rc" - printf '[manager] %s\n' "$reply" >>"$run_dir/run.log" - if ! jq -se 'length == 1 and (.[0] | type == "object" and .type == "s" - and (.data | type == "string" and test("^[0-9]")))' \ - <<<"$reply" >/dev/null 2>&1; then - printf '[manager] invalid public manager version reply\n' >>"$run_dir/run.log" - return 125 - fi -} - -worker() { - run_id=${1:-}; shift || true - valid_id "$run_id" || exit 2 - parse_options "$@" || exit $? - run_dir="$log_shelf/$run_id"; unit_name="cybexos-update-$run_id.service" - release_config_changed=false - release_activated=false - release_backup="$run_dir/config.before.yml" - started_at=$(jq -r '.startedAt // 0' "$run_dir/status.json" 2>/dev/null || printf 0) - [[ $started_at =~ ^[0-9]+$ ]] || started_at=$(date +%s) - dnf_done=false; flatpak_done=false; dnf_rc=0; flatpak_rc=0; tests_rc=0; ansible_rc=0 - firmware_done=false; firmware_rc=0 - snapshot_id="" - boot_id=$(current_boot_id || true) - reboot_recommendation=$(status_reboot_recommendation \ - "$run_dir/status.json" "$boot_id") - [[ $reboot_recommendation == recommended ]] \ - || reboot_recommendation="pending" - firmware_reboot=$(status_firmware_reboot "$run_dir/status.json" "$boot_id") - cancel_requested=false; packages_ran=false; mixed_state=false - trap request_cancellation INT TERM - trap worker_exit_fallback EXIT - exec 9>"$lock_file" - # start_run publishes the queued state while holding this same lock. Wait for - # that short critical section to end, then retain the lock for the complete - # package/test/Ansible lifecycle. - flock 9 - - # Cancellation can win before systemd-run has registered this transient - # unit. The durable intent closes the interval between an inactive unit - # query and registration; never begin package work once it is published. - if [[ -e $run_dir/cancel.requested ]]; then - worker_cancelled - fi - - # Also honor an already-published terminal state instead of resurrecting the - # run as `running`. - local published_state - published_state=$(status_state "$run_id" || true) - if [[ $published_state != queued ]]; then - worker_finalized=true - [[ $published_state == cancelled ]] && exit 130 - printf 'cybexos-update-run: refusing worker for run %s in state %s\n' \ - "$run_id" "${published_state:-unknown}" >&2 - exit 125 - fi - - honor_cancellation - if [[ $release_transaction == true ]] && ! promote_release_candidate; then - worker_failed staging "Could not promote the verified release candidate" 1 - fi - - acquire_inhibitor - write_status running snapshot "Creating a pre-update recovery point" 0 - printf 'CybexOS update %s started at %s\n' "$run_id" "$(date --iso-8601=seconds)" >> "$run_dir/run.log" - # Keep run state and logs private, but do not pass that 0077 umask through - # sudo. DNF5 rewrites its shared system-state TOML files using the process - # umask; making them root-only breaks every later unprivileged dnf query. - local -a privileged_package - if ((EUID == 0)); then - privileged_package=(sh -c 'umask 022; exec "$@"' cybexos-update-package) - else - privileged_package=(sudo -n sh -c \ - 'umask 022; exec "$@"' cybexos-update-package) - fi - if ! drain_installed_provisioning; then - worker_failed reconcile "Could not stop abandoned provisioning workers" 125 - fi - stop_background_download - local snapshot_helper_root=/usr/local/libexec - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ - && -n ${CYBEXOS_UPDATE_TEST_LIBEXEC:-} ]]; then - snapshot_helper_root=$CYBEXOS_UPDATE_TEST_LIBEXEC - elif [[ ! -x $snapshot_helper_root/cybexos-system-snapshot \ - && ! -x $snapshot_helper_root/xps-system-snapshot ]]; then - # The RPM and checkout share a protocol, but own different libexec paths. - local alternate_helper_root - for alternate_helper_root in /usr/libexec /usr/local/libexec; do - if [[ -x $alternate_helper_root/cybexos-system-snapshot \ - || -x $alternate_helper_root/xps-system-snapshot ]]; then - snapshot_helper_root=$alternate_helper_root - break - fi - done - fi - local snapshot_helper="$snapshot_helper_root/cybexos-system-snapshot" - transaction_helper="$snapshot_helper_root/cybexos-update-transaction" - local legacy_snapshot_helper="$snapshot_helper_root/xps-system-snapshot" - local snapshot_rc=0 - # Existing installations can receive the renamed updater before Ansible has - # renamed the root-owned helper. The legacy helper has the same recovery - # contract and safely bridges that first full convergence. - if [[ ! -x $snapshot_helper && -x $legacy_snapshot_helper ]]; then - snapshot_helper=$legacy_snapshot_helper - fi - if [[ -x $snapshot_helper ]]; then - tagged_command snapshot "$run_dir/snapshot.log" "$run_dir/snapshot.rc" \ - "${privileged_package[@]}" "$snapshot_helper" create "update $run_id" - snapshot_rc=$(phase_result "$run_dir/snapshot.rc") - if ((snapshot_rc != 0)); then - worker_failed snapshot "Could not create the pre-update recovery point" "$snapshot_rc" - fi - snapshot_id=$(tail -n 1 "$run_dir/snapshot.log") || snapshot_id='' - if [[ $snapshot_id == skipped:* ]]; then - local skipped_filesystem - skipped_filesystem=$(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) || skipped_filesystem='' - if [[ -z $skipped_filesystem || $skipped_filesystem == btrfs ]]; then - worker_failed snapshot "The recovery helper skipped a Btrfs or unknown filesystem" 125 - fi - snapshot_id='' - elif [[ ! $snapshot_id =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+$ ]]; then - worker_failed snapshot "The recovery helper did not return a valid recovery point" 125 - else - record_recovery_point - fi - else - local root_filesystem - root_filesystem=$(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) || root_filesystem='' - if [[ -z $root_filesystem || $root_filesystem == btrfs ]]; then - worker_failed snapshot "Btrfs recovery helper is not installed" 127 - fi - printf '[snapshot] non-Btrfs root; no filesystem recovery point created\n' \ - >>"$run_dir/run.log" - fi - local bootstrap_helper='' bootstrap_source='' bootstrap_bundle='' - if [[ -n $snapshot_id ]]; then - # An explicitly selected full checkout/release has already passed the - # worker's source validation. A package-only source invocation may use - # its own adjacent checkout, never an arbitrary CWD or home directory. - local adjacent_source - adjacent_source=$(dirname -- "$(dirname -- "$(dirname -- "$script_path")")") - if [[ $full == true && -x $repo/roles/base/files/cybexos-update-bootstrap ]]; then - bootstrap_source=$repo/roles/base/files - elif [[ -x $snapshot_helper_root/cybexos-update-bootstrap ]]; then - bootstrap_source=$snapshot_helper_root - elif [[ ${CYBEXOS_UPDATE_TESTING:-0} != 1 \ - && $script_path == "$adjacent_source/assets/scripts/cybexos-update-run" \ - && -f $adjacent_source/site.yml && -x $adjacent_source/tests/run \ - && -x $adjacent_source/roles/base/files/cybexos-update-bootstrap ]]; then - bootstrap_source=$adjacent_source/roles/base/files - fi - bootstrap_helper=${bootstrap_source:+$bootstrap_source/cybexos-update-bootstrap} - if [[ -z $bootstrap_helper ]]; then - worker_failed snapshot "Recovery bootstrap is unavailable; update from a complete CybexOS release or checkout" 127 - fi - if ! "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" ready \ - --libexec "$snapshot_helper_root" >>"$run_dir/run.log" 2>&1; then - # The first point preserves the untouched old system. The second point - # includes boot recovery, which must survive exchanging the root before - # home restoration finishes. No packages or vendor home paths change - # until this hook is durable and the second checkpoint succeeds. - write_status running snapshot "Installing recovery for the first transactional update" 0 - tagged_command bootstrap "$run_dir/bootstrap.log" "$run_dir/bootstrap.rc" \ - "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" prepare \ - --source "$bootstrap_source" --checkpoint "$snapshot_id" --id "$run_id" - local bootstrap_rc protected_snapshot - bootstrap_rc=$(phase_result "$run_dir/bootstrap.rc") - ((bootstrap_rc == 0)) || worker_failed snapshot "Could not prepare durable boot recovery; no packages were changed" "$bootstrap_rc" - protected_snapshot=$(tail -n 1 "$run_dir/bootstrap.log" | jq -er '.snapshot | strings') || protected_snapshot='' - transaction_helper=$(tail -n 1 "$run_dir/bootstrap.log" | jq -er '.transactionHelper | strings') || transaction_helper='' - if [[ ! $protected_snapshot =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+$ \ - || $protected_snapshot == "$snapshot_id" || ! -x $transaction_helper ]]; then - worker_failed snapshot "Recovery bootstrap did not return a protected checkpoint" 125 - fi - snapshot_id=$protected_snapshot - record_recovery_point - bootstrap_bundle=$(dirname -- "$transaction_helper") - bootstrap_helper=$bootstrap_bundle/cybexos-update-bootstrap - fi - if ! "${privileged_package[@]}" "$transaction_helper" begin "$run_id" "$snapshot_id" \ - --uid "${CYBEXOS_UPDATE_OWNER_UID:-$UID}" >>"$run_dir/run.log" 2>&1; then - worker_failed snapshot "Could not checkpoint the vendor desktop; no packages were changed" 1 - fi - transaction_started=true - transaction_protection=btrfs - fi - honor_cancellation - if [[ $transaction_started == true && $with_packages == true ]]; then - write_status running download "Downloading packages before changing the installed system" 0 - tagged_command download "$run_dir/download.log" "$run_dir/download.rc" \ - "${privileged_package[@]}" dnf -y upgrade --refresh --no-best --downloadonly - local download_rc - download_rc=$(phase_result "$run_dir/download.rc") - ((download_rc == 0)) || worker_failed download "Package download failed; installed system is unchanged" "$download_rc" - honor_cancellation - fi - if [[ $transaction_started == true ]]; then - "${privileged_package[@]}" "$transaction_helper" applying "$run_id" \ - >>"$run_dir/run.log" 2>&1 \ - || worker_failed snapshot "Could not journal the update transaction" 1 - transaction_applying=true - fi - local dnf_pid="" flatpak_pid="" - if [[ $with_packages == true ]]; then - write_status running packages "Updating system packages and Flatpaks" 0 - # --no-best (dnf's default, stated so a local best=True cannot override - # it): versionlock holds Hyprland and Quickshell to their qualified series - # (docs/dependency-policy.md), so a library upgrade that only a newer, - # held-back compositor could use is skipped instead of failing the run. - tagged_command dnf "$run_dir/dnf.log" "$run_dir/dnf.rc" \ - "${privileged_package[@]}" dnf -y upgrade --refresh --no-best & - dnf_pid=$! - if [[ $with_flatpak == true ]]; then - tagged_command flatpak "$run_dir/flatpak.log" "$run_dir/flatpak.rc" \ - "${privileged_package[@]}" flatpak update --system --noninteractive & - flatpak_pid=$! - else - printf '0\n' > "$run_dir/flatpak.rc"; flatpak_done=true - fi - wait_for_job "$dnf_pid" - dnf_rc=$(phase_result "$run_dir/dnf.rc"); dnf_done=true - packages_ran=true - write_status running packages "System package update finished" 0 - if [[ -n $flatpak_pid ]]; then - wait_for_job "$flatpak_pid" - flatpak_rc=$(phase_result "$run_dir/flatpak.rc"); flatpak_done=true - write_status running packages "Package phase finished" 0 - fi - else - printf '0\n' > "$run_dir/dnf.rc" - printf '0\n' > "$run_dir/flatpak.rc" - dnf_done=true - flatpak_done=true - fi - if (( dnf_rc != 0 )); then - worker_failed packages "dnf exited with status $dnf_rc" "$dnf_rc" - fi - if [[ $transaction_started == true ]] && ((flatpak_rc != 0)); then - worker_failed packages "Flatpak exited with status $flatpak_rc" "$flatpak_rc" - fi - honor_cancellation - - if [[ $with_packages == true ]]; then - # RPM triggers can leave PID 1 disconnected from its API bus. One private - # reload synchronizes the successful package boundary before work using - # the public bus (including systemd-run --wait). It does not replay any - # failed service actions recorded by provisioning. - printf '[manager] synchronizing after the package phase\n' >>"$run_dir/run.log" - bounded_manager_command 30s systemctl --system --no-ask-password daemon-reload \ - >>"$run_dir/run.log" 2>&1 - local manager_rc=$? - ((manager_rc == 0)) \ - || worker_failed packages "Could not synchronize the updated system" "$manager_rc" - public_manager_ready - manager_rc=$? - ((manager_rc == 0)) \ - || worker_failed packages "The system manager is unavailable after the package update" "$manager_rc" - honor_cancellation - fi - - if [[ $full == true ]]; then - cd -- "$repo" || { - worker_failed tests "Could not enter repository directory" 1 - } - if [[ $skip_tests == true ]]; then - printf '[tests] repository checks skipped (--skip-tests)\n' >> "$run_dir/run.log" - else - write_status running tests "Running repository checks" 0 - # A wedged check must not hold the update lock indefinitely. - tagged_command tests "$run_dir/tests.log" "$run_dir/tests.rc" \ - timeout --kill-after=1m 30m ./tests/run - tests_rc=$(phase_result "$run_dir/tests.rc") - if (( tests_rc == 124 )); then - worker_failed tests "Repository checks did not finish within 30 minutes" "$tests_rc" - elif (( tests_rc != 0 )); then - worker_failed tests "Repository checks exited with status $tests_rc" "$tests_rc" - fi - honor_cancellation - fi - if [[ $release_transaction == true ]]; then - write_status running migration "Migrating the saved installer configuration" 0 - if ! prepare_release_configuration; then - worker_failed migration "Could not migrate the saved installer configuration" 1 - fi - fi - # Last boundary: once Ansible starts, the transaction runs to completion - # (or failure) so installed files and the active release stay coherent. - honor_cancellation - # From here until activation, installed files can be newer than the - # active release; a failure or abandonment leaves that recorded. - [[ $release_transaction != true ]] || mixed_state=true - write_status running ansible "Applying the managed configuration" 0 - # Keep the complete stream even when the configured callback is compact. - # Ansible's DNF modules inherit the process mask just like a direct dnf - # invocation. Keep shared package state readable without relaxing the - # updater's private log/state mask. - tagged_command ansible "$run_dir/ansible.log" "$run_dir/ansible.rc" \ - sh -c 'umask 022; exec "$@"' cybexos-update-ansible \ - ansible-playbook site.yml --skip-tags boot "${ansible_args[@]}" - ansible_rc=$(phase_result "$run_dir/ansible.rc") - if (( ansible_rc != 0 )); then - worker_failed ansible "Ansible exited with status $ansible_rc" "$ansible_rc" - fi - if [[ $release_transaction == true ]]; then - write_status running activation "Activating the applied CybexOS release" 0 - if ! activate_release; then - worker_failed activation "Could not activate the applied CybexOS release" 1 - fi - mixed_state=false - fi - fi - - # RPM posttrans only queues reconciliation. Finish it within the protected - # update before declaring the new installed policy healthy. - local reconcile_helper=/usr/libexec/cybexos-reconcile - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ - && -n ${CYBEXOS_UPDATE_TEST_LIBEXEC:-} ]]; then - reconcile_helper="$CYBEXOS_UPDATE_TEST_LIBEXEC/cybexos-reconcile" - fi - if [[ -x $reconcile_helper ]]; then - write_status running reconcile "Applying the updated installed-system policy" 0 - tagged_command reconcile "$run_dir/reconcile.log" "$run_dir/reconcile.rc" \ - "${privileged_package[@]}" "$reconcile_helper" --retry - local reconcile_rc reconcile_status - reconcile_rc=$(phase_result "$run_dir/reconcile.rc") - ((reconcile_rc == 0)) || worker_failed reconcile "Installed-system reconciliation failed" "$reconcile_rc" - # The helper deliberately exits successfully when another reconciler has - # its lock or RPM is busy. Success alone does not mean the new policy was - # applied; require its durable status for the current packaged version. - if ! reconcile_status=$("${privileged_package[@]}" "$reconcile_helper" --status \ - 2>>"$run_dir/reconcile.log") \ - || ! jq -e --argjson ownerUid "${CYBEXOS_UPDATE_OWNER_UID:-$UID}" ' - .desiredVersion as $desired | .accounts as $accounts | - .state == "ready" and .pending == false - and ($desired | type == "string" and length > 0) - and .version == $desired - and (.accounts | type == "object" and length > 0 - and all(.[]; .state == "ready" and .version == $desired)) - and ($ownerUid < 1000 or any($accounts[]; .uid == $ownerUid))' \ - <<<"$reconcile_status" >/dev/null 2>&1; then - worker_failed reconcile "Installed-system reconciliation has not reached the current ready state" 125 - fi - fi - - check_reboot_recommendation - apply_firmware_reboot_recommendation - - if [[ $transaction_started == true ]]; then - write_status running health "Validating the updated system and desktop" 0 - public_manager_ready - local health_manager_rc=$? - ((health_manager_rc == 0)) \ - || worker_failed health "The system manager is unavailable" "$health_manager_rc" - if ! "${privileged_package[@]}" "$transaction_helper" commit "$run_id" \ - >>"$run_dir/run.log" 2>&1; then - worker_failed health "The installed health check failed" 1 - fi - transaction_started=false - if [[ -n $bootstrap_bundle ]]; then - # A source role or RPM now owns the canonical recovery service. Retire - # the temporary implementation only after the journal is committed. - "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" finalize \ - --bundle "$bootstrap_bundle" >>"$run_dir/run.log" 2>&1 \ - || printf '[snapshot] retained recovery bootstrap for a later update\n' >>"$run_dir/run.log" - fi - fi - prune_releases - # Device firmware is not part of a filesystem snapshot. Apply it only after - # reversible system/configuration work has passed its health gate. - if [[ $with_firmware == true ]]; then - run_firmware_phase - honor_cancellation - apply_firmware_reboot_recommendation - fi - - local message="Update completed" - (( flatpak_rc == 0 )) || message="Update completed; Flatpak reported status $flatpak_rc" - (( firmware_rc == 0 )) || message="$message; firmware reported status $firmware_rc" - [[ $cancel_requested != true ]] \ - || message="$message; cancellation arrived after its last stopping point" - printf 'CybexOS update %s finished at %s\n' "$run_id" "$(date --iso-8601=seconds)" >> "$run_dir/run.log" - write_status done complete "$message" 0 - worker_finalized=true -} - -attach_run() { - local id file state exit_code tail_pid attach_interrupted=false - id=$(resolve_id "${1:-}") || return - file="$log_shelf/$id/run.log"; [[ -e $file ]] || : > "$file" - tail -n +1 -F -- "$file" & tail_pid=$! - detach_attach() { - attach_interrupted=true - kill "$tail_pid" 2>/dev/null || true - } - trap 'kill "$tail_pid" 2>/dev/null || true' EXIT - trap detach_attach INT TERM - while :; do - if [[ $attach_interrupted == true ]]; then - wait "$tail_pid" 2>/dev/null || true - trap - EXIT INT TERM - printf '\nDetached from update %s; the worker is still running.\n' "$id" - return 130 - fi - repair_stale_status "$id" - state=$(status_state "$id" || printf unknown) - is_active "$state" || break - sleep 1 - done - sleep 1 - kill "$tail_pid" 2>/dev/null || true; wait "$tail_pid" 2>/dev/null || true - trap - EXIT INT TERM - exit_code=$(jq -r '.exitCode // 1' "$(status_path "$id")" 2>/dev/null || printf 1) - printf '\nUpdate %s: %s (logs: %s)\n' "$id" "$state" "$log_shelf/$id" - return "$exit_code" -} - -load_status_globals() { - local id=$1 file - file=$(status_path "$id") - run_id=$id; run_dir="$log_shelf/$id"; unit_name=$(jq -r '.unit // empty' "$file") - started_at=$(jq -r '.startedAt // 0' "$file"); full=$(jq -r '.full // false' "$file") - with_flatpak=$(jq -r '.flatpak // true' "$file"); dnf_done=$(jq -r '.dnfDone // false' "$file") - flatpak_done=$(jq -r '.flatpakDone // false' "$file"); dnf_rc=$(jq -r '.dnfRc // 0' "$file") - flatpak_rc=$(jq -r '.flatpakRc // 0' "$file"); tests_rc=$(jq -r '.testsRc // 0' "$file") - ansible_rc=$(jq -r '.ansibleRc // 0' "$file") - # Records written before the firmware phase existed have none of these. - with_firmware=$(jq -r '.firmware // false' "$file") - [[ $with_firmware == true ]] || with_firmware=false - firmware_done=$(jq -r '.firmwareDone // false' "$file") - [[ $firmware_done == true ]] || firmware_done=false - firmware_rc=$(jq -r '.firmwareRc // 0' "$file") - [[ $firmware_rc =~ ^[0-9]+$ ]] || firmware_rc=0 - firmware_reboot=$(jq -r '.firmwareReboot // false' "$file") - [[ $firmware_reboot == true ]] || firmware_reboot=false - system_unit=$(jq -r '.systemUnit // false' "$file") - release_version=$(jq -r '.releaseVersion // empty' "$file") - [[ -z $release_version ]] && release_transaction=false || release_transaction=true - snapshot_id=$(jq -r '.snapshotId // empty' "$file") - mixed_state=$(jq -r '.mixedState // false' "$file") - [[ $mixed_state == true ]] || mixed_state=false - boot_id=$(current_boot_id || true) - reboot_recommendation=$(status_reboot_recommendation "$file" "$boot_id") -} - -cancel_run() { - local id state unit active_state next_active_state intent system_unit - local phase deferred - id=$(resolve_id "${1:-}") || return; state=$(status_state "$id" || true) - is_active "$state" || { printf 'cybexos-update-run: %s is not active (%s)\n' "$id" "${state:-unknown}" >&2; return 1; } - unit=$(jq -r '.unit // empty' "$(status_path "$id")") - system_unit=$(jq -r '.systemUnit // false' "$(status_path "$id")") - phase=$(jq -r '.phase // empty' "$(status_path "$id")") - deferred=$(jq -r '.deferredCancel // false' "$(status_path "$id")") - [[ -n $unit ]] || unit="cybexos-update-$id.service" - - # A worker launched before deferred cancellation existed is stopped with - # SIGTERM to its whole control group, which would interrupt dnf/rpm. - if [[ $phase == packages && $deferred != true ]]; then - printf 'cybexos-update-run: %s is applying a package transaction that cannot be interrupted safely; wait for it to finish\n' \ - "$id" >&2 - return 1 - fi - - # Publish intent before inspecting the unit. If start_run has not registered - # it yet, the eventual worker observes this marker under the transaction - # lock and exits before package work. If it is already registered, the - # active-unit path below still stops and verifies it synchronously. - intent="$log_shelf/$id/cancel.requested" - atomic_text "$intent" "$(date +%s)" - - if ! active_state=$(unit_active_state "$unit" "$system_unit"); then - printf 'cybexos-update-run: cancellation intent recorded, but %s could not be inspected; outcome is not yet verified\n' \ - "$unit" >&2 - return 1 - fi - case $active_state in - active|activating|reloading|deactivating) - # Queue the stop without waiting for it: with KillMode=mixed the worker - # alone receives SIGTERM and may first finish a package transaction or - # Ansible play, and a blocking stop would hold this client that long. - if [[ $system_unit == true ]]; then - system_manager_command systemctl stop --no-block "$unit" >/dev/null 2>&1 || { - printf 'cybexos-update-run: could not stop %s; update remains active\n' "$unit" >&2 - return 1 - } - elif ! systemctl --user stop --no-block "$unit" >/dev/null 2>&1; then - printf 'cybexos-update-run: could not stop %s; update remains active\n' "$unit" >&2 - return 1 - fi - # Verify the ownership boundary before publishing a terminal record. A - # worker proven stopped can no longer overwrite the synthetic status if - # its TERM trap did not get to run. One still finishing its step records - # the cancellation itself. - for _ in {1..20}; do - if ! next_active_state=$(unit_active_state "$unit" "$system_unit"); then - printf 'cybexos-update-run: could not verify %s stopped\n' "$unit" >&2 - return 1 - fi - case $next_active_state in - inactive|failed) break ;; - esac - sleep 0.05 - done - case $next_active_state in - inactive|failed) ;; - active|activating|reloading|deactivating) - atomic_text "$log_shelf/$id/cancel.pending" "$(date +%s)" - printf 'Cancellation requested for update %s. A running package transaction or configuration step finishes first; `cybexos-update-run attach %s` follows it.\n' \ - "$id" "$id" - return 0 - ;; - *) - printf 'cybexos-update-run: %s is still %s; update remains active\n' \ - "$unit" "${next_active_state:-unknown}" >&2 - return 1 - ;; - esac - ;; - inactive|failed) - if [[ $state != queued ]]; then - repair_stale_status "$id" - state=$(status_state "$id" || true) - printf 'cybexos-update-run: %s has no active worker (%s)\n' \ - "$id" "${state:-unknown}" >&2 - return 1 - fi - # start_run publishes queued before systemd-run registers the unit. Mark - # that narrow window terminal; a subsequently registered worker checks - # the status under the transaction lock and refuses package work. - ;; - *) - printf 'cybexos-update-run: unexpected unit state for %s: %s\n' \ - "$unit" "${active_state:-unknown}" >&2 - return 1 - ;; - esac - - state=$(status_state "$id" || true) - if is_active "$state"; then - load_status_globals "$id" - write_status cancelled cancelled "Update cancelled" 130 - elif [[ $state != cancelled ]]; then - printf 'cybexos-update-run: %s finished as %s before cancellation\n' \ - "$id" "${state:-unknown}" >&2 - return 1 - fi - printf 'Cancelled update %s.\n' "$id" -} - -dismiss_run() { - local id state file temporary - id=$(resolve_id "${1:-}") || return; file=$(status_path "$id") - state=$(status_state "$id" || true) - is_active "$state" && { printf 'cybexos-update-run: an active run cannot be dismissed\n' >&2; return 1; } - # Dismissing hides the result from the panel; the history keeps it. - temporary="$file.$$.tmp" - jq 'if .state == "dismissed" then . else .outcome = .state | .state = "dismissed" end' \ - "$file" > "$temporary" - mv -f -- "$temporary" "$file" -} - -# Finished runs, newest first: what happened and the recovery point each one -# created before it changed anything. Built from the retained run records, so -# it reaches as far back as their twenty-run retention. -history_runs() { - local json=false limit=20 id file current record - local -a records=() - while (($#)); do - case $1 in - --json) json=true ;; - --limit) - shift - [[ ${1:-} =~ ^[0-9]{1,3}$ ]] || { - printf 'cybexos-update-run: --limit needs a number\n' >&2 - return 2 - } - limit=$((10#$1)) - ;; - *) printf 'cybexos-update-run: unknown history option %s\n' "$1" >&2; return 2 ;; - esac - shift - done - # A worker that vanished is recorded as failed before it is listed. - current=$(current_id || true) - [[ -z $current ]] || repair_stale_status "$current" - while IFS= read -r id; do - valid_id "$id" || continue - file=$(status_path "$id") - [[ -r $file ]] || continue - # A dismissed record keeps its outcome; one dismissed by an older - # updater only has its exit code to say what it was. - record=$(jq -c ' - select(type == "object" and (.id | type) == "string") - | (.outcome // (if .state == "dismissed" then - (if .exitCode == 0 then "done" elif .exitCode == 130 then "cancelled" - else "failed" end) else .state end)) as $outcome - | select($outcome == "done" or $outcome == "failed" or $outcome == "cancelled") - | {id, outcome: $outcome, startedAt: (.startedAt // 0), - finishedAt: (.finishedAt // 0), phase: (.phase // ""), - snapshotId: (.snapshotId // ""), releaseVersion: (.releaseVersion // ""), - transactionProtection: (.transactionProtection // "unavailable"), - rollbackState: (.rollbackState // "none"), message: (.message // "")}' \ - "$file" 2>/dev/null) || continue - [[ -n $record ]] && records+=("$record") - done < <(find "$log_shelf" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null) - local listing - listing=$(printf '%s\n' "${records[@]}" | jq -cs --argjson limit "$limit" \ - 'map(select(. != null)) | sort_by(.startedAt, .id) | reverse | .[:$limit]') - if [[ $json == true ]]; then - printf '%s\n' "$listing" - elif [[ $listing == '[]' ]]; then - printf 'No update has finished yet.\n' - else - jq -r '.[] | "\((if .finishedAt > 0 then .finishedAt else .startedAt end) - | strflocaltime("%Y-%m-%d %H:%M")) \(.outcome)\(if .snapshotId != "" - then " recovery point " + .snapshotId else "" end) \(.id)"' <<<"$listing" - fi -} - -read_log() { - local id=${1:-} name=${2:-} offset=${3:-0} count=${4:-} file - valid_id "$id" || return 2 - case $name in - dnf|flatpak|run|tests|ansible|firmware|firmware-events) ;; - *) return 2 ;; - esac - [[ $offset =~ ^[0-9]+$ ]] || return 2 - [[ -z $count || $count =~ ^[0-9]+$ ]] || return 2 - file="$log_shelf/$id/$name.log"; [[ -r $file ]] || return 0 - if [[ -n $count ]]; then - dd if="$file" iflag=skip_bytes,count_bytes skip="$offset" count="$count" \ - status=none - else - tail -c "+$((offset + 1))" -- "$file" - fi -} - -run_client() { - local output rc id existing - local argument - for argument in "$@"; do - if [[ $argument == -h || $argument == --help ]]; then - usage - return 0 - fi - done - # start_run runs inside a command substitution and cannot prompt. Unless - # sudo already works for it, authenticate here in the terminal, so that it - # can launch the system unit instead of failing with "authentication is - # required" in the very terminal it asks for. - if [[ ${CYBEXOS_UPDATE_ASSUME_USER_UNIT:-0} != 1 && -t 0 ]] \ - && ! detached_sudo_available && ! sudo -n true >/dev/null 2>&1; then - printf 'Authenticate once to start the durable system update worker.\n' - sudo -v || return - fi - if output=$(start_run --json "$@"); then - rc=0 - else - rc=$? - fi - [[ -n $output ]] || return "$rc" - id=$(jq -er '.id' <<< "$output") || return 1 - existing=$(jq -r '.existing // false' <<< "$output") - [[ $existing == true ]] \ - && printf 'An update is already active; attaching to %s.\n' "$id" \ - || printf 'Started durable update %s. Ctrl+C detaches; `cybexos-update-run cancel` cancels.\n' "$id" - attach_run "$id" -} - -command=${1:-run}; shift || true -case $command in - run) run_client "$@" ;; - start) start_run "$@" ;; - status) - json=false; [[ ${1:-} == --json ]] && { json=true; shift; } - [[ $json == true ]] && json_status "${1:-}" || human_status "${1:-}" - ;; - attach) attach_run "${1:-}" ;; - cancel) cancel_run "${1:-}" ;; - dismiss) dismiss_run "${1:-}" ;; - log-dir) id=$(resolve_id "${1:-}") || exit; printf '%s/%s\n' "$log_shelf" "$id" ;; - history) history_runs "$@" ;; - read-log) read_log "${1:-}" "${2:-}" "${3:-0}" "${4:-}" ;; - _worker) worker "$@" ;; - -h|--help|help) usage ;; - *) printf 'cybexos-update-run: unknown command %s\n\n' "$command" >&2; usage >&2; exit 2 ;; -esac diff --git a/assets/scripts/cybexos-webapp b/assets/scripts/cybexos-webapp deleted file mode 100755 index cdaf8ebf..00000000 --- a/assets/scripts/cybexos-webapp +++ /dev/null @@ -1,747 +0,0 @@ -#!/usr/bin/python3 -I -"""Install, remove, list, and launch CybexOS web apps. - - cybexos-webapp install --url URL [--name NAME] [--id ID] [--offline] [--notify] - cybexos-webapp remove ID [--notify] - cybexos-webapp list [--json] - cybexos-webapp launch ID - cybexos-webapp refresh [ID...] - cybexos-webapp seed - -A web app is a website in its own browser window with an entry in the -application launcher. Each one is a desktop entry, -~/.local/share/applications/cybexos-webapp-ID.desktop, whose command is -`cybexos-webapp launch ID`, plus its name, address, and icon under -~/.local/share/cybexos/webapps/ID/. Launching picks the browser each time: -Brave Origin in app mode when it is installed (the proprietary applications -choice), otherwise Firefox. - -Installing fetches the site's own icon: an https page's apple-touch-icon or -icon link, else /favicon.ico, within strict size and time limits, converted -to PNG by ImageMagick. Offline, or for an http address, the app gets a -generated monogram, and the real icon is fetched on a later launch. - -`seed` installs the CybexOS defaults (Slack, WhatsApp, YouTube, Google Photos -and X) once per account, offline. Every default it has seeded is recorded, so -one the user removes is never added back. Both installation paths run it: -the checkout's dotfiles role and the ISO's per-account initialization. - -The site address is data. It never reaches a shell, a desktop-entry Exec -line, or a command-line option position. -""" - -from __future__ import annotations - -import argparse -import contextlib -import fcntl -import hashlib -import html.parser -import ipaddress -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import sys -import tempfile -import time -from urllib.parse import urljoin, urlsplit - -ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]{0,39}") -MAX_URL = 2048 -MAX_NAME = 80 -PAGE_LIMIT = 512 * 1024 -ICON_LIMIT = 1024 * 1024 -REQUEST_TIMEOUT = 6 -FETCH_BUDGET = 20 -MAGICK_TIMEOUT = 20 -ICON_SIZE = 128 -# A failed icon fetch is retried on launch at most once a day. -ICON_RETRY_SECONDS = 24 * 3600 -USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) CybexOS-webapp/1" - -# The defaults CybexOS has always offered on Super+M, Super+W, Super+Y, -# Super+Shift+P and Super+Shift+X. Their bindings launch them by these ids. -# cybexos-user-init stops calling `seed` once defaults.json exists, so a -# release that adds a default must also give it a way to be seeded. -DEFAULTS = ( - ("slack", "Slack", "https://app.slack.com/"), - ("whatsapp", "WhatsApp", "https://web.whatsapp.com/"), - ("youtube", "YouTube", "https://youtube.com/"), - ("google-photos", "Google Photos", "https://photos.google.com/"), - ("x", "X", "https://x.com/"), -) - -# Brave Origin exactly as bindings.lua starts it for Super+B, plus app mode. -BRAVE = "brave-origin-stable" -BRAVE_FLAGS = ( - "--enable-features=TouchpadOverscrollHistoryNavigation,PipeWireCamera", - "--enable-wayland-ime", - "--restore-last-session", - "--hide-crash-restore-bubble", -) -FIREFOX = "firefox" - -MONOGRAM_COLORS = ("#3b6fd8", "#6a4fc9", "#b2457b", "#c4572f", "#2f8f6b", - "#2f7f9f", "#7d6a2f", "#55607a") - -# Raster formats only, recognized by their bytes. SVG is refused: an icon is -# rendered, and a vector file can reference other resources. -FORMATS = ( - (b"\x89PNG\r\n\x1a\n", "png"), - (b"\x00\x00\x01\x00", "ico"), - (b"\xff\xd8\xff", "jpeg"), - (b"GIF87a", "gif"), - (b"GIF89a", "gif"), - (b"BM", "bmp"), -) - - -class WebAppError(Exception): - """A failure with a message meant for the user.""" - - -# ---- Locations -------------------------------------------------------------- - -def home() -> Path: - return Path(os.environ.get("HOME") or Path.home()) - - -def data_home() -> Path: - return Path(os.environ.get("XDG_DATA_HOME") or home() / ".local/share") - - -def apps_root() -> Path: - return data_home() / "cybexos/webapps" - - -def applications_dir() -> Path: - return data_home() / "applications" - - -def desktop_path(identifier: str) -> Path: - return applications_dir() / f"cybexos-webapp-{identifier}.desktop" - - -def helper_path() -> Path: - # Both installation paths provide the helper here: a copy on a checkout, - # a link to /usr/share/cybexos/bin on the ISO. - return home() / ".local/bin/cybexos-webapp" - - -def directory(path: Path) -> Path: - """Create a directory without following or replacing a user's symlink.""" - if path.is_symlink(): - raise WebAppError(f"{path} is a symbolic link; leaving it alone") - path.mkdir(parents=True, exist_ok=True) - if not path.is_dir(): - raise WebAppError(f"{path} is not a directory") - return path - - -def write_atomic(path: Path, data: bytes, mode: int = 0o644) -> None: - descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - try: - with os.fdopen(descriptor, "wb") as output: - output.write(data) - output.flush() - os.fchmod(output.fileno(), mode) - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - - -@contextlib.contextmanager -def locked(): - root = directory(apps_root()) - descriptor = os.open(root / ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, "w") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - yield root - - -# ---- Validation ------------------------------------------------------------- - -def validate_url(value: str) -> str: - """Return a canonical http(s) URL, or raise WebAppError. - - http and https only, a host, no user name or password, no whitespace or - control characters, and a valid port. - """ - if not isinstance(value, str): - raise WebAppError("The web address must be text") - text = value.strip() - if not text or len(text) > MAX_URL: - raise WebAppError("Enter a web address of at most 2048 characters") - if any(character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F - for character in text): - raise WebAppError("A web address cannot contain spaces or control characters") - parts = urlsplit(text) - if parts.scheme.lower() not in ("http", "https"): - raise WebAppError("Only http and https web addresses are supported") - if "@" in parts.netloc or parts.username is not None or parts.password is not None: - raise WebAppError("Web addresses with a user name or password are not allowed") - host = parts.hostname or "" - if not host: - raise WebAppError("The web address has no host name") - try: - port = parts.port - except ValueError as error: - raise WebAppError("The web address has an invalid port") from error - if port == 0: - raise WebAppError("The web address has an invalid port") - with contextlib.suppress(ValueError): - ipaddress.ip_address(host) - return parts._replace(scheme=parts.scheme.lower(), path=parts.path or "/").geturl() - try: - ascii_host = host.encode("idna").decode("ascii") - except UnicodeError as error: - raise WebAppError("The web address has an invalid host name") from error - labels = ascii_host.rstrip(".").split(".") - if not all(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in labels): - raise WebAppError("The web address has an invalid host name") - return parts._replace(scheme=parts.scheme.lower(), path=parts.path or "/").geturl() - - -def validate_id(value: str) -> str: - if not isinstance(value, str) or not ID_PATTERN.fullmatch(value): - raise WebAppError("A web app id is 1-40 lowercase letters, digits, or dashes") - return value - - -def clean_name(value: str) -> str: - name = " ".join(str(value or "").split()) - name = "".join(character for character in name if character.isprintable()) - return name[:MAX_NAME].strip() - - -def slug(value: str) -> str: - text = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") - return text[:40].strip("-") - - -def name_from_host(url: str) -> str: - host = (urlsplit(url).hostname or "").lower() - with contextlib.suppress(ValueError): - ipaddress.ip_address(host) - return host - labels = [label for label in host.split(".") if label] - while len(labels) > 2 and labels[0] in ("www", "app", "web", "m", "mobile"): - labels.pop(0) - core = labels[-2] if len(labels) >= 2 else (labels[0] if labels else "Web app") - return core[:1].upper() + core[1:] - - -# ---- Desktop entry ------------------------------------------------------------ - -def desktop_string(value: str) -> str: - """Escape a value for a desktop-entry string key.""" - return (value.replace("\\", "\\\\").replace("\n", "\\n") - .replace("\t", "\\t").replace("\r", "\\r")) - - -def exec_argument(value: str) -> str: - """Quote one Exec argument per the Desktop Entry specification.""" - if re.fullmatch(r"[A-Za-z0-9_@+=:,./-]+", value): - return value - quoted = re.sub(r'(["`$\\])', r"\\\1", value).replace("%", "%%") - return f'"{quoted}"' - - -def desktop_entry(record: dict, icon: Path) -> str: - host = urlsplit(record["url"]).hostname or "" - command = " ".join(exec_argument(part) for part in - (str(helper_path()), "launch", record["id"])) - lines = [ - "[Desktop Entry]", - "Type=Application", - "Version=1.0", - f"Name={desktop_string(record['name'])}", - f"Comment={desktop_string('Web app · ' + host)}", - f"Exec={desktop_string(command)}", - f"Icon={desktop_string(str(icon))}", - "Terminal=false", - "Categories=Network;WebBrowser;", - "StartupNotify=true", - f"X-CybexOS-WebApp={record['id']}", - ] - return "\n".join(lines) + "\n" - - -# ---- Icons -------------------------------------------------------------------- - -def monogram(name: str, identifier: str) -> bytes: - letter = next((character for character in name if character.isalnum()), "?").upper() - letter = letter.replace("&", "&").replace("<", "<").replace(">", ">") - color = MONOGRAM_COLORS[int(hashlib.sha256(identifier.encode()).hexdigest(), 16) - % len(MONOGRAM_COLORS)] - return ( - '\n' - f' \n' - ' {letter}\n' - '\n' - ).encode() - - -def image_format(data: bytes) -> str: - if data[:4] == b"RIFF" and data[8:12] == b"WEBP": - return "webp" - for magic, name in FORMATS: - if data.startswith(magic): - return name - return "" - - -def largest_ico_frame(data: bytes) -> int: - """The index of the largest image in an ICO directory.""" - if len(data) < 6: - return 0 - count = int.from_bytes(data[4:6], "little") - best, best_size = 0, -1 - for index in range(min(count, 64)): - entry = data[6 + index * 16: 22 + index * 16] - if len(entry) < 16: - break - size = (entry[0] or 256) * (entry[1] or 256) - if size > best_size: - best, best_size = index, size - return best - - -def to_png(data: bytes) -> bytes: - """Convert a fetched raster icon to a square PNG with ImageMagick.""" - kind = image_format(data) - if not kind: - raise WebAppError("The site icon is not a supported image") - if shutil.which("magick") is None: - if kind == "png": - return data - raise WebAppError("ImageMagick is not installed") - frame = largest_ico_frame(data) if kind == "ico" else 0 - with tempfile.TemporaryDirectory(prefix="cybexos-webapp-") as scratch: - output = Path(scratch) / "icon.png" - result = subprocess.run( - ["magick", "-limit", "memory", "64MiB", "-limit", "map", "128MiB", - "-limit", "width", "4096", "-limit", "height", "4096", - "-limit", "time", "15", f"{kind}:-[{frame}]", - "-background", "none", "-thumbnail", f"{ICON_SIZE}x{ICON_SIZE}", - "-gravity", "center", "-extent", f"{ICON_SIZE}x{ICON_SIZE}", - "-strip", f"png:{output}"], - input=data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - timeout=MAGICK_TIMEOUT, check=False) - if result.returncode != 0 or not output.is_file(): - raise WebAppError("The site icon could not be converted") - return output.read_bytes() - - -class IconLinks(html.parser.HTMLParser): - """Collect and the page's own name for itself.""" - - def __init__(self): - super().__init__(convert_charrefs=True) - self.icons: list[tuple[int, str]] = [] - self.name = "" - - def handle_starttag(self, tag, attrs): - values = {key.lower(): value or "" for key, value in attrs} - if tag == "link": - rel = values.get("rel", "").lower().split() - href = values.get("href", "").strip() - if not href or href.startswith("data:") or "mask-icon" in rel: - return - if "svg" in values.get("type", "").lower() or href.lower().split("?")[0].endswith(".svg"): - return - if "apple-touch-icon" in rel or "apple-touch-icon-precomposed" in rel: - self.icons.append((10_000, href)) - elif "icon" in rel: - size = 0 - for token in values.get("sizes", "").lower().split(): - match = re.fullmatch(r"(\d+)x(\d+)", token) - if match: - size = max(size, int(match.group(1))) - self.icons.append((min(size, 1024) or 16, href)) - elif tag == "meta" and not self.name: - key = (values.get("name") or values.get("property") or "").lower() - if key in ("application-name", "og:site_name", "apple-mobile-web-app-title"): - self.name = clean_name(values.get("content", "")) - - -class Fetcher: - """https-only GET with a byte limit and an overall time budget.""" - - def __init__(self, budget: float = FETCH_BUDGET): - self.deadline = time.monotonic() + budget - - def get(self, url: str, limit: int) -> tuple[bytes, str]: - import urllib.error - import urllib.request - - remaining = self.deadline - time.monotonic() - if remaining <= 0: - raise WebAppError("Fetching the site took too long") - if urlsplit(url).scheme != "https": - raise WebAppError("Only https addresses are fetched") - - class HttpsOnly(urllib.request.HTTPRedirectHandler): - max_redirections = 5 - - def redirect_request(self, request, fp, code, message, headers, new_url): - if urlsplit(new_url).scheme != "https" or "@" in urlsplit(new_url).netloc: - raise urllib.error.HTTPError(new_url, code, "insecure redirect", - headers, fp) - return super().redirect_request(request, fp, code, message, headers, new_url) - - opener = urllib.request.build_opener(HttpsOnly()) - request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) - try: - with opener.open(request, timeout=min(REQUEST_TIMEOUT, remaining)) as response: - data = response.read(limit + 1) - final = response.geturl() - except (OSError, ValueError, urllib.error.URLError) as error: - raise WebAppError(f"Could not fetch {urlsplit(url).hostname}: {error}") from error - if len(data) > limit: - raise WebAppError("The site sent more data than allowed") - return data, final - - -def discover(url: str, fetcher: Fetcher | None = None) -> tuple[bytes | None, str]: - """Fetch the site's best raster icon as PNG, and the name it gives itself. - - Returns (None, name) when no usable icon was found. - """ - if urlsplit(url).scheme != "https": - return None, "" - fetcher = fetcher or Fetcher() - candidates: list[str] = [] - name = "" - page_url = url - try: - page, page_url = fetcher.get(url, PAGE_LIMIT) - parser = IconLinks() - with contextlib.suppress(Exception): - parser.feed(page.decode("utf-8", "replace")) - name = parser.name - for _size, href in sorted(parser.icons, key=lambda item: -item[0]): - candidates.append(urljoin(page_url, href)) - except WebAppError: - pass - parts = urlsplit(page_url) - candidates.append(f"https://{parts.netloc}/favicon.ico") - seen = set() - for candidate in candidates: - if candidate in seen or urlsplit(candidate).scheme != "https": - continue - seen.add(candidate) - try: - data, _final = fetcher.get(candidate, ICON_LIMIT) - return to_png(data), name - except WebAppError: - continue - return None, name - - -# ---- Records ------------------------------------------------------------------ - -def record_path(identifier: str) -> Path: - return apps_root() / identifier / "app.json" - - -def read_record(identifier: str) -> dict | None: - try: - value = json.loads(record_path(identifier).read_text(encoding="utf-8")) - except (OSError, ValueError): - return None - if not isinstance(value, dict) or value.get("id") != identifier: - return None - try: - value["url"] = validate_url(value.get("url", "")) - except WebAppError: - return None - value["name"] = clean_name(value.get("name", "")) or identifier - if value.get("icon") not in ("icon.png", "icon.svg"): - value["icon"] = "icon.svg" - return value - - -def installed() -> list[dict]: - root = apps_root() - if not root.is_dir(): - return [] - records = [] - for entry in sorted(root.iterdir()): - if entry.is_dir() and not entry.is_symlink() and ID_PATTERN.fullmatch(entry.name): - record = read_record(entry.name) - if record is not None and desktop_path(entry.name).is_file(): - records.append(record) - return sorted(records, key=lambda record: record["name"].lower()) - - -def publish(record: dict, icon: bytes, icon_name: str) -> None: - """Write the icon, metadata and desktop entry of one app.""" - folder = directory(apps_root() / record["id"]) - for stale in ("icon.png", "icon.svg"): - if stale != icon_name: - (folder / stale).unlink(missing_ok=True) - write_atomic(folder / icon_name, icon) - record = {**record, "icon": icon_name} - payload = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n" - write_atomic(folder / "app.json", payload.encode()) - directory(applications_dir()) - write_atomic(desktop_path(record["id"]), desktop_entry(record, folder / icon_name).encode()) - - -def install(url: str, name: str = "", identifier: str = "", offline: bool = False, - fetcher: Fetcher | None = None) -> dict: - url = validate_url(url) - name = clean_name(name) - icon = None - discovered = "" - if not offline: - icon, discovered = discover(url, fetcher) - name = name or discovered or name_from_host(url) - explicit = bool(identifier) - identifier = validate_id(identifier) if explicit else validate_id( - slug(name) or slug(name_from_host(url)) or "web-app") - record = { - "version": 1, - "id": identifier, - "name": name, - "url": url, - "default": False, - "iconSource": "favicon" if icon else "monogram", - # Seeded and offline installs record no attempt, so the first launch - # fetches the real icon; a failed fetch waits a day. - "iconAttempt": 0 if offline else int(time.time()), - } - with locked(): - if not explicit: - # Another site that happens to share the name gets its own id; - # the same address again updates the app in place. - base, number = identifier, 1 - while True: - current = read_record(identifier) - if current is None or current["url"] == url: - break - number += 1 - identifier = f"{base[:37].rstrip('-')}-{number}" - record["id"] = identifier - existing = desktop_path(identifier) - if existing.exists() and read_record(identifier) is None: - raise WebAppError(f"{existing} exists and is not a CybexOS web app") - if icon: - publish(record, icon, "icon.png") - else: - publish(record, monogram(name, identifier), "icon.svg") - return {**record, "icon": str(apps_root() / identifier / - ("icon.png" if icon else "icon.svg"))} - - -def remove(identifier: str) -> dict: - identifier = validate_id(identifier) - with locked() as root: - record = read_record(identifier) - desktop = desktop_path(identifier) - if record is None and not desktop.exists(): - raise WebAppError(f"No web app named {identifier} is installed") - if desktop.exists(): - # Only a desktop entry this helper wrote is removed. - text = desktop.read_text(encoding="utf-8", errors="replace") - if f"X-CybexOS-WebApp={identifier}\n" not in text: - raise WebAppError(f"{desktop} is not a CybexOS web app") - desktop.unlink() - folder = root / identifier - if folder.is_dir() and not folder.is_symlink(): - shutil.rmtree(folder) - return record or {"id": identifier, "name": identifier} - - -# ---- Defaults ----------------------------------------------------------------- - -def seeded_path() -> Path: - return apps_root() / "defaults.json" - - -def seeded_ids() -> set[str]: - try: - value = json.loads(seeded_path().read_text(encoding="utf-8")) - except (OSError, ValueError): - return set() - ids = value.get("seeded") if isinstance(value, dict) else None - return {item for item in ids if isinstance(item, str)} if isinstance(ids, list) else set() - - -def record_seeded(ids: set[str]) -> None: - payload = json.dumps({"version": 1, "seeded": sorted(ids)}, indent=2) + "\n" - write_atomic(seeded_path(), payload.encode()) - - -def seed_default(identifier: str) -> bool: - """Install one default once; False when it was seeded before.""" - default = next((entry for entry in DEFAULTS if entry[0] == identifier), None) - if default is None: - return False - with locked(): - seeded = seeded_ids() - if identifier in seeded: - return False - # A file of the user's own at that name is kept, and counts as seeded. - if not desktop_path(identifier).exists(): - record = {"version": 1, "id": identifier, "name": default[1], "url": default[2], - "default": True, "iconSource": "monogram", "iconAttempt": 0} - publish(record, monogram(default[1], identifier), "icon.svg") - record_seeded(seeded | {identifier}) - return True - - -def seed() -> list[str]: - """Seed every default that was never seeded; never re-add a removed one.""" - already = seeded_ids() - return [identifier for identifier, _name, _url in DEFAULTS - if identifier not in already and seed_default(identifier)] - - -# ---- Launching ---------------------------------------------------------------- - -def browser_command(url: str) -> list[str]: - brave = shutil.which(BRAVE) - if brave: - return [brave, *BRAVE_FLAGS, f"--app={url}"] - return [shutil.which(FIREFOX) or FIREFOX, url] - - -def spawn(argv: list[str]) -> None: - subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True) - - -def refresh_icon(identifier: str, fetcher: Fetcher | None = None) -> bool: - record = read_record(identifier) - if record is None: - raise WebAppError(f"No web app named {identifier} is installed") - with locked(): - record["iconAttempt"] = int(time.time()) - payload = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n" - write_atomic(record_path(identifier), payload.encode()) - icon, _name = discover(record["url"], fetcher) - if icon is None: - return False - with locked(): - current = read_record(identifier) - if current is None or not desktop_path(identifier).exists(): - return False - publish({**current, "iconSource": "favicon"}, icon, "icon.png") - return True - - -def launch(identifier: str) -> None: - identifier = validate_id(identifier) - record = read_record(identifier) - if record is None or not desktop_path(identifier).exists(): - if seed_default(identifier): - record = read_record(identifier) - if record is None: - default = next((entry for entry in DEFAULTS if entry[0] == identifier), None) - hint = (f"cybex webapp install --name {default[1]} --url {default[2]}" - if default else "cybex webapp install --url ADDRESS") - raise WebAppError( - f"The {default[1] if default else identifier} web app was removed. " - f"Reinstall it with Install web app… in the launcher, or {hint}") - try: - spawn(browser_command(record["url"])) - except OSError as error: - raise WebAppError(f"No browser could open {record['name']}: {error}") from error - # The first launch of a seeded or offline install fetches its real icon - # in the background; a failed fetch waits a day. - if (record.get("iconSource") != "favicon" and record["url"].startswith("https://") - and time.time() - int(record.get("iconAttempt") or 0) > ICON_RETRY_SECONDS): - spawn([sys.executable, "-I", os.path.realpath(__file__), "refresh", "--quiet", - identifier]) - - -# ---- Command line --------------------------------------------------------------- - -def notify(summary: str, body: str = "", urgency: str = "normal") -> None: - if shutil.which("notify-send") is None: - return - with contextlib.suppress(OSError, subprocess.SubprocessError): - subprocess.run(["notify-send", "--app-name=CybexOS", f"--urgency={urgency}", - summary, body], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, timeout=3, check=False) - - -def parser() -> argparse.ArgumentParser: - root = argparse.ArgumentParser(prog="cybexos-webapp", description=__doc__.split("\n")[0]) - commands = root.add_subparsers(dest="command", required=True) - add = commands.add_parser("install", help="install a web app") - add.add_argument("--url", required=True) - add.add_argument("--name", default="") - add.add_argument("--id", default="") - add.add_argument("--offline", action="store_true", help="skip fetching the site icon") - add.add_argument("--notify", action="store_true", help="report the outcome as a notification") - drop = commands.add_parser("remove", help="remove a web app") - drop.add_argument("id") - drop.add_argument("--notify", action="store_true") - listing = commands.add_parser("list", help="list installed web apps") - listing.add_argument("--json", action="store_true") - start = commands.add_parser("launch", help="open a web app") - start.add_argument("id") - refresh = commands.add_parser("refresh", help="fetch site icons again") - refresh.add_argument("ids", nargs="*") - refresh.add_argument("--quiet", action="store_true") - commands.add_parser("seed", help="install the CybexOS default web apps once") - return root - - -def main(argv: list[str] | None = None) -> int: - arguments = parser().parse_args(argv) - announce = getattr(arguments, "notify", False) - try: - if arguments.command == "install": - record = install(arguments.url, arguments.name, arguments.id, arguments.offline) - print(record["id"]) - if announce: - notify(f"{record['name']} is installed", "Find it in the launcher.") - elif arguments.command == "remove": - record = remove(arguments.id) - if announce: - notify(f"{record.get('name', arguments.id)} was removed") - elif arguments.command == "list": - records = [{"id": record["id"], "name": record["name"], "url": record["url"], - "icon": str(apps_root() / record["id"] / record["icon"]), - "default": bool(record.get("default"))} for record in installed()] - if arguments.json: - print(json.dumps(records, ensure_ascii=False)) - else: - for record in records: - print(f"{record['id']}\t{record['name']}\t{record['url']}") - elif arguments.command == "launch": - launch(arguments.id) - elif arguments.command == "refresh": - for identifier in arguments.ids or [record["id"] for record in installed()]: - updated = refresh_icon(validate_id(identifier)) - if not arguments.quiet: - print(f"{identifier}: {'icon updated' if updated else 'no icon found'}") - elif arguments.command == "seed": - added = seed() - if added: - print("CHANGED: seeded " + ", ".join(added)) - except WebAppError as error: - print(f"cybexos-webapp: {error}", file=sys.stderr) - if announce or arguments.command == "launch": - notify("Web apps", str(error), "normal") - return 1 - except OSError as error: - print(f"cybexos-webapp: {error}", file=sys.stderr) - if announce or arguments.command == "launch": - notify("Web apps", "The web app could not be changed. Run cybex webapp in a " - "terminal for details.") - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/roles/dotfiles/templates/external-monitor-toggle.j2 b/assets/scripts/external-monitor-toggle old mode 100644 new mode 100755 similarity index 100% rename from roles/dotfiles/templates/external-monitor-toggle.j2 rename to assets/scripts/external-monitor-toggle diff --git a/roles/dotfiles/templates/fastfetch-link-speed.j2 b/assets/scripts/fastfetch-link-speed old mode 100644 new mode 100755 similarity index 100% rename from roles/dotfiles/templates/fastfetch-link-speed.j2 rename to assets/scripts/fastfetch-link-speed diff --git a/assets/scripts/game-mode b/assets/scripts/game-mode index 9bcc1711..c269a413 100755 --- a/assets/scripts/game-mode +++ b/assets/scripts/game-mode @@ -1,5 +1,5 @@ #!/usr/bin/python3 -I -"""Turn CybexOS game mode on or off. +"""Turn NerdOS game mode on or off. game-mode toggle|on|off|status [--json] @@ -8,9 +8,9 @@ and gaps off and holds idle locking and idle suspend off until it is turned off again. Super+Shift+G toggles it; the menubar shows an indicator while it is on, and clicking that turns it off. -The compositor side is cybexos_game_mode() in looknfeel.lua, which keeps what +The compositor side is nerdos_game_mode() in looknfeel.lua, which keeps what it replaced and restores exactly that. This helper records the request in -$XDG_RUNTIME_DIR/cybexos/game-mode.json ({"active": bool, "instance": ..., +$XDG_RUNTIME_DIR/nerdos/game-mode.json ({"active": bool, "instance": ..., "since": ...}), which the compositor reads again after a configuration reload and the menubar watches for its indicator. The file names the Hyprland instance it belongs to: a new login starts a new instance, so game mode always @@ -35,7 +35,7 @@ import time STATE_NAME = "game-mode.json" MAX_BYTES = 4096 -INHIBITOR_UNIT = "cybexos-game-mode-inhibit.service" +INHIBITOR_UNIT = "nerdos-game-mode-inhibit.service" SESSION_TARGET = "hyprland-session.target" COMMAND_TIMEOUT = 10 @@ -48,7 +48,7 @@ def runtime_dir(): value = os.environ.get("XDG_RUNTIME_DIR", "") if not value or not os.path.isabs(value): raise Failure("XDG_RUNTIME_DIR is not set; run game mode from the desktop session") - return Path(value) / "cybexos" + return Path(value) / "nerdos" def instance(): @@ -100,7 +100,7 @@ def run(argv): def compositor(enabled): """Ask looknfeel.lua to apply or restore; hyprctl answers "ok" on success.""" - call = "cybexos_game_mode(" + ("true" if enabled else "false") + ")" + call = "nerdos_game_mode(" + ("true" if enabled else "false") + ")" result = run(["hyprctl", "eval", call]) reply = (result.stdout or "").strip() if result.returncode != 0 or reply.lower() != "ok": @@ -122,7 +122,7 @@ def start_inhibitor(): run(["systemctl", "--user", "reset-failed", INHIBITOR_UNIT]) result = run([ "systemd-run", "--user", "--quiet", "--collect", "--unit=" + INHIBITOR_UNIT, - "--description=CybexOS game mode idle inhibitor", + "--description=NerdOS game mode idle inhibitor", "--property=PartOf=" + SESSION_TARGET, "--", "systemd-inhibit", "--what=idle", "--who=Game mode", "--why=Game mode is on", "--mode=block", "sleep", "infinity", @@ -191,7 +191,7 @@ def turn_off(directory, current): def main(argv=None): - parser = argparse.ArgumentParser(prog="game-mode", description="Turn CybexOS game mode on or off.") + parser = argparse.ArgumentParser(prog="game-mode", description="Turn NerdOS game mode on or off.") parser.add_argument("action", choices=("toggle", "on", "off", "status")) parser.add_argument("--json", action="store_true", help="print the resulting state as JSON") args = parser.parse_args(argv) diff --git a/assets/scripts/nerdos-agent b/assets/scripts/nerdos-agent new file mode 100644 index 00000000..7ae4db78 --- /dev/null +++ b/assets/scripts/nerdos-agent @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# Select and launch one supported coding agent without owning its credentials +# or permission policy. Desktop callers use --window; terminal callers keep +# their current working directory and TTY. +set -uo pipefail + +umask 077 + +config_home=${XDG_CONFIG_HOME:-${HOME:?HOME is required}/.config} +defaults_dir="$config_home/nerdos/defaults" +preference_file="$defaults_dir/agent" +window_child=false +temporary= +chosen_agent= + +cleanup() { + [[ -z ${temporary:-} || ! -e $temporary ]] || rm -f -- "$temporary" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +pause_after_error() { + if [[ $window_child == true && -t 0 ]]; then + printf '\nPress Enter to close.' >&2 + IFS= read -r || true + fi +} + +fail() { + local status=$1 + shift + printf 'nerd agent: %s\n' "$*" >&2 + pause_after_error + exit "$status" +} + +usage() { + cat <<'EOF' +Usage: nerd agent [--window] [--pick] + nerd agent get + nerd agent set [opencode|claude|codex] + nerd agent unset + nerd agent list + nerd agent prompt + +With no subcommand, launch the selected agent in the current directory. The +first interactive launch asks for a default. --pick always asks before launch, +and --window opens the agent in a dedicated Kitty window rooted at ~/Code. + +The launcher does not add automatic-approval or permission-bypass flags. +Authentication and agent-specific settings remain owned by the selected CLI. +EOF +} + +supported_agent() { + case $1 in + opencode|claude|codex) return 0 ;; + *) return 1 ;; + esac +} + +agent_label() { + case $1 in + opencode) printf 'OpenCode' ;; + claude) printf 'Claude Code' ;; + codex) printf 'Codex CLI' ;; + esac +} + +agent_description() { + case $1 in + opencode) printf 'provider-neutral, hosted or local models' ;; + claude) printf 'Anthropic account' ;; + codex) printf 'ChatGPT account or OpenAI API key' ;; + esac +} + +agent_available() { + command -v -- "$1" >/dev/null 2>&1 +} + +load_default() { + local selected + [[ -f $preference_file && -r $preference_file ]] || return 1 + selected=$(<"$preference_file") + supported_agent "$selected" || return 2 + printf '%s\n' "$selected" +} + +store_default() { + local selected=$1 + supported_agent "$selected" \ + || fail 2 "unsupported agent '$selected' (choose opencode, claude, or codex)" + agent_available "$selected" \ + || fail 127 "$(agent_label "$selected") is not installed; enable nerdos.features.developer in /etc/nixos/local and preview with 'nerd update stage local'" + mkdir -p -- "$defaults_dir" \ + || fail 1 "could not create $defaults_dir" + temporary=$(mktemp "$defaults_dir/.agent.XXXXXX") \ + || fail 1 "could not stage the default-agent preference" + printf '%s\n' "$selected" >"$temporary" \ + || fail 1 "could not write the default-agent preference" + chmod 0600 "$temporary" \ + || fail 1 "could not protect the default-agent preference" + mv -fT -- "$temporary" "$preference_file" \ + || fail 1 "could not activate the default-agent preference" + temporary= + printf 'Default AI agent: %s\n' "$(agent_label "$selected")" +} + +pick_agent() { + local agent choice + local choices=() + + for agent in opencode claude codex; do + if agent_available "$agent"; then + choices+=("$agent"$'\t'"$(agent_label "$agent") - $(agent_description "$agent")") + fi + done + ((${#choices[@]} > 0)) \ + || fail 127 "no supported AI agent is installed; enable nerdos.features.developer in /etc/nixos/local and preview with 'nerd update stage local'" + # stdout is intentionally captured by the caller so only the selected ID + # flows into the adapter; stdin remains the reliable interactive boundary. + [[ -t 0 ]] \ + || fail 1 "no default is selected; run 'nerd agent set' in a terminal" + agent_available fzf \ + || fail 127 "fzf is required to choose a default AI agent" + + choice=$(printf '%s\n' "${choices[@]}" | fzf \ + --height=40% --layout=reverse --border \ + --delimiter=$'\t' --with-nth=2.. --prompt='Default AI agent> ') || return 130 + agent=${choice%%$'\t'*} + supported_agent "$agent" && agent_available "$agent" \ + || fail 1 "the agent picker returned an invalid selection" + printf '%s\n' "$agent" +} + +choose_default() { + local status + chosen_agent=$(pick_agent) + status=$? + ((status == 0)) || return "$status" + store_default "$chosen_agent" +} + +launch_agent() { + local force_pick=$1 prompt_mode=$2 prompt=${3:-} + local selected status + local command=() + + if [[ $force_pick == true ]]; then + choose_default + status=$? + ((status == 0)) || return "$status" + selected=$chosen_agent + else + selected=$(load_default) + status=$? + if ((status != 0)); then + if ((status == 2)); then + printf 'nerd agent: ignoring invalid preference in %s\n' \ + "$preference_file" >&2 + fi + choose_default + status=$? + ((status == 0)) || return "$status" + selected=$chosen_agent + fi + fi + agent_available "$selected" \ + || fail 127 "$(agent_label "$selected") is selected but its '$selected' command is missing" + + case $selected in + opencode) + command=(opencode) + [[ $prompt_mode == false ]] || command+=(--prompt "$prompt") + ;; + claude) + command=(claude) + [[ $prompt_mode == false ]] || command+=(-- "$prompt") + ;; + codex) + command=(codex) + [[ $prompt_mode == false ]] || command+=(-- "$prompt") + ;; + esac + + exec "${command[@]}" + fail 127 "could not launch $(agent_label "$selected")" +} + +launch_window() { + local workspace launcher + agent_available nerdos-apps || fail 127 "The default application helper is not installed" + launcher=$(command -v nerd) \ + || fail 127 "the nerd command is not installed" + workspace=${NERDOS_AGENT_WORKSPACE:-$HOME/Code} + [[ -d $workspace ]] || workspace=$HOME + exec nerdos-apps terminal --app-id=nerdos-agent --dir="$workspace" \ + -- "$launcher" agent --window-child "$@" + fail 127 "could not open the AI agent terminal" +} + +list_agents() { + local agent selected='' status marker availability + selected=$(load_default) + status=$? + ((status == 0)) || selected= + for agent in opencode claude codex; do + marker=' ' + [[ $agent != "$selected" ]] || marker='*' + availability=missing + agent_available "$agent" && availability=installed + printf '%s %-8s %-9s %s\n' "$marker" "$agent" "$availability" \ + "$(agent_label "$agent")" + done +} + +join_prompt() { + local value=$1 part + shift + for part in "$@"; do + value+=" $part" + done + printf '%s' "$value" +} + +main() { + local selected status prompt + + if [[ ${1:-} == --window ]]; then + shift + launch_window "$@" + fi + if [[ ${1:-} == --window-child ]]; then + window_child=true + shift + fi + + case ${1:-} in + '' ) + launch_agent false false + ;; + --pick|pick) + shift + (($# == 0)) || fail 2 "--pick does not accept arguments" + launch_agent true false + ;; + get) + shift + (($# == 0)) || fail 2 "get does not accept arguments" + selected=$(load_default) + status=$? + case $status in + 0) printf '%s\n' "$selected" ;; + 1) fail 1 "no default AI agent is selected" ;; + 2) fail 1 "the preference in $preference_file is invalid" ;; + esac + ;; + set) + shift + (($# <= 1)) || fail 2 "set accepts at most one agent name" + if (($# == 1)); then + store_default "$1" + else + selected=$(pick_agent) + status=$? + ((status == 0)) || return "$status" + store_default "$selected" + fi + ;; + unset) + shift + (($# == 0)) || fail 2 "unset does not accept arguments" + if [[ -e $preference_file || -L $preference_file ]]; then + rm -f -- "$preference_file" \ + || fail 1 "could not remove the default-agent preference" + printf 'Default AI agent cleared.\n' + fi + ;; + list) + shift + (($# == 0)) || fail 2 "list does not accept arguments" + list_agents + ;; + prompt) + shift + (($# > 0)) || fail 2 "prompt requires text" + prompt=$(join_prompt "$@") + [[ -n $prompt ]] || fail 2 "prompt requires non-empty text" + launch_agent false true "$prompt" + ;; + help|-h|--help) + usage + ;; + *) + fail 2 "unknown option or subcommand '$1'" + ;; + esac +} + +main "$@" diff --git a/assets/scripts/nerdos-apps b/assets/scripts/nerdos-apps new file mode 100755 index 00000000..75ee8b21 --- /dev/null +++ b/assets/scripts/nerdos-apps @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Personal default applications and argv-safe desktop launch entry points.""" +import argparse +import configparser +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile + +ROLES = {'browser': ['x-scheme-handler/http', 'x-scheme-handler/https', 'text/html'], + 'editor': ['text/plain'], 'pdf': ['application/pdf'], + 'image': ['image/png', 'image/jpeg', 'image/webp'], + 'video': ['video/mp4', 'video/webm', 'video/x-matroska'], 'terminal': []} + + +def libraries(): + import gi + gi.require_version('Gio', '2.0') + from gi.repository import Gio, GLib + try: + gi.require_version('GioUnix', '2.0') + from gi.repository import GioUnix + desktop = GioUnix.DesktopAppInfo + except (ValueError, ImportError): + desktop = Gio.DesktopAppInfo + return Gio, GLib, desktop + + +def config_root(): + return Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) + + +def load_choices(): + path = config_root() / 'nerdos/default-applications.json' + try: + value = json.loads(path.read_text()) + except FileNotFoundError: + return {} + if value.get('schema') != 1 or not isinstance(value.get('choices'), dict): + raise ValueError('Unsupported default-application preferences. Preserve the existing file.') + return value['choices'] + + +def write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink(): + raise ValueError('The preference file is linked. Edit its source explicitly.') + fd, temporary = tempfile.mkstemp(prefix='.applications-', dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(text); stream.flush(); os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def compatible(app, role): + if role == 'browser': + return app.supports_uris() and bool(set(app.get_supported_types() or []) & set(ROLES['browser'][:2])) + if role == 'terminal': + return 'TerminalEmulator' in (app.get_categories() or '').split(';') + return bool(set(app.get_supported_types() or []) & set(ROLES[role])) + + +def desktop_app(identifier): + _, _, Desktop = libraries() + try: + return Desktop.new(identifier) + except TypeError: # PyGObject reports a NULL constructor as TypeError. + return None + + +def current(role): + Gio, _, Desktop = libraries() + if role == 'terminal': + result = subprocess.run(['xdg-terminal-exec', '--print-id'], text=True, capture_output=True, timeout=10) + return desktop_app(result.stdout.strip().split(':')[0]) if result.returncode == 0 else None + return Gio.AppInfo.get_default_for_type(ROLES[role][0], False) + + +def declared_choice(role): + desktops = [d.lower() for d in os.environ.get('XDG_CURRENT_DESKTOP', '').split(':') if d] + config_dirs = [config_root(), *map(Path, os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg').split(':'))] + if role == 'terminal': + for folder in config_dirs: + for name in [*(d + '-xdg-terminals.list' for d in desktops), 'xdg-terminals.list']: + path = folder / name + if path.is_file(): + entries = [line.strip() for line in path.read_text().splitlines() if line.strip() and not line.startswith('#')] + if entries: return entries[0].split(':')[0] + return '' + data_dirs = [Path(os.environ.get('XDG_DATA_HOME', str(Path.home() / '.local/share'))), + *map(Path, os.environ.get('XDG_DATA_DIRS', '/usr/local/share:/usr/share').split(':'))] + for folder in [*config_dirs, *(d / 'applications' for d in data_dirs)]: + for name in [*(d + '-mimeapps.list' for d in desktops), 'mimeapps.list']: + document = configparser.ConfigParser(interpolation=None, strict=False) + document.read(folder / name) + value = document.get('Default Applications', ROLES[role][0], fallback='') + if value: return value.split(';')[0].strip() + return '' + + +def status(): + Gio, _, Desktop = libraries() + saved = load_choices() + applications = [a for a in Gio.AppInfo.get_all() if isinstance(a, Desktop) and a.should_show()] + result = {} + for role in ROLES: + actual = current(role) + missing = saved.get(role) and declared_choice(role) == saved[role] and desktop_app(saved[role]) is None + chosen = saved[role] if missing else actual.get_id() if actual else '' + choices = [{'value': a.get_id(), 'label': a.get_display_name()} for a in applications if compatible(a, role)] + choices.sort(key=lambda a: a['label'].casefold()) + if chosen and not any(c['value'] == chosen for c in choices): + choices.insert(0, {'value': chosen, 'label': chosen + (' · unavailable' if missing else '')}) + result[role] = {'selected': chosen, 'effective': actual.get_id() if actual else '', + 'unavailable': bool(missing), 'choices': choices} + return {'roles': result} + + +def select(role, identifier): + _, _, Desktop = libraries() + app = desktop_app(identifier) + if not app or not compatible(app, role): + raise ValueError('Choose an installed application that supports this use.') + saved = load_choices() + preference = config_root() / 'nerdos/default-applications.json' + if preference.is_symlink(): + raise ValueError('The preference file is linked. Edit its source explicitly.') + if role == 'terminal': + # Respect the most-specific personal terminal file when it exists. + candidates = [config_root() / (d.lower() + '-xdg-terminals.list') + for d in os.environ.get('XDG_CURRENT_DESKTOP', '').split(':') if d] + path = next((p for p in candidates if p.exists()), config_root() / 'xdg-terminals.list') + old = path.read_text().splitlines() if path.exists() else [] + write(path, identifier + '\n' + '\n'.join(line for line in old if line.strip() != identifier) + '\n') + else: + for mime in ROLES[role]: + if not app.set_as_default_for_type(mime): + raise ValueError('The application association could not be saved.') + write(preference, json.dumps({'schema': 1, 'choices': saved | {role: identifier}}, indent=2) + '\n') + return status() + + +def notify(title, message): + try: + subprocess.run(['notify-send', title, message], timeout=5, check=False, capture_output=True) + except (OSError, subprocess.TimeoutExpired): + pass + + +def warn_fallback(role): + _, _, Desktop = libraries() + saved = load_choices().get(role) + if saved and declared_choice(role) == saved and desktop_app(saved) is None: + notify('Default application unavailable', + 'Using an available application. Your saved choice is unchanged; review Settings → Applications.') + + +def terminal(arguments): + warn_fallback('terminal') + os.execvp('xdg-terminal-exec', ['xdg-terminal-exec', *arguments]) + + +def browser(arguments, private=False): + warn_fallback('browser') + app = current('browser') + if not app: + raise ValueError('Choose an installed browser in Settings → Applications.') + if private: + actions = app.list_actions() if hasattr(app, 'list_actions') else [] + action = next((a for a in ('new-private-window', 'new-incognito', 'NewPrivateWindow', 'Incognito') if a in actions), None) + if not action: + raise ValueError('The selected browser does not advertise a private-window action.') + if arguments: + raise ValueError('Private launch does not accept addresses.') + app.launch_action(action, None) + else: + if arguments: app.launch_uris(arguments, None) + else: app.launch([], None) + + +def webapp(url): + app = current('browser') + if app and any(name in (app.get_id() or '').lower() for name in ('brave', 'chromium', 'chrome', 'msedge')): + warn_fallback('browser') + subprocess.Popen([*editor_argv(app, []), '--app=' + url], start_new_session=True) + else: + browser([url]) + + +def editor_argv(app, files): + _, GLib, _ = libraries() + _, command = GLib.shell_parse_argv(app.get_commandline()) + result = [] + for token in command: + if token in ('%F', '%f', '%U', '%u'): + values = [Path(f).absolute().as_uri() for f in files] if token in ('%U', '%u') else files + result.extend(values if token in ('%F', '%U') else values[:1]) + elif token == '%i': + icon = app.get_string('Icon') + if icon: result.extend(['--icon', icon]) + elif token == '%c': result.append(app.get_display_name()) + elif token == '%k': result.append(app.get_filename()) + elif '%' not in token.replace('%%', ''): result.append(token.replace('%%', '%')) + else: raise ValueError('The selected editor has an unsupported desktop command.') + if not any(code in command for code in ('%F', '%f', '%U', '%u')): + result.extend(files) + return result + + +def edit(files): + # Explicit user shell overrides have precedence. No shell evaluates them. + for name in ('VISUAL', 'EDITOR'): + value = os.environ.get(name) + if value: + command = shlex.split(value) + if command and Path(command[0]).name != 'nerdos-apps': + os.execvp(command[0], [*command, *files]) + warn_fallback('editor') + app = current('editor') + if not app: + raise ValueError('Choose an installed text editor in Settings → Applications.') + if app.get_boolean('Terminal'): + terminal(['--', *editor_argv(app, files)]) + else: + Gio, _, _ = libraries() + app.launch([Gio.File.new_for_commandline_arg(f) for f in files], None) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='action', required=True) + sub.add_parser('status') + choose = sub.add_parser('set'); choose.add_argument('role', choices=ROLES); choose.add_argument('desktop_id') + term = sub.add_parser('terminal'); term.add_argument('arguments', nargs=argparse.REMAINDER) + web = sub.add_parser('browser'); web.add_argument('--private', action='store_true'); web.add_argument('addresses', nargs='*') + site = sub.add_parser('webapp'); site.add_argument('url') + editor = sub.add_parser('edit'); editor.add_argument('files', nargs='*') + # Terminal options belong to xdg-terminal-exec, including leading options. + if len(sys.argv) > 1 and sys.argv[1] == 'terminal': + terminal(sys.argv[2:]); return + args = parser.parse_args() + if args.action == 'status': print(json.dumps(status())) + elif args.action == 'set': print(json.dumps(select(args.role, args.desktop_id))) + elif args.action == 'terminal': terminal(args.arguments) + elif args.action == 'browser': browser(args.addresses, args.private) + elif args.action == 'edit': edit(args.files) + elif args.action == 'webapp': webapp(args.url) + + +if __name__ == '__main__': + try: main() + except Exception as error: + # GIO diagnostics and application arguments can contain private URLs. + message = str(error) if isinstance(error, ValueError) else 'The application request failed. Check the selected application and retry.' + if len(sys.argv) > 1 and sys.argv[1] not in ('status', 'set'): + notify('Application could not be opened', message) + print(json.dumps({'error': message}), file=sys.stderr) + sys.exit(1) diff --git a/assets/scripts/nerdos-backup b/assets/scripts/nerdos-backup new file mode 100755 index 00000000..1d1af37c --- /dev/null +++ b/assets/scripts/nerdos-backup @@ -0,0 +1,1528 @@ +#!/usr/bin/python3 -I +"""Encrypted personal-file backups with restic. + +`nerd backup` and Settings -> Backup & recovery both drive this helper: + + nerdos-backup setup local PATH [--existing] [--json] + nerdos-backup setup sftp HOST [PATH] [--existing] [--json] + nerdos-backup confirm-key + nerdos-backup recovery-key [--json] + nerdos-backup now [--notify] + nerdos-backup scheduled + nerdos-backup status [--json] + nerdos-backup snapshots [--json] + nerdos-backup restore [SNAPSHOT] [--path PATH] [--open] [--notify] + nerdos-backup automatic on|off + nerdos-backup excludes + +The repository is always encrypted. A random password kept in the GNOME +Keyring opens it on this computer; a separate recovery key, shown once at +setup, opens it anywhere. Losing both loses the backups, so setup asks the +user to save the key before the first backup. + +A restore always goes into a new folder under ~/Restored and never writes +over the home folder. `scheduled` runs from a user timer during the desktop +session: it backs up at most once a day, quietly skips while the destination +is unreachable, on battery below 30 % or on a metered network, and reminds +once a day when the last backup is more than a week old. + +Invoked as app-backup or browser-backup it is the earlier opt-in browser +profile export: an age-encrypted tarball for the recipient in AGE_RECIPIENT. + +The interpreter runs in isolated mode so a backup never imports modules from +the user's site directory or the working directory. +""" + +from __future__ import annotations + +import argparse +import collections +import contextlib +import fcntl +import getpass +import json +import os +import re +import secrets +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +EXIT_OK = 0 +EXIT_FAILED = 1 +EXIT_USAGE = 2 +EXIT_NOT_CONFIGURED = 3 +# The destination already holds backups; connecting needs their recovery key. +EXIT_EXISTING = 4 +EXIT_BUSY = 75 + +TAG = "nerdos" +REPOSITORY_FOLDER = "nerdos-backup" +KEYRING_APPLICATION = "nerdos-backup" +# restic forget: what the history keeps. +RETENTION = (("--keep-daily", "7"), ("--keep-weekly", "4"), ("--keep-monthly", "6")) +# Daily, with an hour's slack so the hourly check does not push each day's +# backup an hour later than the last. +DUE_AFTER = timedelta(hours=23) +REMIND_AFTER = timedelta(days=7) +MAINTAIN_AFTER = timedelta(days=7) +BATTERY_THRESHOLD = 30 +POWER_SUPPLY = Path("/sys/class/power_supply") +# Crockford's base32: no I, L, O or U, so a written-down key reads back. +RECOVERY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +RECOVERY_GROUPS = 6 +RECOVERY_GROUP_LENGTH = 5 +# The same trust rules as the Remote Server widget: the user's SSH keys and +# ~/.ssh/config, never a password prompt, never an unknown host key. +SSH_ARGS = ("-o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=15 " + "-o ServerAliveInterval=60 -o ServerAliveCountMax=240") +HOST_PATTERN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.@%-]{0,253}") +REMOTE_PATH_PATTERN = re.compile(r"/?[A-Za-z0-9_][A-Za-z0-9_. +/-]{0,250}") +SNAPSHOT_PATTERN = re.compile(r"latest|[0-9a-f]{8,64}") +# Top-level folders that never take a backup unless a drive is mounted there. +SYSTEM_FOLDERS = frozenset(("bin", "boot", "dev", "etc", "lib", "lib64", "proc", "root", "run", + "sbin", "sys", "usr")) + +# Relative to the home folder: caches, the trash and everything that is +# downloaded or rebuilt rather than made by the user. Personal files, +# settings and application data stay in. Add more, one restic pattern per +# line, in ~/.config/nerdos/backup-exclude. +HOME_EXCLUDES = ( + ".cache", + ".local/share/Trash", + ".var/app/*/cache", + # Image/build stores include disposable writable layers; named volumes + # remain personal data when readable by this user. Private subordinate-UID + # data needs a readable export; live databases also need consistency. + ".local/share/containers/storage/overlay", + ".local/share/containers/storage/overlay-images", + ".local/share/containers/storage/overlay-layers", + ".local/share/containers/storage/vfs", + ".local/share/containers/storage/vfs-images", + ".local/share/containers/storage/vfs-layers", + ".local/share/containers/cache", + ".local/share/docker/overlay2", + ".local/share/docker/image", + ".local/share/docker/buildkit", + ".local/share/docker/containerd", + ".local/share/flatpak", + ".local/share/nvim/lazy", + ".local/share/nvim/mason", + ".local/share/voxtype/models", + ".cargo/registry", + ".cargo/git", + ".rustup", + ".npm", + ".npm-global", + ".bun/install/cache", + "go/pkg/mod", + ".m2/repository", + ".gradle/caches", + ".gradle/wrapper/dists", + "Android/Sdk", + ".android/avd", + ".ollama/models", + # Chromium, Brave, Chrome and Electron applications keep their caches + # beside the profile instead of under ~/.cache. + ".config/**/Cache", + ".config/**/Code Cache", + ".config/**/GPUCache", + ".config/**/DawnCache", + ".config/**/GrShaderCache", + ".config/**/ShaderCache", + ".config/**/Service Worker/CacheStorage", + ".config/**/component_crx_cache", +) + tuple( + root + "/" + relative + for root in (".local/share/Steam", ".steam", ".steam/steam", ".steam/root", + ".var/app/com.valvesoftware.Steam/.local/share/Steam") + for relative in ("appcache", "depotcache", "package", "logs", "config/htmlcache", + "steamapps/common", "steamapps/downloading", "steamapps/shadercache", + "steamapps/temp", "steamapps/workshop/downloads") +) +BACKUP_COVERAGE = ( + "Includes readable home files, Steam userdata and Proton saves, and readable rootless container volumes. " + "Skips caches, downloaded games, container images and writable layers, and mounted filesystems. " + "Run nerd backup excludes for all exclusions." +) +BACKUP_CONSISTENCY = ( + "Live database files may be inconsistent. Export databases to a home folder readable by your account " + "before backup (for example pg_dump or mysqldump), or stop the database for the whole backup. " + "Private container volumes owned by subordinate UIDs require readable exports; stopping the database " + "alone does not make those files readable. " + "Keep persistent container data in volumes or home bind mounts; system paths and mounts " + "outside the home filesystem are not included." +) +# Dependency trees and build output, wherever a project keeps them. Cargo's +# target/ and most tool caches carry CACHEDIR.TAG, which --exclude-caches +# already honours. +ANYWHERE_EXCLUDES = ( + "node_modules", + "__pycache__", + ".tox", + ".nox", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".parcel-cache", + ".zig-cache", + "zig-cache", +) +BROWSER_PROFILES = ( + ".config/mozilla/firefox", ".mozilla/firefox", ".config/BraveSoftware/Brave", + ".config/BraveSoftware/Brave-Browser", ".config/BraveSoftware/Brave-Origin", + ".config/google-chrome", +) + + +class Failure(Exception): + """A failure to report in plain words, with its exit status.""" + + def __init__(self, message, status=EXIT_FAILED, *, result="failed"): + super().__init__(message) + self.message = message + self.status = status + self.result = result + + +class Context: + """Where this account keeps its backup configuration and state.""" + + def __init__(self, environ=None): + environ = os.environ if environ is None else environ + self.home = Path(environ.get("HOME") or Path.home()) + config = Path(environ.get("XDG_CONFIG_HOME") or self.home / ".config") + state = Path(environ.get("XDG_STATE_HOME") or self.home / ".local/state") + runtime = Path(environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") + self.config_path = config / "nerdos/backup.json" + self.user_excludes = config / "nerdos/backup-exclude" + self.state_path = state / "nerdos/backup.json" + self.runtime = runtime / "nerdos" + self.lock_path = self.runtime / "backup.lock" + self.progress_path = self.runtime / "backup-progress.json" + + +def utcnow(): + return datetime.now(timezone.utc) + + +def stamp(moment): + return moment.astimezone(timezone.utc).isoformat(timespec="seconds") + + +def parse_time(value): + if not isinstance(value, str) or not value: + return None + # restic writes nanoseconds; datetime takes at most microseconds. + value = re.sub(r"(\.\d{6})\d+", r"\1", value.strip()) + try: + moment = datetime.fromisoformat(value) + except ValueError: + return None + return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) + + +def read_json(path, default): + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + return default + return data if isinstance(data, type(default)) else default + + +def write_json(path, data, mode=0o600): + """Replace a file atomically, so a reader never sees half of it.""" + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix="." + path.name + ".") + try: + with os.fdopen(descriptor, "w") as stream: + json.dump(data, stream, indent=2) + stream.write("\n") + os.chmod(temporary, mode) + os.replace(temporary, path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(temporary) + raise + + +def load_config(context): + config = read_json(context.config_path, {}) + if config.get("version") != 1 or not isinstance(config.get("destination"), dict) \ + or not isinstance(config.get("repository"), str) or not config.get("keyId"): + return None + return config + + +def require_config(context): + config = load_config(context) + if config is None: + raise Failure("Backups are not set up. Run nerd backup setup, or open Settings, " + "Backup & recovery.", EXIT_NOT_CONFIGURED) + return config + + +def load_state(context): + return read_json(context.state_path, {}) + + +def save_state(context, state): + write_json(context.state_path, state) + + +def update_state(context, **changes): + state = load_state(context) + state.update(changes) + save_state(context, state) + return state + + +# ---- Destinations ----------------------------------------------------------- + +def is_repository(path): + path = Path(path) + return (path / "config").is_file() and (path / "keys").is_dir() and (path / "data").is_dir() + + +def mount_point(path): + """The mount point holding `path`, or its nearest existing ancestor.""" + path = Path(os.path.abspath(path)) + while not path.exists(): + path = path.parent + while not os.path.ismount(path): + path = path.parent + return path + + +def drive_label(mount): + """A removable drive's name: udisks mounts it at /run/media/USER/LABEL.""" + parts = Path(mount).parts + if len(parts) == 5 and parts[1:3] == ("run", "media"): + return parts[4] + if len(parts) == 3 and parts[1] == "media": + return parts[2] + return "" + + +def local_destination(context, chosen): + """Validate a folder chosen for backups and name the repository in it.""" + if not isinstance(chosen, str) or not chosen.strip() or "\n" in chosen or "\0" in chosen: + raise Failure("Choose a folder for the backups.", EXIT_USAGE) + text = chosen.strip() + if text == "~" or text.startswith("~/"): + text = str(context.home) + text[1:] + path = Path(os.path.normpath(text)) + if not path.is_absolute(): + raise Failure("Choose the backup folder by its full path.", EXIT_USAGE) + if path == Path("/") or (path.parts[1] in SYSTEM_FOLDERS and not drive_label(mount_point(path))): + raise Failure(f"{path} is a system folder. Choose a folder on a backup drive.", EXIT_USAGE) + nearest = path + while not nearest.exists(): + nearest = nearest.parent + if not nearest.is_dir(): + raise Failure(f"{nearest} is not a folder.", EXIT_USAGE) + if is_repository(path) or path.name == REPOSITORY_FOLDER: + repository = path + else: + repository = path / REPOSITORY_FOLDER + mount = mount_point(repository) + home = context.home.resolve() + inside_home = repository == home or home in repository.parents + # A backup inside the home folder would back itself up and share the + # disk it is meant to survive. A separately mounted drive is fine. + if inside_home and mount == mount_point(home): + raise Failure("Choose a folder outside your home folder, such as an external drive.", + EXIT_USAGE) + label = drive_label(mount) + display = label if label else str(repository) + if label and repository != mount: + display = label + " · " + str(repository.relative_to(mount)) + destination = {"type": "local", "path": str(repository), "mount": str(mount), + "label": label, "display": display} + return destination, str(repository) + + +def remote_destination(host, path): + host = (host or "").strip() + path = (path or REPOSITORY_FOLDER).strip().rstrip("/") or REPOSITORY_FOLDER + if not HOST_PATTERN.fullmatch(host): + raise Failure("Enter an SSH alias or user@hostname; set ports in ~/.ssh/config.", + EXIT_USAGE) + if not REMOTE_PATH_PATTERN.fullmatch(path) or ".." in Path(path).parts: + raise Failure("Enter the folder on the server as a plain path, such as " + "backups/laptop.", EXIT_USAGE) + destination = {"type": "sftp", "host": host, "path": path, "label": "", + "display": host + ":" + path} + return destination, f"sftp:{host}:{path}" + + +def destination_reachable(destination): + """(reachable, reason) without touching the repository's contents. + + A removable drive must still be mounted where it was: an unmounted drive + leaves an empty directory behind on the root filesystem, and a backup + written there would fill the system disk instead. + """ + if destination.get("type") == "local": + mount = destination.get("mount") or "/" + name = destination.get("label") or destination.get("display") or mount + if not os.path.ismount(mount): + return False, f"{name} is not connected." + if not is_repository(destination.get("path", "")): + return False, f"The backup folder on {name} is missing." + return True, "" + return None, "" + + +def ssh_endpoint(host): + """Hostname and port from the user's SSH configuration, or None when + a proxy decides how the connection is made.""" + try: + result = subprocess.run(["ssh", "-G", "--", host], capture_output=True, text=True, + timeout=10, check=False) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + options = {} + for line in result.stdout.splitlines(): + key, _, value = line.partition(" ") + options.setdefault(key.lower(), value.strip()) + for proxy in ("proxyjump", "proxycommand"): + if options.get(proxy, "none") not in ("", "none"): + return None + try: + return options["hostname"], int(options.get("port") or 22) + except (KeyError, ValueError): + return None + + +def remote_reachable(host, timeout=8): + """False only when the server's SSH port certainly does not answer.""" + endpoint = ssh_endpoint(host) + if endpoint is None: + return None + try: + with socket.create_connection(endpoint, timeout=timeout): + return True + except OSError: + return False + + +def low_battery(root=None, threshold=BATTERY_THRESHOLD): + """Running on battery below the threshold. Device batteries (a mouse, a + headset) report scope Device and say nothing about this computer.""" + root = POWER_SUPPLY if root is None else root + capacities = [] + external = False + try: + supplies = sorted(Path(root).iterdir()) + except OSError: + return False + + def read(path): + try: + return path.read_text().strip() + except OSError: + return "" + + for supply in supplies: + kind = read(supply / "type") + if kind == "Battery": + if read(supply / "scope") == "Device": + continue + if read(supply / "status") == "Charging": + external = True + with contextlib.suppress(ValueError): + capacities.append(int(read(supply / "capacity"))) + elif read(supply / "online") == "1": + external = True + if external or not capacities: + return False + return sum(capacities) / len(capacities) < threshold + + +def metered_network(): + """NetworkManager's verdict: 1 is metered, 3 guessed metered.""" + try: + result = subprocess.run( + ["busctl", "--system", "get-property", "org.freedesktop.NetworkManager", + "/org/freedesktop/NetworkManager", "org.freedesktop.NetworkManager", "Metered"], + capture_output=True, text=True, timeout=5, check=False) + except (OSError, subprocess.TimeoutExpired): + return False + parts = result.stdout.split() + return result.returncode == 0 and len(parts) == 2 and parts[1] in ("1", "3") + + +# ---- Keyring ---------------------------------------------------------------- + +def keyring_attributes(key_id): + return ["application", KEYRING_APPLICATION, "repository", key_id] + + +def keyring_store(key_id, password, display): + try: + result = subprocess.run( + ["secret-tool", "store", "--label=NerdOS backup (" + display + ")", + *keyring_attributes(key_id)], + input=password, capture_output=True, text=True, timeout=120, check=False) + except FileNotFoundError as error: + raise Failure("secret-tool is not installed (package libsecret).") from error + except subprocess.TimeoutExpired as error: + raise Failure("The keyring did not answer. Unlock it and try again.") from error + if result.returncode != 0: + raise Failure("The backup password could not be saved in the keyring. " + "Unlock the keyring and try again.") + + +def keyring_clear(key_id): + with contextlib.suppress(OSError, subprocess.TimeoutExpired): + subprocess.run(["secret-tool", "clear", *keyring_attributes(key_id)], + capture_output=True, timeout=30, check=False) + + +class KeyringLocked(Failure): + pass + + +def keyring_password(key_id, interactive): + """The repository password from the keyring. + + `lookup` asks to unlock a locked keyring. A scheduled run must not put + that prompt in front of the user, so it searches without unlocking: a + locked item is listed without its secret, and the run is skipped. + """ + try: + if interactive: + result = subprocess.run(["secret-tool", "lookup", *keyring_attributes(key_id)], + capture_output=True, text=True, timeout=120, check=False) + if result.returncode == 0 and result.stdout: + return result.stdout + # A declined or impossible unlock prompt also ends here, and the + # search tells a locked keyring apart from a missing password. + result = subprocess.run(["secret-tool", "search", *keyring_attributes(key_id)], + capture_output=True, text=True, timeout=30, check=False) + except FileNotFoundError as error: + raise Failure("secret-tool is not installed (package libsecret).") from error + except subprocess.TimeoutExpired as error: + raise KeyringLocked("The keyring did not answer. Unlock it and try again.") from error + listed = False + for line in result.stdout.splitlines(): + if line.startswith("["): + listed = True + elif line.startswith("secret = "): + return line[len("secret = "):] + if listed: + raise KeyringLocked("The keyring is locked. Unlock it and try again.") + raise Failure("The backup password is missing from the keyring. Connect the backup " + "again with its recovery key.") + + +def new_password(): + return secrets.token_urlsafe(32) + + +def new_recovery_key(): + characters = "".join(secrets.choice(RECOVERY_ALPHABET) + for _ in range(RECOVERY_GROUPS * RECOVERY_GROUP_LENGTH)) + return format_recovery_key(characters) + + +def format_recovery_key(characters): + size = RECOVERY_GROUP_LENGTH + return "-".join(characters[i:i + size] for i in range(0, len(characters), size)) + + +def normalize_recovery_key(text): + """A typed key in its canonical form; anything else is used as typed, + so a restic repository made elsewhere can still be connected.""" + raw = (text or "").strip() + compact = re.sub(r"[\s-]", "", raw).upper().translate(str.maketrans("OIL", "011")) + if len(compact) == RECOVERY_GROUPS * RECOVERY_GROUP_LENGTH \ + and all(character in RECOVERY_ALPHABET for character in compact): + return format_recovery_key(compact) + return raw + + +# ---- restic ----------------------------------------------------------------- + +def restic_environment(): + """restic must not pick up a repository or password from the session.""" + environment = {key: value for key, value in os.environ.items() + if not key.startswith(("RESTIC_", "AWS_", "B2_", "AZURE_", "GOOGLE_"))} + environment["RESTIC_PROGRESS_FPS"] = "1" + return environment + + +class Restic: + """One repository; secrets reach restic through pipes, never argv or the + environment, where other processes could read them.""" + + def __init__(self, config): + self.repository = config["repository"] + self.remote = config["destination"].get("type") == "sftp" + self.host = config["destination"].get("host", "") + + def command(self, arguments, password_path, new_password_path=None): + command = ["restic", "--repo", self.repository, "--password-file", password_path, + "--retry-lock", "1m"] + if self.remote: + command += ["-o", "sftp.args=" + SSH_ARGS] + command += list(arguments) + if new_password_path: + command += ["--new-password-file", new_password_path] + return command + + def run(self, arguments, password, *, new_password=None, on_line=None): + """(status, stdout lines, stderr tail).""" + descriptors = [] + + def secret(value): + read, write = os.pipe() + os.write(write, value.encode()) + os.close(write) + descriptors.append(read) + return f"/dev/fd/{read}" + + try: + command = self.command(arguments, secret(password), + secret(new_password) if new_password is not None else None) + try: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, text=True, + env=restic_environment(), pass_fds=descriptors) + except FileNotFoundError as error: + raise Failure("restic is not installed.") from error + finally: + for descriptor in descriptors: + os.close(descriptor) + errors = collections.deque(maxlen=40) + lines = [] + # restic --json emits error records on stderr, beside its plain-text + # diagnostics. Deliver both streams without concurrently mutating Progress. + stream_lock = threading.Lock() + + def report(line): + with stream_lock: + on_line(line.rstrip("\n")) + + def read_errors(): + for line in process.stderr: + errors.append(line) + if on_line is not None: + report(line) + + with process: + # stderr drains beside stdout, so neither pipe can fill and stall restic. + drain = threading.Thread(target=read_errors, daemon=True) + drain.start() + for line in process.stdout: + line = line.rstrip("\n") + if on_line is None: + lines.append(line) + else: + report(line) + status = process.wait() + drain.join() + return status, lines, "".join(errors) + + def explain(self, status, stderr): + return explain(status, stderr, self.host) + + +def explain(status, stderr, host=""): + """restic's failure in plain words.""" + text = stderr or "" + lowered = text.lower() + server = host or "The server" + if status == 10 or "repository does not exist" in lowered: + return "No backup was found at the destination. Set up backups again." + if status == 11 or "unable to create lock" in lowered: + return "The backup is in use by another backup. Try again later." + if status == 12 or "wrong password" in lowered: + return "The saved password does not open this backup. Connect it again with its recovery key." + if "no space left on device" in lowered: + return "The backup destination is full." + if "host key verification failed" in lowered: + return f"This computer does not trust {server} yet. Connect once with ssh {host} in a terminal." + if "permission denied (publickey" in lowered or "permission denied, please try again" in lowered: + return f"{server} refused your SSH key." + if "could not resolve hostname" in lowered: + return f"{server} could not be found." + if any(phrase in lowered for phrase in ("connection refused", "connection timed out", + "no route to host", "network is unreachable", + "connection closed", "subsystem request failed")): + return f"{server} could not be reached." + if "read-only file system" in lowered: + return "The backup destination is read-only." + if "permission denied" in lowered: + return "NerdOS cannot write to the backup destination." + for line in reversed(text.splitlines()): + line = line.strip() + if line.startswith("Fatal: "): + line = line[len("Fatal: "):] + return line[:1].upper() + line[1:].rstrip(".") + "." + if status == 130 or status < 0: + return "The backup was interrupted." + return f"The backup failed (restic exit {status})." + + +def exclude_arguments(context, config): + home = str(context.home) + arguments = ["--exclude-caches", "--one-file-system"] + for pattern in HOME_EXCLUDES: + arguments += ["--exclude", home + "/" + pattern] + for pattern in ANYWHERE_EXCLUDES: + arguments += ["--exclude", pattern] + destination = config["destination"] + if destination.get("type") == "local": + arguments += ["--exclude", destination["path"]] + if context.user_excludes.is_file(): + arguments += ["--exclude-file", str(context.user_excludes)] + return arguments + + +def backup_arguments(context, config): + return ["backup", "--json", "--tag", TAG, "--host", config["host"], "--skip-if-unchanged", + *exclude_arguments(context, config), str(context.home)] + + +def forget_arguments(config): + arguments = ["forget", "--tag", TAG, "--host", config["host"]] + for flag, count in RETENTION: + arguments += [flag, count] + return arguments + ["--prune"] + + +# ---- Progress, lock and notifications ------------------------------------- + +@contextlib.contextmanager +def exclusive(context): + """One backup or restore at a time per account.""" + context.runtime.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor = os.open(context.lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise Failure("A backup or restore is already running.", EXIT_BUSY) from error + try: + yield + finally: + # The progress file belongs to the holder of the lock. + with contextlib.suppress(OSError): + context.progress_path.unlink() + finally: + os.close(descriptor) + + +def busy(context): + try: + descriptor = os.open(context.lock_path, os.O_RDONLY | os.O_NOFOLLOW) + except OSError: + return False + try: + fcntl.flock(descriptor, fcntl.LOCK_SH | fcntl.LOCK_NB) + except BlockingIOError: + return True + finally: + os.close(descriptor) + return False + + +class Progress: + """restic's JSON stream, summarized at most once a second for Settings.""" + + def __init__(self, context, operation, echo=False): + self.context = context + self.operation = operation + self.echo = echo + self.started = stamp(utcnow()) + self.last_write = 0.0 + self.summary = None + self.errors = collections.deque(maxlen=40) + self.error_count = 0 + self.write({"percent": 0}) + + def write(self, values): + data = {"operation": self.operation, "started": self.started, "pid": os.getpid(), **values} + with contextlib.suppress(OSError): + write_json(self.context.progress_path, data) + + def __call__(self, line): + try: + message = json.loads(line) + except ValueError: + return + if not isinstance(message, dict): + return + kind = message.get("message_type") + if kind == "summary": + self.summary = message + elif kind == "error": + error = message.get("error") or {} + detail = error.get("message", "") if isinstance(error, dict) else str(error) + self.error_count += 1 + self.errors.append(f"{message.get('item', '')}: {detail}".strip(": ") + or "restic reported an unspecified read error") + elif kind == "status" and time.monotonic() - self.last_write >= 1: + self.last_write = time.monotonic() + percent = message.get("percent_done") or 0 + self.write({"percent": round(float(percent), 4), + "bytesDone": message.get("bytes_done", message.get("bytes_restored", 0)), + "totalBytes": message.get("total_bytes", 0)}) + if self.echo: + print(f"\r{self.operation.capitalize()}: {float(percent) * 100:5.1f} %", + end="", file=sys.stderr, flush=True) + + def finish(self): + if self.echo and self.last_write: + print(file=sys.stderr) + + +def runtime_command(context): + installed = Path(shutil.which("nerdos-runtime") or "/run/current-system/sw/bin/nerdos-runtime") + return str(installed) if installed.exists() else shutil.which("nerdos-runtime") + + +def notify(context, title, body, urgency="normal"): + """One notification; clicking it opens Settings at Backup & recovery.""" + if not shutil.which("notify-send"): + return + command = ["notify-send", "--app-name=Backups", "--urgency=" + urgency, + "--action=default=Open backup settings", title, body] + try: + # notify-send waits while it offers an action; bound that wait. + result = subprocess.run(command, capture_output=True, text=True, timeout=600, check=False) + except (OSError, subprocess.TimeoutExpired): + return + runtime = runtime_command(context) + if result.stdout.strip() == "default" and runtime: + with contextlib.suppress(OSError, subprocess.TimeoutExpired): + subprocess.run([runtime, "ipc", "settings", "open", "backup"], + capture_output=True, timeout=10, check=False) + + +def start_timer(): + """The session target starts the timer at login; after an update or a + first setup in a running session, start it now. Best effort: outside a + session there is no user manager to ask.""" + with contextlib.suppress(OSError, subprocess.TimeoutExpired): + subprocess.run(["systemctl", "--user", "start", "--no-block", "nerdos-backup.timer"], + capture_output=True, timeout=10, check=False) + + +def human_size(count): + value = float(count or 0) + for unit in ("B", "KB", "MB", "GB", "TB"): + if value < 1000 or unit == "TB": + return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}" + value /= 1000 + return f"{value:.1f} TB" + + +# ---- Setup ------------------------------------------------------------------ + +def destination_from(context, arguments): + if arguments.kind == "local": + return local_destination(context, arguments.target) + return remote_destination(arguments.target, arguments.path) + + +def key_ids(repository, password): + status, lines, stderr = repository.run(["key", "list", "--json"], password) + if status != 0: + raise Failure(repository.explain(status, stderr)) + try: + keys = json.loads("\n".join(lines)) + except ValueError as error: + raise Failure("restic listed its keys in an unexpected form.") from error + current = [key.get("id") for key in keys if isinstance(key, dict) and key.get("current")] + return current[0] if current else None + + +def existing_message(): + return Failure("This destination already holds NerdOS backups. Enter their recovery key to " + "use them.", EXIT_EXISTING) + + +def create_repository(config, repository): + """A new repository: the keyring password opens it here, the recovery key + anywhere. Returns the recovery key, or None when it could not be added.""" + password = new_password() + keyring_store(config["keyId"], password, config["destination"]["display"]) + try: + status, _, stderr = repository.run(["init"], password) + if status != 0: + if re.search(r"already (exists|initialized)", stderr, re.IGNORECASE): + raise existing_message() + raise Failure(repository.explain(status, stderr)) + except BaseException: + keyring_clear(config["keyId"]) + raise + recovery = new_recovery_key() + config["recoveryKeyConfirmed"] = False + status, _, _ = repository.run(["key", "add"], password, new_password=recovery) + if status != 0: + # The backups still work from this computer; Settings offers a new key. + return None + config["recoveryKeyId"] = key_ids(repository, recovery) + return recovery + + +def connect_repository(config, repository, recovery_text): + """Existing backups: their recovery key adds a new keyring password.""" + recovery_key = normalize_recovery_key(recovery_text) + if not recovery_key: + raise Failure("Enter the recovery key for these backups.", EXIT_USAGE) + status, _, stderr = repository.run(["cat", "config"], recovery_key) + if status == 12: + raise Failure("That recovery key does not open these backups.") + if status != 0: + raise Failure(repository.explain(status, stderr)) + password = new_password() + keyring_store(config["keyId"], password, config["destination"]["display"]) + status, _, stderr = repository.run(["key", "add"], recovery_key, new_password=password) + if status != 0: + keyring_clear(config["keyId"]) + raise Failure(repository.explain(status, stderr)) + config["recoveryKeyId"] = key_ids(repository, recovery_key) + config["recoveryKeyConfirmed"] = True + + +def setup(context, arguments, read_key): + """Point backups at a destination. Returns (config, recovery key or None).""" + destination, url = destination_from(context, arguments) + previous = load_config(context) + if previous and previous["repository"] == url: + # Already backing up there; the keyring password still opens it. + return previous, None + config = {"version": 1, "destination": destination, "repository": url, + "host": socket.gethostname() or "localhost", "keyId": uuid.uuid4().hex, + "automatic": previous.get("automatic", True) if previous else True, + "created": stamp(utcnow())} + repository = Restic(config) + local = destination["type"] == "local" + exists = is_repository(destination["path"]) if local else None + recovery = None + if arguments.existing: + if exists is False: + raise Failure("No NerdOS backups were found in that folder.") + connect_repository(config, repository, read_key()) + else: + if exists: + raise existing_message() + if local and not Path(destination["path"]).parent.is_dir(): + raise Failure(f"{Path(destination['path']).parent} does not exist.") + recovery = create_repository(config, repository) + write_json(context.config_path, config) + if previous: + keyring_clear(previous["keyId"]) + save_state(context, {}) + return config, recovery + + +def rotate_recovery_key(context): + config = require_config(context) + repository = Restic(config) + password = keyring_password(config["keyId"], interactive=True) + recovery = new_recovery_key() + status, _, stderr = repository.run(["key", "add"], password, new_password=recovery) + if status != 0: + raise Failure(repository.explain(status, stderr)) + new_id = key_ids(repository, recovery) + old_id = config.get("recoveryKeyId") + if old_id and old_id != new_id: + status, _, stderr = repository.run(["key", "remove", old_id], password) + if status != 0: + raise Failure("The new recovery key works, but the old one could not be removed: " + + repository.explain(status, stderr)) + config["recoveryKeyId"] = new_id + config["recoveryKeyConfirmed"] = False + write_json(context.config_path, config) + return recovery + + +# ---- Backing up ------------------------------------------------------------- + +def due(state, now): + last = parse_time(state.get("lastSuccess")) + return last is None or now - last >= DUE_AFTER + + +def next_due(config, state, now): + if not config.get("automatic", True): + return None + last = parse_time(state.get("lastSuccess")) + return now if last is None else max(now, last + DUE_AFTER) + + +def reminder_due(config, state, now): + """A week without a finished backup, reminded at most once a day.""" + since = parse_time(state.get("lastSuccess")) or parse_time(config.get("created")) + if since is None or now - since < REMIND_AFTER: + return False + return state.get("lastReminder") != now.astimezone().date().isoformat() + + +def reminder_text(config, state, now, reason=""): + last = parse_time(state.get("lastSuccess")) + if last is None: + since = parse_time(config.get("created")) or now + days = max(7, (now - since).days) + text = f"No backup has finished since you set up backups {days} days ago." + else: + days = max(7, (now - last).days) + text = f"Your last backup was {days} days ago." + if not config.get("automatic", True): + return text + " Automatic backups are off." + if reason: + return text + " " + reason + return text + + +def failure_notice_due(state, message, now): + """One notification per distinct failure and day; the hourly retries of + a failing backup must not repeat it.""" + notice = state.get("failureNotice") or {} + return not (notice.get("date") == now.astimezone().date().isoformat() + and notice.get("message") == message) + + +def run_backup(context, config, password, echo=False): + """Back up and, at most weekly, apply retention. Returns a summary line.""" + repository = Restic(config) + progress = Progress(context, "backup", echo=echo) + status, _, stderr = repository.run(backup_arguments(context, config), password, + on_line=progress) + progress.finish() + # 3: a snapshot can be saved, but files/directories were omitted. A denied + # directory can hide any number of files; count reported read errors only. + if status not in (0, 3): + raise Failure(repository.explain(status, stderr)) + summary = progress.summary or {} + now = utcnow() + incomplete = status == 3 or progress.error_count > 0 + errors = list(progress.errors) + if incomplete: + if progress.error_count: + detail = (f"{progress.error_count} read error(s) were reported. " + "Files or directories were omitted; the total number of missing files is unknown.") + else: + detail = "Some files or directories could not be read; the unreadable count is unknown." + if stderr.strip(): + errors = [stderr.strip()] + message = "Backup incomplete. " + detail + if summary.get("snapshot_id"): + message += " A partial snapshot was saved; its readable files can be restored." + else: + message += " Check the available snapshots for saved files." + if errors: + message += " Restic: " + errors[0] + else: + message = "" + state = update_state(context, lastAttempt=stamp(now), + lastResult="partial" if incomplete else "ok", lastError=message, lastSkip="", + lastSnapshot=summary.get("snapshot_id", ""), + lastSummary={"files": summary.get("total_files_processed", 0), + "bytes": summary.get("total_bytes_processed", 0), + "added": summary.get("data_added", 0), + "unreadable": (progress.error_count or None) if incomplete else 0, + "errors": errors}) + if incomplete: + # Preserve the last complete success and all snapshots. Do not apply + # retention after an incomplete run or suppress the next scheduled retry. + raise Failure(message, result="partial") + update_state(context, lastSuccess=stamp(now)) + line = (f"Backed up {summary.get('total_files_processed', 0):,} files " + f"({human_size(summary.get('data_added', 0))} new) to {config['destination']['display']}.") + last_maintenance = parse_time(state.get("lastMaintenance")) + if last_maintenance is None or now - last_maintenance >= MAINTAIN_AFTER: + maintenance = Progress(context, "cleanup") + status, _, stderr = repository.run(forget_arguments(config), password) + if status == 0: + status, _, stderr = repository.run(["check"], password) + maintenance.finish() + if status != 0: + raise Failure("The backup finished, but cleaning up old backups failed: " + + repository.explain(status, stderr)) + update_state(context, lastMaintenance=stamp(utcnow())) + return line + + +def record_failure(context, message, notify_user, now=None, *, result="failed"): + now = now or utcnow() + state = update_state(context, lastAttempt=stamp(now), lastResult=result, lastError=message) + if notify_user and failure_notice_due(state, message, now): + update_state(context, failureNotice={"date": now.astimezone().date().isoformat(), + "message": message}) + return True + return False + + +def back_up_now(context, notify_user): + """A backup the user asked for: no schedule, battery or network rules.""" + config = require_config(context) + with exclusive(context): + try: + reachable, reason = destination_reachable(config["destination"]) + if reachable is False: + raise Failure(reason) + password = keyring_password(config["keyId"], interactive=True) + return run_backup(context, config, password, echo=sys.stderr.isatty()) + except Failure as error: + failure = error + record_failure(context, error.message, False, result=error.result) + # Notified after the lock is released, so Settings already shows the result. + if notify_user: + notify(context, "Backup incomplete" if failure.result == "partial" else "Backup failed", + failure.message, "normal" if failure.result == "partial" else "critical") + raise failure + + +def skip_reason(config): + """Why a due scheduled backup waits: (skip code, words for a reminder).""" + destination = config["destination"] + reachable, reason = destination_reachable(destination) + if reachable is False: + if destination.get("label") and not os.path.ismount(destination.get("mount") or "/"): + reason = f"Connect {destination['label']} to back up." + return "unreachable", reason + if low_battery(): + return "battery", "" + if destination["type"] == "sftp": + if metered_network(): + return "metered", "" + if remote_reachable(destination["host"]) is False: + return "unreachable", f"{destination['host']} could not be reached." + return "", "" + + +def scheduled(context, now=None): + """The timer's hourly check. Quiet unless something needs the user.""" + config = load_config(context) + if config is None: + return EXIT_OK + now = now or utcnow() + notice = None + reason = "" + try: + with exclusive(context): + if config.get("automatic", True) and due(load_state(context), now): + skip, reason = skip_reason(config) + if not skip: + try: + password = keyring_password(config["keyId"], interactive=False) + run_backup(context, config, password) + except KeyringLocked: + skip, reason = "keyring", "Unlock the keyring to back up." + except Failure as error: + if record_failure(context, error.message, True, now, result=error.result): + notice = (("Backup incomplete", error.message, "normal") + if error.result == "partial" else + ("Backup failed", error.message, "critical")) + if skip: + update_state(context, lastSkip=skip, lastSkipAt=stamp(now)) + state = load_state(context) + if notice is None and reminder_due(config, state, now): + update_state(context, lastReminder=now.astimezone().date().isoformat()) + notice = ("Back up your files", reminder_text(config, state, now, reason), "normal") + except Failure as error: + if error.status != EXIT_BUSY: + raise + return EXIT_OK + if notice: + notify(context, *notice) + return EXIT_OK + + +# ---- Status ----------------------------------------------------------------- + +def progress_info(context): + if not busy(context): + return None + progress = read_json(context.progress_path, {}) + return {"operation": progress.get("operation", "backup"), + "percent": progress.get("percent", 0), + "started": progress.get("started", "")} + + +def media_root(): + """Where Settings starts browsing for a backup drive.""" + user = getpass.getuser() + for candidate in (f"/run/media/{user}", "/media", "/mnt"): + if os.path.isdir(candidate): + return candidate + return "/" + + +def status_report(context, now=None): + now = now or utcnow() + config = load_config(context) + state = load_state(context) + progress = progress_info(context) + report = {"configured": config is not None, "running": progress is not None, + "progress": progress, "lastRestore": state.get("lastRestore"), + "mediaRoot": media_root(), "coverage": BACKUP_COVERAGE, + "consistency": BACKUP_CONSISTENCY} + if config is None: + return report + reachable, reason = destination_reachable(config["destination"]) + upcoming = next_due(config, state, now) + report.update({ + "destination": {key: config["destination"].get(key, "") + for key in ("type", "display", "label", "path", "host")}, + "automatic": bool(config.get("automatic", True)), + "reachable": reachable, + "unreachableReason": reason, + "recoveryKeyConfirmed": bool(config.get("recoveryKeyConfirmed")), + "created": config.get("created", ""), + "lastSuccess": state.get("lastSuccess", ""), + "lastAttempt": state.get("lastAttempt", ""), + "lastResult": state.get("lastResult", ""), + "lastError": state.get("lastError", ""), + "lastSkip": state.get("lastSkip", ""), + "lastSummary": state.get("lastSummary"), + "nextDue": stamp(upcoming) if upcoming else "", + "stale": bool(config and reminder_due(config, {**state, "lastReminder": ""}, now)), + }) + return report + + +def print_status(report): + print(report["coverage"]) + print(report["consistency"]) + if not report["configured"]: + print("Backups are not set up. Run: nerd backup setup local PATH, or " + "nerd backup setup sftp HOST [PATH].") + return + destination = report["destination"] + print("Destination: " + destination["display"] + + ("" if report["reachable"] is not False else " (" + report["unreachableReason"] + ")")) + print("Automatic: " + ("daily" if report["automatic"] else "off")) + last = parse_time(report["lastSuccess"]) + print("Last complete backup: " + (last.astimezone().strftime("%Y-%m-%d %H:%M") if last else "never")) + if report["lastResult"] in ("failed", "partial"): + print("Last attempt: " + report["lastResult"] + ": " + report["lastError"]) + if report["running"]: + progress = report["progress"] + print(f"Running: {progress['operation']} {progress['percent'] * 100:.0f} %") + upcoming = parse_time(report["nextDue"]) + if upcoming: + print("Next backup: after " + upcoming.astimezone().strftime("%Y-%m-%d %H:%M")) + if not report["recoveryKeyConfirmed"]: + print("Recovery key: not saved. Run nerd backup recovery-key to make a new one.") + + +# ---- Restoring -------------------------------------------------------------- + +def snapshot_list(context, config, password): + repository = Restic(config) + status, lines, stderr = repository.run(["snapshots", "--json", "--tag", TAG], password) + if status != 0: + raise Failure(repository.explain(status, stderr)) + try: + snapshots = json.loads("\n".join(lines) or "[]") + except ValueError as error: + raise Failure("restic listed the backups in an unexpected form.") from error + result = [] + for snapshot in snapshots if isinstance(snapshots, list) else []: + if not isinstance(snapshot, dict) or not SNAPSHOT_PATTERN.fullmatch(snapshot.get("id", "")): + continue + paths = snapshot.get("paths") or [] + summary = snapshot.get("summary") or {} + result.append({"id": snapshot["id"], "time": snapshot.get("time", ""), + "host": snapshot.get("hostname", ""), + "path": paths[0] if paths else "", + "files": summary.get("total_files_processed", 0), + "bytes": summary.get("total_bytes_processed", 0)}) + result.sort(key=lambda entry: parse_time(entry["time"]) or datetime.min.replace( + tzinfo=timezone.utc), reverse=True) + return result + + +def restore_path(text): + """A path inside the backed-up home folder, relative to it.""" + if not text: + return "" + if "\n" in text or "\0" in text or any(character in text for character in "*?[\\"): + raise Failure("Enter a plain path inside your home folder, such as Documents/Taxes.", + EXIT_USAGE) + text = text.strip() + if text == "~" or text.startswith("~/"): + text = text[2:] + parts = [part for part in Path(text).parts if part not in ("/", ".")] + if ".." in parts: + raise Failure("Enter a path inside your home folder.", EXIT_USAGE) + return "/".join(parts) + + +def restore_target(context, moment): + """A folder that did not exist before, under ~/Restored, named by when the + backup was made. Created here, so a restore can never land in place.""" + parent = context.home / "Restored" + try: + parent.mkdir(mode=0o700) + except FileExistsError: + pass + if parent.is_symlink() or not parent.is_dir(): + raise Failure(f"{parent} must be a folder.") + name = moment.astimezone().strftime("%Y-%m-%d-%H%M") + for attempt in range(1, 100): + target = parent / (name if attempt == 1 else f"{name}-{attempt}") + try: + target.mkdir(mode=0o700) + except FileExistsError: + continue + return target + raise Failure("Too many restored folders share this name. Tidy up ~/Restored.") + + +def open_folder(path): + with contextlib.suppress(OSError): + subprocess.Popen(["xdg-open", str(path)], stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + + +def restore(context, snapshot_id, relative, open_files, notify_user): + config = require_config(context) + if not SNAPSHOT_PATTERN.fullmatch(snapshot_id or ""): + raise Failure("Choose a backup to restore.", EXIT_USAGE) + relative = restore_path(relative) + try: + with exclusive(context): + reachable, reason = destination_reachable(config["destination"]) + if reachable is False: + raise Failure(reason) + password = keyring_password(config["keyId"], interactive=True) + snapshots = snapshot_list(context, config, password) + if snapshot_id == "latest": + own = [entry for entry in snapshots if entry["host"] == config["host"]] + chosen = (own or snapshots or [None])[0] + else: + chosen = next((entry for entry in snapshots + if entry["id"].startswith(snapshot_id)), None) + if chosen is None or not chosen["path"].startswith("/"): + raise Failure("That backup was not found.") + if not relative: + free = shutil.disk_usage(context.home).free + if chosen["bytes"] and chosen["bytes"] > free: + raise Failure(f"Restoring everything needs {human_size(chosen['bytes'])}, " + f"but only {human_size(free)} is free. Restore a folder instead.") + moment = parse_time(chosen["time"]) or utcnow() + target = restore_target(context, moment) + arguments = ["restore", chosen["id"] + ":" + chosen["path"], "--target", str(target), + "--overwrite", "never", "--json"] + if relative: + arguments += ["--include", "/" + relative] + progress = Progress(context, "restore", echo=sys.stderr.isatty()) + repository = Restic(config) + status, _, stderr = repository.run(arguments, password, on_line=progress) + progress.finish() + if status != 0: + with contextlib.suppress(OSError): + target.rmdir() + raise Failure(repository.explain(status, stderr).replace("backup failed", + "restore failed")) + restored = (progress.summary or {}).get("total_files", 0) + if progress.summary is not None and not restored: + with contextlib.suppress(OSError): + target.rmdir() + raise Failure(f"Nothing at {relative or 'that path'} is in that backup.") + update_state(context, lastRestore={"time": stamp(utcnow()), "ok": True, + "target": str(target), "error": ""}) + except Failure as error: + if error.status != EXIT_BUSY: + update_state(context, lastRestore={"time": stamp(utcnow()), "ok": False, + "target": "", "error": error.message}) + if notify_user: + notify(context, "Restore failed", error.message, "critical") + raise + shown = target + if relative and (target / relative).exists(): + shown = target / relative if (target / relative).is_dir() else (target / relative).parent + if open_files: + open_folder(shown) + return shown + + +# ---- Compatibility: app-backup --------------------------------------------- + +def browser_profiles(context, output, environ): + """The earlier app-backup: an age-encrypted tar of the browser profiles.""" + recipient = environ.get("AGE_RECIPIENT", "") + if not recipient: + print("Set AGE_RECIPIENT to an age public recipient.", file=sys.stderr) + return 2 + if not shutil.which("age"): + print("age is required", file=sys.stderr) + return 1 + home = context.home + output = output or str(home / "Downloads" / time.strftime("browser-profiles-%Y%m%d-%H%M%S.tar.age")) + paths = [path for path in BROWSER_PROFILES if os.path.lexists(home / path)] + if not paths: + print("No browser profiles found", file=sys.stderr) + return 1 + previous = os.umask(0o077) + try: + tar = subprocess.Popen(["tar", "-C", str(home), "-cf", "-", *paths], stdout=subprocess.PIPE) + age = subprocess.run(["age", "-r", recipient, "-o", output], stdin=tar.stdout, check=False) + tar.stdout.close() + if tar.wait() != 0 or age.returncode != 0: + return 1 + finally: + os.umask(previous) + print("Encrypted opt-in browser backup: " + output) + return 0 + + +# ---- Command line ----------------------------------------------------------- + +def parser(): + parser = argparse.ArgumentParser( + prog="nerd backup", description="Encrypted backups of your home folder.") + commands = parser.add_subparsers(dest="command") + setup_command = commands.add_parser("setup", help="choose where backups go") + setup_command.add_argument("kind", choices=("local", "sftp")) + setup_command.add_argument("target", help="a folder, or an SSH alias or user@host") + setup_command.add_argument("path", nargs="?", help="the folder on the SSH server") + setup_command.add_argument("--existing", action="store_true", + help="connect backups already there; the recovery key is read " + "from standard input") + setup_command.add_argument("--json", action="store_true") + commands.add_parser("confirm-key", help="record that the recovery key is saved") + key_command = commands.add_parser("recovery-key", help="replace the recovery key") + key_command.add_argument("--json", action="store_true") + now_command = commands.add_parser("now", help="back up now") + now_command.add_argument("--notify", action="store_true", help="report a failure as a notification") + commands.add_parser("scheduled", help="the timer's daily backup and weekly reminder") + status_command = commands.add_parser("status", help="show the last and next backup") + status_command.add_argument("--json", action="store_true") + snapshots_command = commands.add_parser("snapshots", help="list the backups") + snapshots_command.add_argument("--json", action="store_true") + restore_command = commands.add_parser("restore", help="restore into a new folder in ~/Restored") + restore_command.add_argument("snapshot", nargs="?", default="latest") + restore_command.add_argument("--path", default="", help="a file or folder inside your home folder") + restore_command.add_argument("--open", action="store_true", help="open the restored folder in Files") + restore_command.add_argument("--notify", action="store_true") + automatic_command = commands.add_parser("automatic", help="turn daily backups on or off") + automatic_command.add_argument("state", choices=("on", "off")) + commands.add_parser("excludes", help="list what backups leave out") + commands.add_parser("browser-profiles", help="the earlier app-backup browser export") \ + .add_argument("output", nargs="?") + return parser + + +def read_recovery_key(): + if sys.stdin.isatty(): + return getpass.getpass("Recovery key: ") + # One line: Settings keeps the pipe open after writing it. + return sys.stdin.readline() + + +def emit(arguments, data, text): + if getattr(arguments, "json", False): + print(json.dumps(data)) + elif text: + print(text) + + +def main(argv=None, environ=None, program=None): + environ = os.environ if environ is None else environ + argv = sys.argv[1:] if argv is None else argv + context = Context(environ) + if Path(program or sys.argv[0]).name in ("app-backup", "browser-backup"): + return browser_profiles(context, argv[0] if argv else "", environ) + arguments = parser().parse_args(argv or ["status"]) + try: + if arguments.command == "setup": + config, recovery = setup(context, arguments, read_recovery_key) + start_timer() + if getattr(arguments, "json", False): + print(json.dumps({"ok": True, "destination": config["destination"]["display"], + "recoveryKey": recovery or "", "coverage": BACKUP_COVERAGE, + "consistency": BACKUP_CONSISTENCY})) + elif recovery: + print(f"Backups go to {config['destination']['display']}.\n\nRecovery key:\n\n" + f" {recovery}\n\nSave it somewhere safe, such as a password manager. It " + "opens these backups on any computer.\nIf you lose both this computer's " + "keyring and this key, the backups cannot be opened.\nThen run: " + "nerd backup confirm-key") + else: + print(f"Connected the backups at {config['destination']['display']}.") + if not getattr(arguments, "json", False): + print(BACKUP_COVERAGE) + print(BACKUP_CONSISTENCY) + elif arguments.command == "confirm-key": + config = require_config(context) + config["recoveryKeyConfirmed"] = True + write_json(context.config_path, config) + elif arguments.command == "recovery-key": + recovery = rotate_recovery_key(context) + emit(arguments, {"ok": True, "recoveryKey": recovery}, + f"New recovery key:\n\n {recovery}\n\nThe previous key no longer opens these " + "backups. Save this one, then run: nerd backup confirm-key") + elif arguments.command == "now": + print(back_up_now(context, arguments.notify)) + elif arguments.command == "scheduled": + return scheduled(context) + elif arguments.command == "status": + report = status_report(context) + if arguments.json: + print(json.dumps(report)) + else: + print_status(report) + elif arguments.command == "snapshots": + config = require_config(context) + password = keyring_password(config["keyId"], interactive=True) + snapshots = snapshot_list(context, config, password) + if arguments.json: + print(json.dumps({"snapshots": snapshots})) + for entry in [] if arguments.json else snapshots: + moment = parse_time(entry["time"]) + when = moment.astimezone().strftime("%Y-%m-%d %H:%M") if moment else entry["time"] + print(f"{entry['id'][:8]} {when} {entry['host']} {human_size(entry['bytes'])}") + elif arguments.command == "restore": + shown = restore(context, arguments.snapshot, arguments.path, arguments.open, + arguments.notify) + print(f"Restored to {shown}") + elif arguments.command == "automatic": + config = require_config(context) + config["automatic"] = arguments.state == "on" + write_json(context.config_path, config) + if config["automatic"]: + start_timer() + elif arguments.command == "excludes": + print(BACKUP_COVERAGE) + print(BACKUP_CONSISTENCY) + config = load_config(context) or {"destination": {}} + arguments_list = exclude_arguments(context, config) + for flag, value in zip(arguments_list, arguments_list[1:]): + if flag in ("--exclude", "--exclude-file"): + print(value if flag == "--exclude" else "patterns in " + value) + elif arguments.command == "browser-profiles": + return browser_profiles(context, arguments.output or "", environ) + except Failure as error: + if getattr(arguments, "json", False): + print(json.dumps({"ok": False, "error": error.message, + "existing": error.status == EXIT_EXISTING})) + print("nerdos-backup: " + error.message, file=sys.stderr) + return error.status + return EXIT_OK + + +if __name__ == "__main__": + # A stopped unit or session ends a run through the cleanup paths. + signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) + sys.exit(main()) diff --git a/assets/scripts/nerdos-firmware-update b/assets/scripts/nerdos-firmware-update new file mode 100755 index 00000000..ea3045fe --- /dev/null +++ b/assets/scripts/nerdos-firmware-update @@ -0,0 +1,696 @@ +#!/usr/bin/python3 -I +"""Inspect and install fwupd device firmware independently of Nix generations. + +The desktop opens `interactive` in a terminal. It refreshes firmware metadata, +shows the exact device/release plan and requires confirmation before installing. +fwupd handles authorization and verifies publisher signatures. Firmware changes +are not reverted by a Nix generation rollback. No command reboots the machine. + + nerdos-firmware-update plan + nerdos-firmware-update interactive + nerdos-firmware-update install [--events PATH] + +`plan` is read-only and prints {"devices": [...]}. `install` appends one +compact JSON object per line to PATH, flushed as it happens, and writes +human-readable progress to stdout for the run log. It always ends the event +stream with exactly one `summary`. Exit status: 0 when nothing failed +(including nothing to do), 1 when any device failed, 2 when fwupd could not +be used at all. + +The Nix package wraps Python and PyGObject with their native fwupd typelibs; +the isolated interpreter never imports packages from the user's site directory. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys + +EXIT_OK = 0 +EXIT_FAILED = 1 +EXIT_SETUP = 2 + +CONNECT_TIMEOUT_SECONDS = 30 +USER_AGENT = ("nerdos-firmware-update", "1") + +# fwupd only sends a request with its own text when it is device-specific; a +# generic request expects the client to phrase it, as fwupdmgr does. +REQUEST_MESSAGES = { + "org.freedesktop.fwupd.request.remove-replug": "Unplug the device and plug it back in.", + "org.freedesktop.fwupd.request.insert-usb-cable": "Plug the device's USB cable back in.", + "org.freedesktop.fwupd.request.remove-usb-cable": "Unplug the device's USB cable.", + "org.freedesktop.fwupd.request.press-unlock": "Press the unlock button on the device.", + "org.freedesktop.fwupd.replug-power": "Unplug the device's power and plug it back in.", + "org.freedesktop.fwupd.replug-install": + "Unplug the device and plug it back in to finish installing.", + "org.freedesktop.fwupd.request.do-not-power-off": + "Do not turn off the computer or unplug the device until the update finishes.", +} +DEFAULT_REQUEST_MESSAGE = "Follow the instructions on the device." + +FAILURE_MESSAGES = { + "ac-power": "Connect the power adapter to install this firmware.", + "battery": "Charge the battery before installing this firmware.", + "auth": "The firmware update was not authorized.", + "network": "Couldn't download the firmware. Check the network connection.", + "signature": "The downloaded firmware failed verification.", + "busy": "Another firmware update is in progress. Try again when it finishes.", + "user-action": "The device needs your attention before it can be updated.", + "other": "The firmware update failed.", +} +UNAVAILABLE_MESSAGE = "The firmware update service (fwupd) is not available." +MISSING_BINDINGS_MESSAGE = "The firmware updater cannot load fwupd's Python bindings." +ALREADY_PENDING_MESSAGE = "Already scheduled for the next restart." + +# FwupdError members this helper distinguishes, by name, so the numeric codes +# always come from the installed libfwupd. +FWUPD_ERROR_NAMES = ( + "AC_POWER_REQUIRED", "ALREADY_PENDING", "AUTH_EXPIRED", "AUTH_FAILED", + "BATTERY_LEVEL_TOO_LOW", "BUSY", "INTERNAL", "INVALID_FILE", + "NEEDS_USER_ACTION", "NOTHING_TO_DO", "NOT_FOUND", "NOT_REACHABLE", + "NOT_SUPPORTED", "PERMISSION_DENIED", "READ", "SIGNATURE_INVALID", + "TIMED_OUT", "VERSION_NEWER", "VERSION_SAME", +) +# Only these GIO errors mean the download could not reach its server. A +# closed D-Bus connection (a crashed daemon) is also a GIO error, but blaming +# the network for it would send the user to the wrong place. +NETWORK_IO_ERROR_NAMES = ( + "CONNECTION_CLOSED", "CONNECTION_REFUSED", "HOST_NOT_FOUND", + "HOST_UNREACHABLE", "NETWORK_UNREACHABLE", "NOT_CONNECTED", + "PROXY_AUTH_FAILED", "PROXY_FAILED", "PROXY_NEED_AUTH", "PROXY_NOT_ALLOWED", + "TIMED_OUT", +) +# get_upgrades() reports "no upgrade" as an error; these are all ordinary. +NO_UPGRADE_ERRORS = ("NOTHING_TO_DO", "NOT_FOUND", "NOT_SUPPORTED", + "VERSION_NEWER", "VERSION_SAME") + +STATUS_WORDS = { + "decompressing": "decompressing", + "device-busy": "waiting for the device", + "device-erase": "erasing", + "device-read": "reading", + "device-restart": "restarting the device", + "device-verify": "verifying", + "device-write": "writing", + "downloading": "downloading", + "loading": "loading", + "scheduling": "scheduling for the next restart", + "shutdown": "shutting down", + "waiting-for-auth": "waiting for authorization", + "waiting-for-user": "waiting for you", +} + + +class SetupError(Exception): + """fwupd cannot be used at all; nothing was attempted.""" + + +class Api: + """The parts of gi.repository this helper uses, resolved once. + + Keeping them on one object lets the tests substitute a fake libfwupd and + lets every lookup degrade instead of fail on an older or newer typelib. + """ + + def __init__(self, fwupd, glib, gio=None): + self.fwupd = fwupd + self.glib = glib + self.error_domain = quark_name(glib, getattr(fwupd, "error_quark", None), "FwupdError") + self.io_domain = quark_name(glib, getattr(gio, "io_error_quark", None), + "g-io-error-quark") + self.resolver_domain = quark_name(glib, getattr(gio, "resolver_error_quark", None), + "g-resolver-error-quark") + self.error_names = {} + for name in FWUPD_ERROR_NAMES: + member = getattr(fwupd.Error, name, None) + if member is not None: + self.error_names[int(member)] = name + io_errors = getattr(gio, "IOErrorEnum", None) + self.network_io_codes = { + int(getattr(io_errors, name)) for name in NETWORK_IO_ERROR_NAMES + if io_errors is not None and getattr(io_errors, name, None) is not None + } + + def fwupd_error_name(self, error): + if getattr(error, "domain", None) != self.error_domain: + return None + return self.error_names.get(getattr(error, "code", None)) + + def is_network_error(self, error): + domain = getattr(error, "domain", None) + return domain == self.resolver_domain or ( + domain == self.io_domain and getattr(error, "code", None) in self.network_io_codes) + + def feature_flags(self): + flags = 0 + for name in ("REQUESTS", "REQUESTS_NON_GENERIC", "UPDATE_ACTION", + "DETACH_ACTION", "FDE_WARNING"): + member = getattr(self.fwupd.FeatureFlags, name, None) + if member is not None: + flags = member if not flags else flags | member + return flags + + def status_name(self, status): + """fwupd's own status string (downloading, device-write, ...), or None.""" + try: + return self.fwupd.status_to_string(status) or None + except (TypeError, ValueError): + return None + + def request_kind(self, kind): + kinds = self.fwupd.RequestKind + if getattr(kinds, "POST", None) is not None and kind == kinds.POST: + return "post" + # IMMEDIATE, and an unknown kind that fwupd sends mid-update. + return "immediate" + + +def quark_name(glib, quark_function, fallback): + """PyGObject reports GLib.Error.domain as the quark's string.""" + if quark_function is None: + return fallback + try: + name = glib.quark_to_string(quark_function()) + except (AttributeError, TypeError, ValueError): + return fallback + return name or fallback + + +def load_api(): + """Import libfwupd lazily so the logic stays importable without PyGObject.""" + try: + import gi + + gi.require_version("Fwupd", "2.0") + from gi.repository import Fwupd, Gio, GLib + except Exception as error: # ImportError, or a missing/mismatched typelib. + raise SetupError(MISSING_BINDINGS_MESSAGE) from error + return Api(Fwupd, GLib, Gio) + + +def first_line(text, fallback=""): + """One trimmed line of fwupd's message, without a D-Bus error prefix.""" + for line in str(text or "").splitlines(): + line = re.sub(r"^GDBus\.Error:[\w.-]+:\s*", "", line.strip()) + if line: + return line + return fallback + + +def error_message(error): + return first_line(getattr(error, "message", None) or str(error)) + + +def device_flag_names(api, device): + """Return fwupd's names for the device's flags. + + Introspection truncates FwupdDeviceFlags to 32 bits, which loses newer + flags such as affects-fde, so the device's own JSON export (which names + all 64) is authoritative. The enum members fill in if that export fails. + """ + names = set() + try: + exported = json.loads(device.to_json_string(api.fwupd.CodecFlags.NONE)) + names.update(flag for flag in exported.get("Flags", []) if isinstance(flag, str)) + except Exception: # Best effort: the enum members below still apply. + pass + for name in ("updatable", "needs-reboot", "needs-shutdown", "affects-fde"): + member = getattr(api.fwupd.DeviceFlags, name.upper().replace("-", "_"), None) + try: + if member is not None and device.has_flag(member): + names.add(name) + except (TypeError, ValueError, OverflowError): + pass + return names + + +class Planned: + """One device, the release it will receive, and its `plan` event entry.""" + + def __init__(self, device, release, info): + self.device = device + self.release = release + self.info = info + + @property + def id(self): + return self.info["id"] + + @property + def needs_reboot(self): + return self.info["needsReboot"] + + @property + def label(self): + versions = " → ".join(value for value in (self.info["from"], self.info["to"]) if value) + return f"{self.info['name']} {versions}".strip() + + +def clean_text(value): + return str(value or "").strip() + + +def plan_entry(api, device, release): + flags = device_flag_names(api, device) + return Planned(device, release, { + "id": clean_text(device.get_id()), + "name": clean_text(device.get_name()) or "Unknown device", + "vendor": clean_text(device.get_vendor()), + # fwupd reports some versions with padding ("HPS0050 "). + "from": clean_text(device.get_version()), + "to": clean_text(release.get_version()), + # A capsule or other deferred update is staged now and applied by + # the next restart (or full shutdown); the panel must say so. + "needsReboot": bool({"needs-reboot", "needs-shutdown"} & flags), + "affectsFde": "affects-fde" in flags, + }) + + +def build_plan(api, client, warn=None): + """What `fwupdmgr update` would install: the newest upgrade per device.""" + try: + devices = client.get_devices(None) + except api.glib.Error as error: + if api.fwupd_error_name(error) == "NOTHING_TO_DO": + return [] + raise SetupError(UNAVAILABLE_MESSAGE) from error + plan = [] + for device in devices or []: + if "updatable" not in device_flag_names(api, device): + continue + try: + releases = client.get_upgrades(device.get_id(), None) + except api.glib.Error as error: + if warn is not None and api.fwupd_error_name(error) not in NO_UPGRADE_ERRORS: + warn(f"{device.get_name() or device.get_id()}: {error_message(error)}") + continue + if releases: + plan.append(plan_entry(api, device, releases[0])) + return plan + + +def run_main_loop_until(api, start, timeout=None): + """Run START(done) in a GLib main loop until it calls done(error). + + Returns the error (None on success). A fake client may complete + synchronously, in which case the loop never runs. + """ + loop = api.glib.MainLoop() + state = {"done": False, "error": None, "timer": None} + + def done(error=None): + if state["done"]: + return + state["done"] = True + state["error"] = error + loop.quit() + + def expired(): + state["timer"] = None + done(TimeoutError("fwupd did not respond")) + return False + + try: + start(done) + except Exception as error: # Reported as this step's failure. + done(error) + if not state["done"]: + if timeout is not None: + state["timer"] = api.glib.timeout_add_seconds(timeout, expired) + loop.run() + if state["timer"] is not None: + api.glib.source_remove(state["timer"]) + return state["error"] + + +def open_client(api): + """Connect to the daemon with the same client features as fwupdmgr. + + The daemon filters releases by client features (a release can require a + client that shows detach actions), so `plan` and `install` must declare + the same set. + """ + try: + client = api.fwupd.Client.new() + except Exception as error: + raise SetupError(UNAVAILABLE_MESSAGE) from error + + def start(done): + def connected(source, result, *_): + try: + source.connect_finish(result) + except Exception as error: + done(error) + else: + done() + + # Fwupd.Client.connect is shadowed by GObject's signal connect. + client.connect_async(None, connected) + + if run_main_loop_until(api, start, CONNECT_TIMEOUT_SECONDS) is not None: + raise SetupError(UNAVAILABLE_MESSAGE) + try: + client.set_feature_flags(api.feature_flags(), None) + except api.glib.Error as error: + raise SetupError(UNAVAILABLE_MESSAGE) from error + if hasattr(client, "set_user_agent_for_package"): + # libfwupd refuses to download without a user agent. + client.set_user_agent_for_package(*USER_AGENT) + return client + + +def classify_error(api, error): + """Map an install error to the event that reports it: (event, fields).""" + message = error_message(error) + name = api.fwupd_error_name(error) + + def failed(reason, text=None): + return "failed", {"reason": reason, "message": text or FAILURE_MESSAGES[reason]} + + if name == "AC_POWER_REQUIRED": + return failed("ac-power") + if name == "BATTERY_LEVEL_TOO_LOW": + return failed("battery") + if name in ("PERMISSION_DENIED", "AUTH_FAILED", "AUTH_EXPIRED"): + return failed("auth") + if name == "BUSY": + return failed("busy") + if name == "NEEDS_USER_ACTION": + return failed("user-action", message) + if name in ("NOTHING_TO_DO", "VERSION_SAME", "VERSION_NEWER"): + return "skipped", {"message": message or "This firmware is already installed."} + if name == "ALREADY_PENDING": + return "installed", {"needsReboot": True, "message": ALREADY_PENDING_MESSAGE} + if name in ("NOT_REACHABLE", "TIMED_OUT") or api.is_network_error(error): + return failed("network") + # libfwupd downloads in the client and reports a failed transfer as an + # invalid file ("Failed to download ..."); that is not a bad signature. + if name in ("INVALID_FILE", "INTERNAL", "NOT_FOUND", "READ") \ + and re.search(r"\bdownload\b", message, re.IGNORECASE): + return failed("network") + if name in ("SIGNATURE_INVALID", "INVALID_FILE"): + return failed("signature") + return failed("other", message) + + +class EventLog: + """Append-only JSON-lines sink; without a path, events go nowhere.""" + + def __init__(self, path=None): + # The worker pre-creates the file as the update owner so the panel can + # read it unprivileged; appending as root keeps that ownership. + self._stream = open(path, "a", encoding="utf-8") if path else None + + def emit(self, event, **fields): + record = {"event": event, **fields} + if self._stream is not None: + # ASCII-only output keeps every byte offset a character boundary + # for the panel's windowed `read-log` reads. + self._stream.write(json.dumps(record, separators=(",", ":")) + "\n") + self._stream.flush() + + def close(self): + if self._stream is not None: + self._stream.close() + self._stream = None + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + +class HumanOutput: + def __init__(self, stream): + self.stream = stream + + def line(self, text): + # The worker reads stdout through a pipe; flush every line so the + # run log follows the update live. + self.stream.write(text + "\n") + self.stream.flush() + + +def percentage(value): + """A 0-100 percentage, or None: fwupd reports "unknown" as G_MAXUINT.""" + try: + value = int(value) + except (TypeError, ValueError): + return None + return value if 0 <= value <= 100 else None + + +class ProgressFilter: + """Emit a progress event only when the status or percentage changes. + + An unknown status or percentage keeps the last known value. + """ + + def __init__(self): + self.reset() + + def reset(self): + self.status = None + self.percent = None + + def observe(self, status, percent): + status = status or self.status + if percent is None: + percent = self.percent if self.percent is not None else 0 + if status is None: + return None + if (status, percent) == (self.status, self.percent): + return None + self.status, self.percent = status, percent + return status, percent + + +class HumanProgress: + """Throttle stdout to status changes and every tenth percent.""" + + def __init__(self): + self.reset() + + def reset(self): + self.status = None + self.bucket = None + + def observe(self, label, status, percent): + if status == "idle": + return None + bucket = percent // 10 + if (status, bucket) == (self.status, self.bucket): + return None + self.status, self.bucket = status, bucket + words = STATUS_WORDS.get(status, status) + return f"{label}: {words} {percent}%" if percent > 0 else f"{label}: {words}" + + +def request_message(request): + message = (request.get_message() or "").strip() + return message or REQUEST_MESSAGES.get(request.get_id() or "", DEFAULT_REQUEST_MESSAGE) + + +class Installer: + """Install a plan sequentially, streaming events for each device.""" + + def __init__(self, api, client, events, out): + self.api = api + self.client = client + self.events = events + self.out = out + self.current = None + self.progress = ProgressFilter() + self.human = HumanProgress() + client.connect("notify::status", self._on_progress) + client.connect("notify::percentage", self._on_progress) + client.connect("device-request", self._on_request) + + def _on_progress(self, client, *_): + planned = self.current + if planned is None: + return + change = self.progress.observe(self.api.status_name(client.get_status()), + percentage(client.get_percentage())) + if change is None: + return + status, percent = change + self.events.emit("progress", id=planned.id, status=status, percent=percent) + text = self.human.observe(planned.label, status, percent) + if text is not None: + self.out.line(text) + + def _on_request(self, client, request, *_): + planned = self.current + if planned is None: + return + message = request_message(request) + self.events.emit("request", id=planned.id, + kind=self.api.request_kind(request.get_kind()), + requestId=request.get_id() or "", message=message) + self.out.line(f"{planned.info['name']}: {message}") + + def install(self, planned): + fwupd = self.api.fwupd + + def start(done): + def finished(source, result, *_): + try: + source.install_release_finish(result) + except Exception as error: + done(error) + else: + done() + + self.client.install_release_async( + planned.device, planned.release, fwupd.InstallFlags.NONE, + fwupd.ClientDownloadFlags.NONE, None, finished) + + # No deadline: the daemon owns the flash once it starts, and + # abandoning the client would not stop a write in progress. + return run_main_loop_until(self.api, start) + + def run(self, plan): + installed = failed = 0 + needs_reboot = False + self.events.emit("plan", devices=[planned.info for planned in plan]) + if not plan: + self.out.line("No firmware updates are available.") + try: + for index, planned in enumerate(plan, 1): + self.current = planned + self.progress.reset() + self.human.reset() + self.events.emit("device", id=planned.id, index=index, count=len(plan)) + self.out.line(f"Updating {planned.label} ({index} of {len(plan)})") + error = self.install(planned) + if error is None: + event, fields = "installed", { + "needsReboot": planned.needs_reboot, + "message": (planned.release.get_update_message() or "").strip(), + } + else: + event, fields = classify_error(self.api, error) + self.current = None + self.events.emit(event, id=planned.id, **fields) + if event == "installed": + installed += 1 + needs_reboot = needs_reboot or fields["needsReboot"] + suffix = "; restart to finish" if fields["needsReboot"] else "" + self.out.line(f"{planned.label}: installed{suffix}") + elif event == "failed": + failed += 1 + detail = error_message(error) + self.out.line(f"{planned.label}: failed: {detail or fields['message']}") + else: + self.out.line(f"{planned.label}: skipped: {fields['message']}") + except Exception as error: # A bug must still leave the panel a summary. + failed += 1 + planned_id = self.current.id if self.current is not None else "" + self.current = None + self.events.emit("failed", id=planned_id, reason="other", + message=error_message(error) or FAILURE_MESSAGES["other"]) + self.out.line(f"Firmware update stopped: {error_message(error)}") + self.events.emit("summary", installed=installed, failed=failed, needsReboot=needs_reboot) + if plan: + summary = f"Firmware: {installed} installed, {failed} failed" + self.out.line(summary + ("; restart to finish installing" if needs_reboot else "")) + return EXIT_FAILED if failed else EXIT_OK + + +def command_plan(out): + try: + api = load_api() + plan = build_plan(api, open_client(api), warn=warn) + except SetupError as error: + warn(str(error)) + return EXIT_SETUP + out.line(json.dumps({"devices": [planned.info for planned in plan]}, separators=(",", ":"))) + return EXIT_OK + + +def command_install(events_path, out, interactive=False): + try: + events = EventLog(events_path) + except OSError as error: + warn(f"cannot open {events_path}: {error.strerror or error}") + return EXIT_SETUP + with events: + try: + api = load_api() + client = open_client(api) + plan = build_plan(api, client, warn=warn) + except Exception as error: + if isinstance(error, SetupError): + reason, message = "unavailable", str(error) + else: # A bug must still leave the panel a terminal record. + warn(f"unexpected setup failure: {error!r}") + reason, message = "other", error_message(error) or FAILURE_MESSAGES["other"] + out.line(message) + events.emit("failed", id="", reason=reason, message=message) + events.emit("summary", installed=0, failed=1, needsReboot=False) + return EXIT_SETUP + if interactive and plan: + out.line("Available firmware updates:") + for planned in plan: + out.line(" " + planned.label) + out.line("Firmware cannot be rolled back with NixOS generations. Keep external power connected.") + try: + approved = input("Install these firmware updates? [y/N] ").strip().lower() in ("y", "yes") + except (EOFError, KeyboardInterrupt): + approved = False + if not approved: + out.line("Firmware installation cancelled; no firmware was installed.") + return EXIT_OK + return Installer(api, client, events, out).run(plan) + + +def command_interactive(out): + if not sys.stdin.isatty(): + out.line("Open firmware updates in a terminal to inspect and confirm the device plan.") + return EXIT_SETUP + out.line("Refreshing signed firmware metadata…") + try: + result = subprocess.run(["fwupdmgr", "refresh"], check=False) + except OSError as error: + out.line(f"Cannot refresh firmware metadata: {error}") + return EXIT_SETUP + # fwupdmgr returns 2 for an operation with nothing to do. + if result.returncode not in (0, 2): + out.line("Firmware metadata could not be refreshed. Check the network and fwupd service, then retry.") + return EXIT_SETUP + return command_install(None, out, interactive=True) + + +def warn(text): + print(f"nerdos-firmware-update: {text}", file=sys.stderr, flush=True) + + +def main(argv=None): + parser = argparse.ArgumentParser( + prog="nerdos-firmware-update", + description="Install fwupd firmware updates with structured progress.") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("plan", help="print the updates `install` would apply (read-only)") + commands.add_parser("interactive", help="refresh metadata, review and confirm firmware installation in a terminal") + install = commands.add_parser("install", help="install every available firmware update") + install.add_argument("--events", metavar="PATH", + help="append JSON-lines progress events to PATH") + args = parser.parse_args(argv) + if hasattr(sys.stdout, "reconfigure"): + # A system unit may run with a C locale; the run log is UTF-8. + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + out = HumanOutput(sys.stdout) + if args.command == "plan": + return command_plan(out) + if args.command == "interactive": + return command_interactive(out) + return command_install(args.events, out) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/assets/scripts/nerdos-report b/assets/scripts/nerdos-report new file mode 100755 index 00000000..dba7bfbb --- /dev/null +++ b/assets/scripts/nerdos-report @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Collect a redacted NerdOS diagnostic bundle for a bug report. + +nerd report gathers read-only health checks, versions, a hardware summary, recent +journal excerpts and shell health into one local archive, removes personal +data, shows what it collected, and can open a prefilled GitHub issue. It +never uploads anything: the user reviews the archive and attaches it. +""" +import argparse +import datetime +import gzip +import hashlib +import io +import ipaddress +import json +import os +from pathlib import Path +import pwd +import re +import shutil +import socket +import subprocess +import sys +import tarfile +import urllib.parse + +ISSUE_URL = 'https://github.com/DigitalPals/NerdOS/issues/new' +# GitHub accepts .gz/.tgz/.zip attachments but not .zst, so the default +# archive is a gzip tarball the user can attach directly. +DEFAULT_SUFFIX = '.tar.gz' +COMMAND_TIMEOUT = 20 +JOURNAL_LINES = 300 +# Hostnames that identify nothing; redacting them would erase "NixOS". +GENERIC_HOSTS = {'localhost', 'localhost.localdomain', 'nixos', 'nerdos', 'linux', 'host'} + + +# ---- redaction --------------------------------------------------------- + +SECRET_PATTERNS = ( + ('key', re.compile(r'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----', re.S), + '[private key]'), + ('token', re.compile(r'\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{22,})\b'), '[token]'), + ('token', re.compile(r'\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}'), '[token]'), + ('token', re.compile(r'\b(?:AKIA|ASIA)[0-9A-Z]{16}\b'), '[token]'), + ('token', re.compile(r'\bAIza[0-9A-Za-z_-]{35}\b'), '[token]'), + ('token', re.compile(r'\bxox[abposr]-[A-Za-z0-9-]{10,}'), '[token]'), + ('token', re.compile(r'\bglpat-[A-Za-z0-9_-]{20,}'), '[token]'), + ('token', re.compile(r'\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}'), '[token]'), + ('token', re.compile(r'(?i)(\bbearer\s+)[A-Za-z0-9._~+/=-]{8,}'), r'\1[token]'), + ('token', re.compile(r'(?i)(\b[a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@'), r'\1[credentials]@'), + ('token', re.compile(r'(?i)([?&](?:access_token|token|key|api_key|code|sig|signature|secret)=)[^&\s"\']+'), + r'\1[token]'), + # name = value / name: value / "name": "value" for secret-bearing names. + ('token', re.compile(r'(?i)(\b[\w.-]*(?:password|passwd|passphrase|secret|token|api[_-]?key|access[_-]?key' + r'|private[_-]?key|credentials?|cookie|session[_-]?id|auth(?:orization)?)["\']?\s*[:=]\s*' + r'["\']?)(?!\[)([^\s"\',;&}]+)'), r'\1[token]'), +) +# user@1000.service and friends are systemd unit instances, not addresses. +EMAIL = re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.(?!(?:service|socket|target|timer|mount|slice|scope' + r'|path|device|swap)\b)[A-Za-z]{2,}\b') +MAC = re.compile(r'(?= 1000 and entry.pw_name not in ('nobody', 'nfsnobody'): + users.add(entry.pw_name) + homes.add(entry.pw_dir) + full = entry.pw_gecos.split(',', 1)[0].strip() + if full: + names.add(full) + names.update(part for part in full.split() if len(part) >= 3) + users.add(os.environ.get('USER', '')) + hosts = {socket.gethostname(), read('/etc/hostname').strip()} + hosts |= {host.split('.', 1)[0] for host in hosts if host} + for line in read('/etc/machine-info').splitlines(): + if line.startswith('PRETTY_HOSTNAME='): + hosts.add(line.split('=', 1)[1].strip().strip('"')) + ssids = set() + rc, output = run(['nmcli', '-t', '-f', 'NAME,TYPE', 'connection', 'show']) + if rc == 0: + for line in output.splitlines(): + name, _, kind = line.rpartition(':') + if kind == '802-11-wireless' and name: + ssids.add(name.replace('\\:', ':')) + code, ssid = run(['nmcli', '-g', '802-11-wireless.ssid', 'connection', 'show', name.replace('\\:', ':')]) + if code == 0 and ssid: + ssids.add(ssid) + machine = {read('/etc/machine-id').strip(), read('/proc/sys/kernel/random/boot_id').strip()} + # Hyprland's monitor descriptions end in the panel's serial number. + serials = set() + rc, output = run(['hyprctl', 'monitors', 'all', '-j']) + monitors = json_or_text(output) if rc == 0 else None + for monitor in monitors if isinstance(monitors, list) else []: + if isinstance(monitor, dict) and isinstance(monitor.get('serial'), str): + serials.add(monitor['serial']) + return {'users': users, 'names': names, 'hosts': hosts, 'homes': homes, 'ssids': ssids, + 'machine_ids': {value for value in machine if value}, 'serials': serials} + + +def json_or_text(output): + try: + return json.loads(output) + except ValueError: + return None + + +def hardware_summary(): + """Model and devices; DMI serial numbers and UUIDs are never read.""" + lines = [] + for field in ('sys_vendor', 'product_name', 'product_version', 'board_vendor', 'board_name', 'bios_version'): + value = read(f'/sys/class/dmi/id/{field}').strip() + if value: + lines.append(f'{field}: {value}') + model = next((line.split(':', 1)[1].strip() for line in read('/proc/cpuinfo').splitlines() + if line.startswith('model name')), '') + lines.append(f'cpu: {model}') + memory = next((line.split(':', 1)[1].strip() for line in read('/proc/meminfo').splitlines() + if line.startswith('MemTotal')), '') + lines.append(f'memory: {memory}') + sections = ['\n'.join(lines)] + for title, argv in (('UEFI and Secure Boot', ['bootctl', 'status', '--no-pager']), + ('PCI devices', ['lspci', '-nnk']), + ('USB devices', ['lsusb']), + ('Block devices', ['lsblk', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINTS']), + ('Monitors', ['hyprctl', 'monitors', 'all']), + ('Kernel command line', ['cat', '/proc/cmdline'])): + rc, output = run(argv) + sections.append(f'## {title}\n{output if rc == 0 else f"unavailable ({output})"}') + for path in ('/etc/nerdos/hardware.json', '/var/lib/nerdos/hardware-status.json', + '/var/lib/nerdos/nvidia-status.json'): + if Path(path).is_file(): + sections.append(f'## {path}\n{read(path).strip()}') + return '\n\n'.join(sections) + '\n' + + +def gpu_summary(): + rc, output = run(['lspci', '-nnk']) + gpus, current = [], None + if rc == 0: + for line in output.splitlines(): + if not line.startswith(('\t', ' ')): + current = None + if re.search(r'(VGA compatible|3D|Display) controller', line): + current = {'name': line.split(': ', 1)[-1], 'driver': ''} + gpus.append(current) + elif current is not None and 'Kernel driver in use:' in line: + current['driver'] = line.split(':', 1)[1].strip() + return [f"{gpu['name']} ({gpu['driver'] or 'no driver'})" for gpu in gpus] + + +def journal(arguments, user=False): + argv = ['journalctl'] + (['--user'] if user else []) + ['-b', '--no-pager', '-o', 'short-iso', + '-n', str(JOURNAL_LINES)] + arguments + rc, output = run(argv) + return output if rc == 0 else f'unavailable ({output})' + + +def diagnostics(): + """Read-only observations, without changing or accepting deployment health.""" + checks = [] + + def record(name, state, detail): + checks.append({'name': name, 'state': state, 'detail': detail}) + + code, output = run(['systemctl', 'is-system-running']) + record('boot', 'ok' if code == 0 and output == 'running' else 'warning', output) + declaration = json_or_text(read('/etc/nerdos/health-services.json')) + if (not isinstance(declaration, dict) or declaration.get('schema') != 1 + or not isinstance(declaration.get('units'), list) or not declaration['units'] + or not isinstance(declaration.get('userUnits'), list)): + record('services', 'warning', 'Health service declaration is missing or invalid') + else: + for scope, units in (('system', declaration['units']), ('user', declaration['userUnits'])): + for unit in units: + if not isinstance(unit, str) or not re.fullmatch(r'[A-Za-z0-9_.@:-]+\.(service|target)', unit): + record('services', 'warning', 'Invalid health service declaration') + continue + code, output = run(['systemctl', *(['--user'] if scope == 'user' else []), 'is-active', unit]) + record(unit, 'ok' if code == 0 and output == 'active' else 'warning', output) + code, output = run([tool('nerd'), 'update', 'status']) + deployment = json_or_text(output) + state = deployment.get('state') if isinstance(deployment, dict) else None + record('deployment', 'ok' if code == 0 and state in ('idle', 'done', 'ready') else 'warning', + output[-4000:]) + code, output = run(['hyprctl', '-j', 'configerrors']) + errors = json_or_text(output) + healthy = code == 0 and isinstance(errors, list) and all(isinstance(error, str) and not error.strip() for error in errors) + record('hyprland', 'ok' if healthy else 'warning', output[-4000:]) + code, output = run([tool('nerd'), 'shell', 'status']) + shell = json_or_text(output) + healthy = code == 0 and isinstance(shell, dict) and shell.get('safe') is False + record('shell', 'ok' if healthy else 'warning', output[-4000:]) + code, output = run(['systemctl', '--user', 'is-active', 'quickshell.service']) + record('quickshell.service', 'ok' if code == 0 and output == 'active' else 'warning', output) + return {'version': 1, 'overall': 'warning' if any(check['state'] != 'ok' for check in checks) else 'ok', + 'checks': checks, 'scope': 'Read-only diagnostics; these do not confirm a trial deployment or qualify hardware'} + + +def collect(): + """Unredacted sections, by archive member name. Redaction happens once.""" + nerd = tool('nerd') + sections = {} + sections['health.json'] = json.dumps(diagnostics()) + release = os_release() + version_lines = [f'installation: {installation()}', f"os: {release.get('PRETTY_NAME', 'unknown')}", + f'kernel: {os.uname().release}', f'architecture: {os.uname().machine}', + f"session: {os.environ.get('XDG_CURRENT_DESKTOP', '')} {os.environ.get('XDG_SESSION_TYPE', '')}".strip()] + rc, version = run([nerd, 'version']) + version_lines.insert(0, f"nerdos: {version if rc == 0 else 'unknown'}") + rc, packages = run(['nix', 'path-info', '/run/current-system']) + version_lines += ['', '## Packages', packages] + for title, argv in (('Hyprland', ['hyprctl', 'version']), ('Quickshell', ['qs', '--version'])): + rc, output = run(argv) + version_lines += ['', f'## {title}', output if rc == 0 else f'unavailable ({output})'] + sections['versions.txt'] = '\n'.join(version_lines) + '\n' + sections['hardware.txt'] = hardware_summary() + rc, shell = run([nerd, 'shell', 'status']) + sections['shell-health.json'] = shell if rc == 0 and json_or_text(shell) is not None else json.dumps( + {'available': False, 'output': shell[-2000:]}) + failed = [] + for scope, argv in (('system', ['systemctl', '--failed', '--plain', '--no-legend']), + ('user', ['systemctl', '--user', '--failed', '--plain', '--no-legend'])): + rc, output = run(argv) + failed.append(f'## {scope}\n{output if rc == 0 else f"unavailable ({output})"}'.rstrip()) + sections['failed-units.txt'] = '\n\n'.join(failed) + '\n' + rc, errors = run(['hyprctl', 'configerrors']) + session_log = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hyprland/session.log' + sections['journal/hyprland.txt'] = (f'## hyprctl configerrors\n{errors}\n\n## {session_log}\n' + f'{read(session_log, JOURNAL_LINES)}\n') + sections['journal/quickshell.txt'] = journal(['-u', 'quickshell.service'], user=True) + '\n' + sections['journal/sddm.txt'] = journal(['-u', 'sddm.service']) + '\n' + rc, update = run([nerd, 'update', 'status']) + update_units = ('nerdos-stage@*.service', 'nerdos-apply@*.service', + 'nerdos-rollback.service', 'nerdos-confirm.service', + 'nerdos-recover.service', 'nerdos-region.service') + sections['journal/update-worker.txt'] = (f'## nerd update status\n{update}\n\n## system units\n' + f"{journal([arg for unit in update_units for arg in ('-u', unit)])}\n") + sections['journal/hardware-setup.txt'] = '\n\n'.join( + f'## {unit}\n{journal(["-u", unit])}' for unit in + ('xps-haptic-touchpad.service', 'xps-ipu7-abi.service', 'v4l2-relayd-ipu7.service', 'nvidia-persistenced.service')) + '\n' + return sections + + +def summary(sections, redactions, name): + release = os_release() + health = json_or_text(sections.get('health.json', '')) or {} + failing = [check.get('name', '') for check in health.get('checks', []) + if isinstance(check, dict) and check.get('state') in ('fail', 'warning', 'pending')] + version = next((line.split(': ', 1)[1] for line in sections['versions.txt'].splitlines() + if line.startswith('nerdos: ')), 'unknown') + return { + 'version': 1, + 'archive': name, + 'nerdos': version, + 'installation': installation(), + 'nixos': release.get('VERSION_ID', 'unknown'), + 'kernel': os.uname().release, + 'gpus': gpu_summary(), + 'health': health.get('overall', 'unavailable') if isinstance(health, dict) else 'unavailable', + 'attention': failing[:12], + 'members': sorted(sections), + 'redactions': dict(sorted(redactions.items())), + } + + +def issue_url(info, redact): + """A short, redacted environment block; the logs stay in the archive.""" + lines = [f"- NerdOS: {info['nerdos']} ({info['installation']} installation)", + f"- NixOS {info['nixos']}, kernel {info['kernel']}"] + lines += [f'- GPU: {gpu}' for gpu in info['gpus'][:3]] + attention = ', '.join(info['attention']) if info['attention'] else 'none' + lines += [f"- System diagnostics: {info['health']} (needs attention: {attention})", + f"- Diagnostic bundle: {info['archive']} (attach it to this issue)"] + query = {'template': 'bug_report.yml', 'labels': 'bug', 'environment': redact('\n'.join(lines))} + return ISSUE_URL + '?' + urllib.parse.urlencode(query, quote_via=urllib.parse.quote) + + +def default_directory(): + """The XDG download folder, where a browser's file picker starts.""" + home = Path.home() + for line in read(Path(os.environ.get('XDG_CONFIG_HOME', home / '.config')) / 'user-dirs.dirs').splitlines(): + if line.startswith('XDG_DOWNLOAD_DIR='): + path = Path(line.split('=', 1)[1].strip().strip('"').replace('$HOME', str(home))) + if path.is_dir(): + return path + return home / 'Downloads' if (home / 'Downloads').is_dir() else home + + +def output_path(requested, stamp): + name = f'nerdos-report-{stamp}{DEFAULT_SUFFIX}' + if not requested: + return default_directory() / name + path = Path(requested).expanduser() + if path.is_dir(): + return path / name + if not path.name.endswith(('.tar.gz', '.tgz', '.tar.zst')): + raise SystemExit('nerd report: --output must be a directory or end in .tar.gz, .tgz or .tar.zst') + return path + + +def write_archive(path, members, stamp): + """Owner-only archive with fixed metadata; .tar.zst needs the zstd tool.""" + root = path.name.split('.tar', 1)[0].removesuffix('.tgz') + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode='w', format=tarfile.PAX_FORMAT) as archive: + for name, text in sorted(members.items()): + data = text.encode() + info = tarfile.TarInfo(f'{root}/{name}') + info.size, info.mode, info.mtime = len(data), 0o600, int(stamp.timestamp()) + archive.addfile(info, io.BytesIO(data)) + raw = buffer.getvalue() + if path.name.endswith('.tar.zst'): + result = subprocess.run(['zstd', '-q', '-19', '-c'], input=raw, capture_output=True, check=False) + if result.returncode: + raise SystemExit('nerd report: zstd is needed for a .tar.zst archive') + data = result.stdout + else: + data = gzip.compress(raw, mtime=int(stamp.timestamp())) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, 'wb') as stream: + stream.write(data) + os.chmod(path, 0o600) + return hashlib.sha256(data).hexdigest(), len(data) + + +def readme(info): + return ('NerdOS diagnostic bundle\n\n' + 'Created by `nerd report`. Personal data was replaced with placeholders such as\n' + '[user], [hostname], /home/[user], [mac], [ipv4], [ipv6], [serial], [uuid], [ssid],\n' + '[email] and [token]. Review the files before attaching the archive to a public issue;\n' + 'automatic redaction cannot recognize every private value.\n\n' + f"Redactions by kind: {json.dumps(info['redactions'], sort_keys=True)}\n") + + +def main(argv=None): + parser = argparse.ArgumentParser(prog='nerd report', description=__doc__.splitlines()[0]) + parser.add_argument('--output', help='archive path (.tar.gz, .tgz or .tar.zst) or a directory') + parser.add_argument('--json', action='store_true', help='print a machine-readable summary; never prompt') + parser.add_argument('--no-browser', action='store_true', help='do not offer to open a GitHub issue') + args = parser.parse_args(argv) + + stamp = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + path = output_path(args.output, stamp.strftime('%Y%m%d-%H%M%S')) + if not args.json: + print('Collecting read-only system and desktop diagnostics…', file=sys.stderr, flush=True) + found = identities() + redact = Redactor(**found) + members = {name: redact(text) for name, text in collect().items()} + info = summary(members, redact.counts, path.name) + members['summary.json'] = json.dumps(info, indent=2, sort_keys=True) + '\n' + members['README.txt'] = readme(info) + digest, size = write_archive(path, members, stamp) + url = issue_url(info, redact) + if args.json: + print(json.dumps({'path': str(path), 'sha256': digest, 'bytes': size, 'issueUrl': url, + 'summary': info}, sort_keys=True)) + return 0 + print(f'Diagnostic bundle: {path} ({size} bytes)') + print(f"NerdOS {info['nerdos']} ({info['installation']}), NixOS {info['nixos']}, kernel {info['kernel']}") + for gpu in info['gpus']: + print(f'GPU: {gpu}') + print(f"System diagnostics: {info['health']}" + (f" (needs attention: {', '.join(info['attention'])})" + if info['attention'] else '')) + print('Files: ' + ', '.join(info['members'])) + removed = ', '.join(f'{count} {kind}' for kind, count in info['redactions'].items()) or 'nothing found' + print(f'Redacted: {removed}') + print('Review the archive before sharing it; nothing has been uploaded.') + if args.no_browser or not sys.stdin.isatty(): + print(f'Report the issue at {ISSUE_URL} and attach {path}.') + return 0 + try: + answer = input('Open a prefilled GitHub issue in your browser? [y/N] ').strip().lower() + except EOFError: + answer = '' + if answer in ('y', 'yes'): + rc, output = run(['xdg-open', url]) + if rc: + print(f'Could not open the browser ({output}). Open this address instead:\n{url}') + else: + print(f'Attach {path.name} to the issue; the browser does not upload it for you.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/assets/scripts/nerdos-runtime b/assets/scripts/nerdos-runtime new file mode 100755 index 00000000..9943ef6e --- /dev/null +++ b/assets/scripts/nerdos-runtime @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +# Resolve the active NerdOS runtime without making a checkout mutable. +set -euo pipefail +umask 077 + +config_root=${XDG_CONFIG_HOME:-$HOME/.config}/nerdos +data_root=${XDG_DATA_HOME:-$HOME/.local/share}/nerdos +runtime_root=/etc/nerdos/runtime +dev_source_file=$config_root/dev-source + +usage() { + cat <<'EOF' +Usage: + nerdos-runtime path quickshell|hypr-source|hypr-generated|hypr-config + nerdos-runtime exec quickshell|hypridle|hyprlock + nerdos-runtime ipc TARGET FUNCTION [ARGUMENT...] + nerdos-runtime dev enable CHECKOUT + nerdos-runtime dev disable + nerdos-runtime dev status + nerdos-runtime shell status|safe|recover + nerdos-runtime plugin add|update|clone|remove|list|enable|disable|set|reload|restart [arguments] + +The development switch only records a validated checkout. It never fetches, +resets, merges, or otherwise changes that checkout. + +ipc calls an IpcHandler function on the running shell of the selected +runtime. It never starts a shell. +EOF +} + +validate_checkout() { + local requested=${1:-} canonical top mode + [[ -n $requested && $requested == /* && -d $requested ]] || return 1 + canonical=$(readlink -f -- "$requested") || return 1 + [[ -n $canonical && $canonical == /* && -d $canonical ]] || return 1 + [[ $canonical != *$'\n'* && $canonical != *$'\r'* ]] || return 1 + [[ $(stat -c %u -- "$canonical") == "$(id -u)" ]] || return 1 + mode=$(stat -c %a -- "$canonical") || return 1 + # A runtime sourced from a world-writable checkout lets another account + # replace code immediately before the desktop starts. + (( (8#$mode & 0002) == 0 )) || return 1 + [[ -e $canonical/.git ]] || return 1 + top=$(git -C "$canonical" rev-parse --show-toplevel 2>/dev/null) || return 1 + top=$(readlink -f -- "$top") || return 1 + [[ $top == "$canonical" ]] || return 1 + [[ -f $canonical/desktop/quickshell/shell.qml + && -f $canonical/desktop/hypr/hyprland.lua + && -f $canonical/desktop/hypr/bindings.lua + && -f $canonical/desktop/hypr/autostart.lua ]] || return 1 + printf '%s\n' "$canonical" +} + +configured_dev_source() { + local value + local -a values=() + [[ -f $dev_source_file && ! -L $dev_source_file ]] || return 1 + mapfile -t values <"$dev_source_file" + [[ ${#values[@]} -eq 1 ]] || return 1 + value=${values[0]} + [[ -n $value ]] || return 1 + validate_checkout "$value" +} + +active_source() { + local source + if source=$(configured_dev_source); then + printf '%s\n' "$source" + return 0 + fi + if [[ -e $dev_source_file || -L $dev_source_file ]]; then + printf 'nerdos-runtime: ignoring invalid development source: %s\n' \ + "$dev_source_file" >&2 + fi + return 1 +} + +runtime_path() { + local kind=$1 source selected + case $kind in + quickshell) + if source=$(active_source); then + selected=$source/desktop/quickshell + else + selected=$runtime_root/quickshell + fi + # Quickshell hashes the supplied path for IPC without canonicalizing + # aliases. Launchers, recovery and bundled helpers must use one identity. + readlink -f -- "$selected" + ;; + hypr-source) + if source=$(active_source); then + printf '%s/desktop/hypr\n' "$source" + else + printf '%s/hypr\n' "$runtime_root" + fi + ;; + hypr-generated) printf '%s/hypr\n' "$runtime_root" ;; + hypr-config) printf '%s/hypr/hyprland.lua\n' "$runtime_root" ;; + *) printf 'nerdos-runtime: unknown runtime path %s\n' "$kind" >&2; return 2 ;; + esac +} + +restart_desktop() { + [[ ${NERDOS_RUNTIME_TESTING:-0} == 1 ]] && return 0 + systemctl --user try-restart quickshell.service || { + printf 'nerdos-runtime: Quickshell could not be restarted\n' >&2 + return 1 + } + if [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]] && command -v hyprctl >/dev/null; then + hyprctl reload >/dev/null || { + printf 'nerdos-runtime: Hyprland could not reload the selected source\n' >&2 + return 1 + } + fi +} + +dev_command() { + local action=${1:-} source temporary + shift || true + case $action in + enable) + [[ $# -eq 1 ]] || { usage >&2; return 2; } + source=$(validate_checkout "$1") || { + printf 'nerd dev: %s is not a safe NerdOS checkout\n' "$1" >&2 + return 2 + } + if [[ -e $config_root || -L $config_root ]]; then + [[ -d $config_root && ! -L $config_root ]] || { + printf 'nerd dev: refusing linked or non-directory config root: %s\n' \ + "$config_root" >&2 + return 2 + } + else + mkdir -p -- "$config_root" + fi + if [[ -e $dev_source_file || -L $dev_source_file ]]; then + [[ -f $dev_source_file && ! -L $dev_source_file ]] || { + printf 'nerd dev: refusing linked or non-file switch: %s\n' \ + "$dev_source_file" >&2 + return 2 + } + fi + temporary=$(mktemp "$config_root/.dev-source.XXXXXX") + trap 'rm -f -- "${temporary:-}"' RETURN + printf '%s\n' "$source" >"$temporary" + chmod 0600 "$temporary" + mv -f -- "$temporary" "$dev_source_file" + temporary= + restart_desktop + printf 'Development runtime enabled: %s\n' "$source" + ;; + disable) + [[ $# -eq 0 ]] || { usage >&2; return 2; } + if [[ -e $dev_source_file || -L $dev_source_file ]]; then + rm -f -- "$dev_source_file" + restart_desktop + printf 'Development runtime disabled.\n' + else + printf 'Development runtime is already disabled.\n' + fi + ;; + status) + [[ $# -eq 0 ]] || { usage >&2; return 2; } + if source=$(configured_dev_source); then + printf 'enabled\t%s\n' "$source" + elif [[ -e $dev_source_file || -L $dev_source_file ]]; then + printf 'invalid\t%s\n' "$dev_source_file" + return 1 + else + printf 'disabled\t%s\n' "$runtime_root" + fi + ;; + *) usage >&2; return 2 ;; + esac +} + +# Applies the shell's idle timeouts (shell.json) to the vendor hypridle +# configuration. Prints the rendered file, or the vendor file when rendering is +# impossible, so a broken settings file never leaves the session without idle +# locking. +render_hypridle_config() { + local vendor=$1 script output_dir output temporary session_action + script=$(runtime_path quickshell)/scripts/hypridle-config.py + session_action=$(command -v nerdos-session-action) + output_dir=${XDG_RUNTIME_DIR:-}/nerdos + output=$output_dir/hypridle.conf + if [[ -z ${XDG_RUNTIME_DIR:-} || ! -f $script ]] || + ! mkdir -p -- "$output_dir" || + ! temporary=$(mktemp "$output_dir/.hypridle.conf.XXXXXX"); then + printf 'nerdos-runtime: using default idle timeouts\n' >&2 + printf '%s\n' "$vendor" + return 0 + fi + if python3 "$script" "$config_root/shell.json" "$session_action" >"$temporary" && + mv -f -- "$temporary" "$output"; then + printf '%s\n' "$output" + else + rm -f -- "$temporary" + printf 'nerdos-runtime: could not apply idle settings; using defaults\n' >&2 + printf '%s\n' "$vendor" + fi +} + +# Prints the lock screen the system theme rendered in the shell's colours +# (docs/system-theme.md), or fails so the vendor file is used. Only a regular +# file whose first line is the renderer's header counts: the renderer replaces +# it atomically, and hyprlock skips entries it cannot parse rather than +# refusing to lock, so a damaged file costs the look, never the lock. +themed_hyprlock_config() { + local file first + file=${XDG_STATE_HOME:-$HOME/.local/state}/nerdos/theme/hyprlock.conf + [[ -f $file && ! -L $file && -s $file ]] || return 1 + IFS= read -r first <"$file" || return 1 + [[ $first == '# NerdOS lock screen, rendered by the system theme (theme_hyprlock.py).' ]] || + return 1 + printf '%s\n' "$file" +} + +# Whether this account registered a security key in the pam_u2f mapping the +# lock screen's PAM service reads (docs/security.md). +registered_security_key() { + local mapping=$HOME/.config/Yubico/u2f_keys user line + [[ -f $mapping && ! -L $mapping ]] || return 1 + user=$(id -un) || return 1 + while IFS= read -r line || [[ -n $line ]]; do + [[ $line == "$user:"?* ]] && return 0 + done <"$mapping" + return 1 +} + +# Adds the sign-in factors to a NerdOS lock screen (docs/hardware-security.md): the +# nerdos-lock PAM service, which has no pam_fprintd step and tries a +# registered security key before the password, and hyprlock's own fprintd +# check beside it while the NixOS module enables fingerprints. Prints the rendered +# file, or the input file when that PAM service is absent: a lock naming a +# missing service would refuse every password, and hyprlock's default service +# already asks fprintd itself. +render_hyprlock_config() { + local selected=$1 lock_pam=/etc/pam.d/nerdos-lock + local fingerprint_policy=/etc/nerdos/fingerprint-enabled + local output_dir output temporary fingerprint=false keys=false + output_dir=${XDG_RUNTIME_DIR:-}/nerdos + output=$output_dir/hyprlock.conf + if [[ -z ${XDG_RUNTIME_DIR:-} || ! -f $lock_pam ]] || + ! mkdir -p -- "$output_dir" || + ! temporary=$(mktemp "$output_dir/.hyprlock.conf.XXXXXX"); then + printf '%s\n' "$selected" + return 0 + fi + test -e "$fingerprint_policy" && fingerprint=true + registered_security_key && keys=true + if { + if [[ $keys == true ]]; then + # PAM tries the key when the field is submitted, so an empty Enter + # must reach it, and the wait for a touch needs words. Both edits + # match only NerdOS's own lock layouts. + sed -e 's|>Password|>Password or security key|' \ + -e '/^ *placeholder_text = /a\ check_text = Touch your security key if it flashes' \ + -- "$selected" && + printf '\ngeneral:ignore_empty_input = false\n' + else + cat -- "$selected" + fi + } >"$temporary" && cat >>"$temporary" <&2 + printf '%s\n' "$selected" + fi +} + +exec_runtime() { + local kind=$1 selected user_config themed + case $kind in + quickshell) + selected=$(runtime_path quickshell) + [[ -f $selected/shell.qml ]] || { + printf 'nerdos-runtime: Quickshell runtime is incomplete: %s\n' \ + "$selected" >&2 + return 1 + } + # The authentication UI belongs to the selected shell. Stop the old + # agent synchronously before QML registers its listener; this also + # handles an upgrade while the previous agent is still running. + # Older releases remain usable when rolling back or leaving dev mode. + if [[ ${NERDOS_RUNTIME_TESTING:-0} != 1 ]] && { + systemctl --user is-active --quiet hyprpolkitagent.service || + [[ $(systemctl --user show hyprpolkitagent.service -p LoadState --value 2>/dev/null) == loaded ]]; + }; then + if [[ -f $selected/PolkitWindow.qml ]]; then + systemctl --user stop hyprpolkitagent.service + else + systemctl --user start --no-block hyprpolkitagent.service + fi + fi + export NERDOS_USER_CONFIG_ROOT=$config_root + export NERDOS_THEME_ROOT=$data_root/themes + export NERDOS_PLUGIN_ROOT=$data_root/plugins + export OMARCHY_PATH=$selected/compat/omarchy + export PATH=$OMARCHY_PATH/bin:$PATH + if [[ -f $selected/scripts/shell-recovery.py ]]; then + exec python3 "$selected/scripts/shell-recovery.py" run "$selected" + fi + exec qs -p "$selected" + ;; + hypridle|hyprlock) + user_config=$config_root/hypr/$kind.conf + selected=$runtime_root/hypr/$kind.conf + [[ ! -f $user_config || -L $user_config ]] || selected=$user_config + if [[ $kind == hyprlock && $selected != "$user_config" ]] && + themed=$(themed_hyprlock_config); then + selected=$themed + fi + [[ -f $selected ]] || { + printf 'nerdos-runtime: %s configuration is missing: %s\n' \ + "$kind" "$selected" >&2 + return 1 + } + if [[ $kind == hypridle && $selected != "$user_config" ]]; then + selected=$(render_hypridle_config "$selected") + fi + # A person's own lock file keeps exactly the authentication it sets. + if [[ $kind == hyprlock && $selected != "$user_config" ]]; then + selected=$(render_hyprlock_config "$selected") + fi + if [[ $kind == hypridle ]]; then + exec hypridle --config "$selected" + else + exec hyprlock --config "$selected" \ + --immediate-render --no-fade-in --quiet + fi + ;; + *) printf 'nerdos-runtime: unknown executable %s\n' "$kind" >&2; return 2 ;; + esac +} + +# The shell runs as `qs -p `, not as a named configuration, so a bare +# `qs ipc call` finds no "default" config and reaches nothing. Instances are +# matched by config path, which must be the one exec_runtime selected, the +# development checkout included. The timeout keeps a hung shell from +# stranding one client per key press; with no shell running, qs fails at once. +ipc_call() { + local selected + selected=$(runtime_path quickshell) + if [[ -f $selected/scripts/shell-recovery.py ]]; then + selected=$(python3 "$selected/scripts/shell-recovery.py" path "$selected") + fi + # --any-display: a caller started by systemd (a reminder timer) need not + # carry the session's display. The -- keeps function names that shadow qs + # subcommands (e.g. show) positional. + exec timeout --kill-after=1s 2s \ + qs ipc --any-display -p "$selected" call -- "$@" +} + +command_name=${1:-} +[[ $# -eq 0 ]] || shift +case $command_name in + path) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + runtime_path "$1" + ;; + exec) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + exec_runtime "$1" + ;; + ipc) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + ipc_call "$@" + ;; + dev) dev_command "$@" ;; + shell) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + case $1 in status|safe|recover|record-stop) ;; *) usage >&2; exit 2 ;; esac + selected=$(runtime_path quickshell) + # A previous release without recovery support remains rollback-compatible. + [[ -f $selected/scripts/shell-recovery.py ]] || { + [[ $1 != record-stop ]] || exit 0 + printf 'nerdos-runtime: this desktop release has no recovery mode\n' >&2 + exit 1 + } + python3 "$selected/scripts/shell-recovery.py" "$1" "$selected" + if [[ $1 == safe || $1 == recover ]]; then + exec systemctl --user restart quickshell.service + fi + ;; + plugin) + selected=$(runtime_path quickshell) + if [[ ${1:-} == reload ]]; then + exec "$selected/compat/omarchy/bin/omarchy-shell" shell rescanPlugins + fi + if [[ ${1:-} == restart ]]; then + exec systemctl --user restart quickshell.service + fi + exec python3 "$selected/scripts/user-plugins.py" "$@" + ;; + -h|--help|help) usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/assets/scripts/nerdos-software b/assets/scripts/nerdos-software new file mode 100755 index 00000000..ab7094ee --- /dev/null +++ b/assets/scripts/nerdos-software @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec nerdos-runtime ipc software open diff --git a/assets/scripts/nerdos-theme b/assets/scripts/nerdos-theme new file mode 100755 index 00000000..56f10e91 --- /dev/null +++ b/assets/scripts/nerdos-theme @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# `nerd theme`: list, apply, install, export and remove named appearance +# themes (docs/shell-appearance.md#themes). The work happens in the active +# shell runtime's scripts/theme-packs.py, which Settings → Appearance uses as +# well, so both read the same theme schema; this wrapper only finds that +# runtime, a development checkout included, and the IPC helper beside it. +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: nerd theme list [--json] + nerd theme apply ID + nerd theme install SOURCE [--replace] + nerd theme export ID|CURRENT [DIR] [--name NAME] + nerd theme remove ID + +SOURCE is a theme folder, a .tar.gz or .zip archive, or an https:// Git URL. +Themes are data: theme.json and one optional PNG, JPEG or WebP wallpaper. +USAGE +} + +case ${1:-} in + -h|--help|help) usage; exit 0 ;; + '') usage >&2; exit 2 ;; +esac + +helper_dir=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")") +runtime=$helper_dir/nerdos-runtime +if [[ ! -x $runtime ]]; then + runtime=$(command -v nerdos-runtime) || { + printf 'nerd theme: nerdos-runtime is not installed\n' >&2 + exit 1 + } +fi +quickshell=$("$runtime" path quickshell) +script=$quickshell/scripts/theme-packs.py +if [[ ! -f $script ]]; then + printf 'nerd theme: the active desktop release has no theme support\n' >&2 + exit 1 +fi +export NERDOS_RUNTIME_HELPER=$runtime +exec python3 -B "$script" "$@" diff --git a/assets/scripts/nerdos-webapp b/assets/scripts/nerdos-webapp new file mode 100755 index 00000000..913da529 --- /dev/null +++ b/assets/scripts/nerdos-webapp @@ -0,0 +1,733 @@ +#!/usr/bin/python3 -I +"""Install, remove, list, and launch NerdOS web apps. + + nerdos-webapp install --url URL [--name NAME] [--id ID] [--offline] [--notify] + nerdos-webapp remove ID [--notify] + nerdos-webapp list [--json] + nerdos-webapp launch ID + nerdos-webapp refresh [ID...] + nerdos-webapp seed + +A web app is a website in its own browser window with an entry in the +application launcher. Each one is a desktop entry, +~/.local/share/applications/nerdos-webapp-ID.desktop, whose command is +`nerdos-webapp launch ID`, plus its name, address, and icon under +~/.local/share/nerdos/webapps/ID/. Launching picks the browser each time: +Brave Origin in app mode when it is installed (the proprietary applications +choice), otherwise Firefox. + +Installing fetches the site's own icon: an https page's apple-touch-icon or +icon link, else /favicon.ico, within strict size and time limits, converted +to PNG by ImageMagick. Offline, or for an http address, the app gets a +generated monogram, and the real icon is fetched on a later launch. + +`seed` installs the NerdOS defaults (Slack, WhatsApp, YouTube, Google Photos +and X) once per account, offline. Every default it has seeded is recorded, so +one the user removes is never added back. The shared NixOS personal-init +service runs it for both the live desktop and installed accounts. + +The site address is data. It never reaches a shell, a desktop-entry Exec +line, or a command-line option position. +""" + +from __future__ import annotations + +import argparse +import contextlib +import fcntl +import hashlib +import html.parser +import ipaddress +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import time +from urllib.parse import urljoin, urlsplit + +ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]{0,39}") +MAX_URL = 2048 +MAX_NAME = 80 +PAGE_LIMIT = 512 * 1024 +ICON_LIMIT = 1024 * 1024 +REQUEST_TIMEOUT = 6 +FETCH_BUDGET = 20 +MAGICK_TIMEOUT = 20 +ICON_SIZE = 128 +# A failed icon fetch is retried on launch at most once a day. +ICON_RETRY_SECONDS = 24 * 3600 +USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) NerdOS-webapp/1" + +# The defaults NerdOS has always offered on Super+M, Super+W, Super+Y, +# Super+Shift+P and Super+Shift+X. Their bindings launch them by these ids. +# The managed personal-init service calls `seed` at login. Recorded deletions +# remain absent; newly declared defaults are seeded once. +DEFAULTS = ( + ("slack", "Slack", "https://app.slack.com/"), + ("whatsapp", "WhatsApp", "https://web.whatsapp.com/"), + ("youtube", "YouTube", "https://youtube.com/"), + ("google-photos", "Google Photos", "https://photos.google.com/"), + ("x", "X", "https://x.com/"), +) + +MONOGRAM_COLORS = ("#3b6fd8", "#6a4fc9", "#b2457b", "#c4572f", "#2f8f6b", + "#2f7f9f", "#7d6a2f", "#55607a") + +# Raster formats only, recognized by their bytes. SVG is refused: an icon is +# rendered, and a vector file can reference other resources. +FORMATS = ( + (b"\x89PNG\r\n\x1a\n", "png"), + (b"\x00\x00\x01\x00", "ico"), + (b"\xff\xd8\xff", "jpeg"), + (b"GIF87a", "gif"), + (b"GIF89a", "gif"), + (b"BM", "bmp"), +) + + +class WebAppError(Exception): + """A failure with a message meant for the user.""" + + +# ---- Locations -------------------------------------------------------------- + +def home() -> Path: + return Path(os.environ.get("HOME") or Path.home()) + + +def data_home() -> Path: + return Path(os.environ.get("XDG_DATA_HOME") or home() / ".local/share") + + +def apps_root() -> Path: + return data_home() / "nerdos/webapps" + + +def applications_dir() -> Path: + return data_home() / "applications" + + +def desktop_path(identifier: str) -> Path: + return applications_dir() / f"nerdos-webapp-{identifier}.desktop" + + +def helper_path() -> Path: + # The shared NixOS package provides this stable selected-system entry point. + return Path("/run/current-system/sw/bin/nerdos-webapp") + + +def directory(path: Path) -> Path: + """Create a directory without following or replacing a user's symlink.""" + if path.is_symlink(): + raise WebAppError(f"{path} is a symbolic link; leaving it alone") + path.mkdir(parents=True, exist_ok=True) + if not path.is_dir(): + raise WebAppError(f"{path} is not a directory") + return path + + +def write_atomic(path: Path, data: bytes, mode: int = 0o644) -> None: + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as output: + output.write(data) + output.flush() + os.fchmod(output.fileno(), mode) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +@contextlib.contextmanager +def locked(): + root = directory(apps_root()) + descriptor = os.open(root / ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield root + + +# ---- Validation ------------------------------------------------------------- + +def validate_url(value: str) -> str: + """Return a canonical http(s) URL, or raise WebAppError. + + http and https only, a host, no user name or password, no whitespace or + control characters, and a valid port. + """ + if not isinstance(value, str): + raise WebAppError("The web address must be text") + text = value.strip() + if not text or len(text) > MAX_URL: + raise WebAppError("Enter a web address of at most 2048 characters") + if any(character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F + for character in text): + raise WebAppError("A web address cannot contain spaces or control characters") + parts = urlsplit(text) + if parts.scheme.lower() not in ("http", "https"): + raise WebAppError("Only http and https web addresses are supported") + if "@" in parts.netloc or parts.username is not None or parts.password is not None: + raise WebAppError("Web addresses with a user name or password are not allowed") + host = parts.hostname or "" + if not host: + raise WebAppError("The web address has no host name") + try: + port = parts.port + except ValueError as error: + raise WebAppError("The web address has an invalid port") from error + if port == 0: + raise WebAppError("The web address has an invalid port") + with contextlib.suppress(ValueError): + ipaddress.ip_address(host) + return parts._replace(scheme=parts.scheme.lower(), path=parts.path or "/").geturl() + try: + ascii_host = host.encode("idna").decode("ascii") + except UnicodeError as error: + raise WebAppError("The web address has an invalid host name") from error + labels = ascii_host.rstrip(".").split(".") + if not all(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in labels): + raise WebAppError("The web address has an invalid host name") + return parts._replace(scheme=parts.scheme.lower(), path=parts.path or "/").geturl() + + +def validate_id(value: str) -> str: + if not isinstance(value, str) or not ID_PATTERN.fullmatch(value): + raise WebAppError("A web app id is 1-40 lowercase letters, digits, or dashes") + return value + + +def clean_name(value: str) -> str: + name = " ".join(str(value or "").split()) + name = "".join(character for character in name if character.isprintable()) + return name[:MAX_NAME].strip() + + +def slug(value: str) -> str: + text = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return text[:40].strip("-") + + +def name_from_host(url: str) -> str: + host = (urlsplit(url).hostname or "").lower() + with contextlib.suppress(ValueError): + ipaddress.ip_address(host) + return host + labels = [label for label in host.split(".") if label] + while len(labels) > 2 and labels[0] in ("www", "app", "web", "m", "mobile"): + labels.pop(0) + core = labels[-2] if len(labels) >= 2 else (labels[0] if labels else "Web app") + return core[:1].upper() + core[1:] + + +# ---- Desktop entry ------------------------------------------------------------ + +def desktop_string(value: str) -> str: + """Escape a value for a desktop-entry string key.""" + return (value.replace("\\", "\\\\").replace("\n", "\\n") + .replace("\t", "\\t").replace("\r", "\\r")) + + +def exec_argument(value: str) -> str: + """Quote one Exec argument per the Desktop Entry specification.""" + if re.fullmatch(r"[A-Za-z0-9_@+=:,./-]+", value): + return value + quoted = re.sub(r'(["`$\\])', r"\\\1", value).replace("%", "%%") + return f'"{quoted}"' + + +def desktop_entry(record: dict, icon: Path) -> str: + host = urlsplit(record["url"]).hostname or "" + command = " ".join(exec_argument(part) for part in + (str(helper_path()), "launch", record["id"])) + lines = [ + "[Desktop Entry]", + "Type=Application", + "Version=1.0", + f"Name={desktop_string(record['name'])}", + f"Comment={desktop_string('Web app · ' + host)}", + f"Exec={desktop_string(command)}", + f"Icon={desktop_string(str(icon))}", + "Terminal=false", + "Categories=Network;WebBrowser;", + "StartupNotify=true", + f"X-NerdOS-WebApp={record['id']}", + ] + return "\n".join(lines) + "\n" + + +# ---- Icons -------------------------------------------------------------------- + +def monogram(name: str, identifier: str) -> bytes: + letter = next((character for character in name if character.isalnum()), "?").upper() + letter = letter.replace("&", "&").replace("<", "<").replace(">", ">") + color = MONOGRAM_COLORS[int(hashlib.sha256(identifier.encode()).hexdigest(), 16) + % len(MONOGRAM_COLORS)] + return ( + '\n' + f' \n' + ' {letter}\n' + '\n' + ).encode() + + +def image_format(data: bytes) -> str: + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + for magic, name in FORMATS: + if data.startswith(magic): + return name + return "" + + +def largest_ico_frame(data: bytes) -> int: + """The index of the largest image in an ICO directory.""" + if len(data) < 6: + return 0 + count = int.from_bytes(data[4:6], "little") + best, best_size = 0, -1 + for index in range(min(count, 64)): + entry = data[6 + index * 16: 22 + index * 16] + if len(entry) < 16: + break + size = (entry[0] or 256) * (entry[1] or 256) + if size > best_size: + best, best_size = index, size + return best + + +def to_png(data: bytes) -> bytes: + """Convert a fetched raster icon to a square PNG with ImageMagick.""" + kind = image_format(data) + if not kind: + raise WebAppError("The site icon is not a supported image") + if shutil.which("magick") is None: + if kind == "png": + return data + raise WebAppError("ImageMagick is not installed") + frame = largest_ico_frame(data) if kind == "ico" else 0 + with tempfile.TemporaryDirectory(prefix="nerdos-webapp-") as scratch: + output = Path(scratch) / "icon.png" + result = subprocess.run( + ["magick", "-limit", "memory", "64MiB", "-limit", "map", "128MiB", + "-limit", "width", "4096", "-limit", "height", "4096", + "-limit", "time", "15", f"{kind}:-[{frame}]", + "-background", "none", "-thumbnail", f"{ICON_SIZE}x{ICON_SIZE}", + "-gravity", "center", "-extent", f"{ICON_SIZE}x{ICON_SIZE}", + "-strip", f"png:{output}"], + input=data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=MAGICK_TIMEOUT, check=False) + if result.returncode != 0 or not output.is_file(): + raise WebAppError("The site icon could not be converted") + return output.read_bytes() + + +class IconLinks(html.parser.HTMLParser): + """Collect and the page's own name for itself.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.icons: list[tuple[int, str]] = [] + self.name = "" + + def handle_starttag(self, tag, attrs): + values = {key.lower(): value or "" for key, value in attrs} + if tag == "link": + rel = values.get("rel", "").lower().split() + href = values.get("href", "").strip() + if not href or href.startswith("data:") or "mask-icon" in rel: + return + if "svg" in values.get("type", "").lower() or href.lower().split("?")[0].endswith(".svg"): + return + if "apple-touch-icon" in rel or "apple-touch-icon-precomposed" in rel: + self.icons.append((10_000, href)) + elif "icon" in rel: + size = 0 + for token in values.get("sizes", "").lower().split(): + match = re.fullmatch(r"(\d+)x(\d+)", token) + if match: + size = max(size, int(match.group(1))) + self.icons.append((min(size, 1024) or 16, href)) + elif tag == "meta" and not self.name: + key = (values.get("name") or values.get("property") or "").lower() + if key in ("application-name", "og:site_name", "apple-mobile-web-app-title"): + self.name = clean_name(values.get("content", "")) + + +class Fetcher: + """https-only GET with a byte limit and an overall time budget.""" + + def __init__(self, budget: float = FETCH_BUDGET): + self.deadline = time.monotonic() + budget + + def get(self, url: str, limit: int) -> tuple[bytes, str]: + import urllib.error + import urllib.request + + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise WebAppError("Fetching the site took too long") + if urlsplit(url).scheme != "https": + raise WebAppError("Only https addresses are fetched") + + class HttpsOnly(urllib.request.HTTPRedirectHandler): + max_redirections = 5 + + def redirect_request(self, request, fp, code, message, headers, new_url): + if urlsplit(new_url).scheme != "https" or "@" in urlsplit(new_url).netloc: + raise urllib.error.HTTPError(new_url, code, "insecure redirect", + headers, fp) + return super().redirect_request(request, fp, code, message, headers, new_url) + + opener = urllib.request.build_opener(HttpsOnly()) + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with opener.open(request, timeout=min(REQUEST_TIMEOUT, remaining)) as response: + data = response.read(limit + 1) + final = response.geturl() + except (OSError, ValueError, urllib.error.URLError) as error: + raise WebAppError(f"Could not fetch {urlsplit(url).hostname}: {error}") from error + if len(data) > limit: + raise WebAppError("The site sent more data than allowed") + return data, final + + +def discover(url: str, fetcher: Fetcher | None = None) -> tuple[bytes | None, str]: + """Fetch the site's best raster icon as PNG, and the name it gives itself. + + Returns (None, name) when no usable icon was found. + """ + if urlsplit(url).scheme != "https": + return None, "" + fetcher = fetcher or Fetcher() + candidates: list[str] = [] + name = "" + page_url = url + try: + page, page_url = fetcher.get(url, PAGE_LIMIT) + parser = IconLinks() + with contextlib.suppress(Exception): + parser.feed(page.decode("utf-8", "replace")) + name = parser.name + for _size, href in sorted(parser.icons, key=lambda item: -item[0]): + candidates.append(urljoin(page_url, href)) + except WebAppError: + pass + parts = urlsplit(page_url) + candidates.append(f"https://{parts.netloc}/favicon.ico") + seen = set() + for candidate in candidates: + if candidate in seen or urlsplit(candidate).scheme != "https": + continue + seen.add(candidate) + try: + data, _final = fetcher.get(candidate, ICON_LIMIT) + return to_png(data), name + except WebAppError: + continue + return None, name + + +# ---- Records ------------------------------------------------------------------ + +def record_path(identifier: str) -> Path: + return apps_root() / identifier / "app.json" + + +def read_record(identifier: str) -> dict | None: + try: + value = json.loads(record_path(identifier).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(value, dict) or value.get("id") != identifier: + return None + try: + value["url"] = validate_url(value.get("url", "")) + except WebAppError: + return None + value["name"] = clean_name(value.get("name", "")) or identifier + if value.get("icon") not in ("icon.png", "icon.svg"): + value["icon"] = "icon.svg" + return value + + +def installed() -> list[dict]: + root = apps_root() + if not root.is_dir(): + return [] + records = [] + for entry in sorted(root.iterdir()): + if entry.is_dir() and not entry.is_symlink() and ID_PATTERN.fullmatch(entry.name): + record = read_record(entry.name) + if record is not None and desktop_path(entry.name).is_file(): + records.append(record) + return sorted(records, key=lambda record: record["name"].lower()) + + +def publish(record: dict, icon: bytes, icon_name: str) -> None: + """Write the icon, metadata and desktop entry of one app.""" + folder = directory(apps_root() / record["id"]) + for stale in ("icon.png", "icon.svg"): + if stale != icon_name: + (folder / stale).unlink(missing_ok=True) + write_atomic(folder / icon_name, icon) + record = {**record, "icon": icon_name} + payload = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + write_atomic(folder / "app.json", payload.encode()) + directory(applications_dir()) + write_atomic(desktop_path(record["id"]), desktop_entry(record, folder / icon_name).encode()) + + +def install(url: str, name: str = "", identifier: str = "", offline: bool = False, + fetcher: Fetcher | None = None) -> dict: + url = validate_url(url) + name = clean_name(name) + icon = None + discovered = "" + if not offline: + icon, discovered = discover(url, fetcher) + name = name or discovered or name_from_host(url) + explicit = bool(identifier) + identifier = validate_id(identifier) if explicit else validate_id( + slug(name) or slug(name_from_host(url)) or "web-app") + record = { + "version": 1, + "id": identifier, + "name": name, + "url": url, + "default": False, + "iconSource": "favicon" if icon else "monogram", + # Seeded and offline installs record no attempt, so the first launch + # fetches the real icon; a failed fetch waits a day. + "iconAttempt": 0 if offline else int(time.time()), + } + with locked(): + if not explicit: + # Another site that happens to share the name gets its own id; + # the same address again updates the app in place. + base, number = identifier, 1 + while True: + current = read_record(identifier) + if current is None or current["url"] == url: + break + number += 1 + identifier = f"{base[:37].rstrip('-')}-{number}" + record["id"] = identifier + existing = desktop_path(identifier) + if existing.exists() and read_record(identifier) is None: + raise WebAppError(f"{existing} exists and is not a NerdOS web app") + if icon: + publish(record, icon, "icon.png") + else: + publish(record, monogram(name, identifier), "icon.svg") + return {**record, "icon": str(apps_root() / identifier / + ("icon.png" if icon else "icon.svg"))} + + +def remove(identifier: str) -> dict: + identifier = validate_id(identifier) + with locked() as root: + record = read_record(identifier) + desktop = desktop_path(identifier) + if record is None and not desktop.exists(): + raise WebAppError(f"No web app named {identifier} is installed") + if desktop.exists(): + # Only a desktop entry this helper wrote is removed. + text = desktop.read_text(encoding="utf-8", errors="replace") + if f"X-NerdOS-WebApp={identifier}\n" not in text: + raise WebAppError(f"{desktop} is not a NerdOS web app") + desktop.unlink() + folder = root / identifier + if folder.is_dir() and not folder.is_symlink(): + shutil.rmtree(folder) + return record or {"id": identifier, "name": identifier} + + +# ---- Defaults ----------------------------------------------------------------- + +def seeded_path() -> Path: + return apps_root() / "defaults.json" + + +def seeded_ids() -> set[str]: + try: + value = json.loads(seeded_path().read_text(encoding="utf-8")) + except (OSError, ValueError): + return set() + ids = value.get("seeded") if isinstance(value, dict) else None + return {item for item in ids if isinstance(item, str)} if isinstance(ids, list) else set() + + +def record_seeded(ids: set[str]) -> None: + payload = json.dumps({"version": 1, "seeded": sorted(ids)}, indent=2) + "\n" + write_atomic(seeded_path(), payload.encode()) + + +def seed_default(identifier: str) -> bool: + """Install one default once; False when it was seeded before.""" + default = next((entry for entry in DEFAULTS if entry[0] == identifier), None) + if default is None: + return False + with locked(): + seeded = seeded_ids() + if identifier in seeded: + return False + # A file of the user's own at that name is kept, and counts as seeded. + if not desktop_path(identifier).exists(): + record = {"version": 1, "id": identifier, "name": default[1], "url": default[2], + "default": True, "iconSource": "monogram", "iconAttempt": 0} + publish(record, monogram(default[1], identifier), "icon.svg") + record_seeded(seeded | {identifier}) + return True + + +def seed() -> list[str]: + """Seed every default that was never seeded; never re-add a removed one.""" + already = seeded_ids() + return [identifier for identifier, _name, _url in DEFAULTS + if identifier not in already and seed_default(identifier)] + + +# ---- Launching ---------------------------------------------------------------- + +def browser_command(url: str) -> list[str]: + return ["nerdos-apps", "webapp", url] + + +def spawn(argv: list[str]) -> None: + subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True) + + +def refresh_icon(identifier: str, fetcher: Fetcher | None = None) -> bool: + record = read_record(identifier) + if record is None: + raise WebAppError(f"No web app named {identifier} is installed") + with locked(): + record["iconAttempt"] = int(time.time()) + payload = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + write_atomic(record_path(identifier), payload.encode()) + icon, _name = discover(record["url"], fetcher) + if icon is None: + return False + with locked(): + current = read_record(identifier) + if current is None or not desktop_path(identifier).exists(): + return False + publish({**current, "iconSource": "favicon"}, icon, "icon.png") + return True + + +def launch(identifier: str) -> None: + identifier = validate_id(identifier) + record = read_record(identifier) + if record is None or not desktop_path(identifier).exists(): + if seed_default(identifier): + record = read_record(identifier) + if record is None: + default = next((entry for entry in DEFAULTS if entry[0] == identifier), None) + hint = (f"nerdos-webapp install --name {default[1]} --url {default[2]}" + if default else "nerdos-webapp install --url ADDRESS") + raise WebAppError( + f"The {default[1] if default else identifier} web app was removed. " + f"Reinstall it with Install web app… in the launcher, or {hint}") + try: + spawn(browser_command(record["url"])) + except OSError as error: + raise WebAppError(f"No browser could open {record['name']}: {error}") from error + # The first launch of a seeded or offline install fetches its real icon + # in the background; a failed fetch waits a day. + if (record.get("iconSource") != "favicon" and record["url"].startswith("https://") + and time.time() - int(record.get("iconAttempt") or 0) > ICON_RETRY_SECONDS): + spawn([sys.executable, "-I", os.path.realpath(__file__), "refresh", "--quiet", + identifier]) + + +# ---- Command line --------------------------------------------------------------- + +def notify(summary: str, body: str = "", urgency: str = "normal") -> None: + if shutil.which("notify-send") is None: + return + with contextlib.suppress(OSError, subprocess.SubprocessError): + subprocess.run(["notify-send", "--app-name=NerdOS", f"--urgency={urgency}", + summary, body], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=3, check=False) + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(prog="nerdos-webapp", description=__doc__.split("\n")[0]) + commands = root.add_subparsers(dest="command", required=True) + add = commands.add_parser("install", help="install a web app") + add.add_argument("--url", required=True) + add.add_argument("--name", default="") + add.add_argument("--id", default="") + add.add_argument("--offline", action="store_true", help="skip fetching the site icon") + add.add_argument("--notify", action="store_true", help="report the outcome as a notification") + drop = commands.add_parser("remove", help="remove a web app") + drop.add_argument("id") + drop.add_argument("--notify", action="store_true") + listing = commands.add_parser("list", help="list installed web apps") + listing.add_argument("--json", action="store_true") + start = commands.add_parser("launch", help="open a web app") + start.add_argument("id") + refresh = commands.add_parser("refresh", help="fetch site icons again") + refresh.add_argument("ids", nargs="*") + refresh.add_argument("--quiet", action="store_true") + commands.add_parser("seed", help="install the NerdOS default web apps once") + return root + + +def main(argv: list[str] | None = None) -> int: + arguments = parser().parse_args(argv) + announce = getattr(arguments, "notify", False) + try: + if arguments.command == "install": + record = install(arguments.url, arguments.name, arguments.id, arguments.offline) + print(record["id"]) + if announce: + notify(f"{record['name']} is installed", "Find it in the launcher.") + elif arguments.command == "remove": + record = remove(arguments.id) + if announce: + notify(f"{record.get('name', arguments.id)} was removed") + elif arguments.command == "list": + records = [{"id": record["id"], "name": record["name"], "url": record["url"], + "icon": str(apps_root() / record["id"] / record["icon"]), + "default": bool(record.get("default"))} for record in installed()] + if arguments.json: + print(json.dumps(records, ensure_ascii=False)) + else: + for record in records: + print(f"{record['id']}\t{record['name']}\t{record['url']}") + elif arguments.command == "launch": + launch(arguments.id) + elif arguments.command == "refresh": + for identifier in arguments.ids or [record["id"] for record in installed()]: + updated = refresh_icon(validate_id(identifier)) + if not arguments.quiet: + print(f"{identifier}: {'icon updated' if updated else 'no icon found'}") + elif arguments.command == "seed": + added = seed() + if added: + print("CHANGED: seeded " + ", ".join(added)) + except WebAppError as error: + print(f"nerdos-webapp: {error}", file=sys.stderr) + if announce or arguments.command == "launch": + notify("Web apps", str(error), "normal") + return 1 + except OSError as error: + print(f"nerdos-webapp: {error}", file=sys.stderr) + if announce or arguments.command == "launch": + notify("Web apps", "The web app could not be changed. Run nerdos-webapp in a " + "terminal for details.") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/assets/scripts/quickshell-reminder b/assets/scripts/quickshell-reminder index c2552f33..47a3b4cc 100755 --- a/assets/scripts/quickshell-reminder +++ b/assets/scripts/quickshell-reminder @@ -3,7 +3,7 @@ set -euo pipefail -state_dir="${QUICKSHELL_REMINDER_STATE_DIR:-$HOME/.local/state/cybexos/shell/reminders}" +state_dir="${QUICKSHELL_REMINDER_STATE_DIR:-$HOME/.local/state/nerdos/shell/reminders}" script_path="$(readlink -f -- "$0")" usage() { @@ -57,10 +57,10 @@ schedule_record() { suppress_refresh=0 # The shell runs by path, so a bare `qs ipc call` finds no configuration; -# cybexos-runtime names the active runtime. Best effort: the shell's own +# nerdos-runtime names the active runtime. Best effort: the shell's own # settle and restore reads are the fallback for a missed call. refresh_shell() { - local runtime=${HOME:-}/.local/bin/cybexos-runtime + local runtime=${HOME:-}/.local/bin/nerdos-runtime ((suppress_refresh == 0)) || return 0 [[ -x $runtime ]] || return 0 "$runtime" ipc reminders refresh >/dev/null 2>&1 || true diff --git a/assets/scripts/screen-record b/assets/scripts/screen-record index edc45008..ca82deee 100644 --- a/assets/scripts/screen-record +++ b/assets/scripts/screen-record @@ -66,8 +66,8 @@ render_nodes() { ((${#nvidia_nodes[@]} == 0)) || printf '%s\n' "${nvidia_nodes[@]}" } -# Whether VA-API offers H.264 encoding on a render node. Mesa as Fedora -# builds it leaves H.264 out (RPM Fusion's freeworld drivers add it back), and +# Whether VA-API offers H.264 encoding on a render node. Mesa as NixOS +# builds it leaves H.264 out (codec support depends on the selected driver), and # nvidia-vaapi-driver only decodes, so the vendor alone proves nothing: the # driver's own list of entrypoints is the answer. vaapi_encodes_h264() { @@ -87,7 +87,7 @@ ffmpeg_has_nvenc() { } # One line: the recorder, then how it encodes. gpu-screen-recorder is not -# packaged for Fedora; when a user installs it, it is the better recorder on +# packaged for NixOS; when a user installs it, it is the better recorder on # NVIDIA, where it encodes with NVENC directly. select_encoder() { local entry node vendor driver nvidia=0 nodes=() @@ -318,7 +318,8 @@ OUTPUT_FILE="$OUTPUT_DIR/screen-recording-${TS}.mp4" # shell can open the camera while the encoder initialises. Without any video # device there is nothing to show; say so once and record anyway. if ((webcam)); then - if compgen -G "$V4L_ROOT/video*" >/dev/null; then + cameras=("$V4L_ROOT"/video*) + if [[ -e ${cameras[0]} ]]; then publish_state "$WEBCAM_STATE" "$SELECTION" else webcam=0 diff --git a/assets/scripts/screenshot b/assets/scripts/screenshot index 7a17351d..ae2fcef3 100644 --- a/assets/scripts/screenshot +++ b/assets/scripts/screenshot @@ -154,7 +154,7 @@ edit_screenshot() { --early-exit \ --actions-on-enter save-to-clipboard \ --save-after-copy \ - --copy-command "$HOME/.local/bin/clipboard-copy-image" + --copy-command "clipboard-copy-image" } # offer_edit FILE [SUMMARY] [BODY] diff --git a/bootstrap b/bootstrap index da0f9dff..8ddf0993 100755 --- a/bootstrap +++ b/bootstrap @@ -1,5 +1,3 @@ #!/usr/bin/env bash -# Backward-compatible entry point. New installations use ./install directly. set -euo pipefail -repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -exec "$repo_dir/install" "$@" +exec nerd install "$@" diff --git a/desktop/assistant/nerdos_assistant/__init__.py b/desktop/assistant/nerdos_assistant/__init__.py new file mode 100644 index 00000000..b2f64243 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/__init__.py @@ -0,0 +1,3 @@ +"""The NerdOS Assistant service: agent sessions behind one local socket.""" + +__version__ = "1" diff --git a/desktop/assistant/nerdos_assistant/__main__.py b/desktop/assistant/nerdos_assistant/__main__.py new file mode 100644 index 00000000..eb53e2f3 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/desktop/assistant/nerdos_assistant/auth.py b/desktop/assistant/nerdos_assistant/auth.py new file mode 100644 index 00000000..144851df --- /dev/null +++ b/desktop/assistant/nerdos_assistant/auth.py @@ -0,0 +1,35 @@ +"""Ask the agents about sign-in without reading or copying their credentials.""" + +from __future__ import annotations + +import asyncio +import contextlib + +from .protocol import stop_process + + +STATUS_ARGS = {"claude": ["auth", "status"], "codex": ["login", "status"]} +LOGIN_ARGS = {"claude": ["auth", "login"], "codex": ["login"]} +STATUS_TIMEOUT = 8.0 + + +async def status(agent, executable, home, env): + """Both CLIs document 0 for signed in and 1 for signed out. + + Discard their output: it can identify the account and does not belong in + the socket, persisted assistant state or journal. No model is invoked. + """ + process = None + try: + process = await asyncio.create_subprocess_exec( + executable, *STATUS_ARGS[agent], cwd=home, env=env, + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, start_new_session=True) + code = await asyncio.wait_for(process.wait(), STATUS_TIMEOUT) + return "ready" if code == 0 else "required" if code == 1 else "unknown" + except (OSError, asyncio.TimeoutError): + return "unknown" + finally: + if process is not None and process.returncode is None: + with contextlib.suppress(Exception): + await stop_process(process) diff --git a/desktop/assistant/nerdos_assistant/claude.py b/desktop/assistant/nerdos_assistant/claude.py new file mode 100644 index 00000000..de404cff --- /dev/null +++ b/desktop/assistant/nerdos_assistant/claude.py @@ -0,0 +1,292 @@ +"""Claude Code over stream-json, with permission prompts answered by the policy. + +`--permission-prompt-tool stdio` turns every prompt Claude Code would show in +a terminal into a `can_use_tool` control request on stdout. The answer is a +`control_response` on stdin: the policy's, or the person's through a card. +""" + +from __future__ import annotations + +import asyncio +import base64 +import collections +import contextlib +import itertools +import os + +from . import policy, steps +from .protocol import LOG, encode, iter_lines, parse_json, stop_process +from .turn import classify + + +IDLE_SECONDS = 15 * 60 +STOP_GRACE = 5.0 + + +class ClaudeAgent: + name = "claude" + label = "Claude Code" + + def __init__(self, executable, home, instructions, status_path=None, conversation_id=None, + on_conversation=None, env=None): + self.executable = executable + self.home = str(home) + self.instructions = instructions + self.status_path = status_path + self.conversation_id = conversation_id + self.on_conversation = on_conversation or (lambda agent, identifier: None) + self.env = env + self.process = None + self.model = None + self.turn = None + self.result = None + self.stderr = collections.deque(maxlen=20) + self.tasks = set() + self.write_lock = asyncio.Lock() + self.idle = None + self.requests = itertools.count(1) + self.resumed = False + self.resume_failed = False + self.received = 0 + + @property + def resumable(self): + return bool(self.conversation_id) + + def handoff(self): + return [self.executable, "--resume", self.conversation_id] if self.conversation_id else None + + def _spawn(self, coroutine): + task = asyncio.create_task(coroutine) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + return task + + def _record(self, identifier): + if isinstance(identifier, str) and identifier and identifier != self.conversation_id: + self.conversation_id = identifier + self.on_conversation(self.name, identifier) + + async def _start(self, model): + if self.process is not None and self.process.returncode is None: + if self.model == model: + return + # A different model needs a new process; the session resumes in it. + await self.close() + command = [self.executable, "-p", "--input-format", "stream-json", "--output-format", "stream-json", + "--verbose", "--include-partial-messages", "--permission-prompt-tool", "stdio", + "--append-system-prompt", self.instructions] + if model: + command += ["--model", model] + self.resumed = bool(self.conversation_id) + if self.conversation_id: + command += ["--resume", self.conversation_id] + self.received = 0 + self.process = await asyncio.create_subprocess_exec( + *command, cwd=self.home, env=self.env, stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=True) + self.model = model + self.stderr.clear() + process = self.process + errors = self._spawn(self._read_errors(process)) + self._spawn(self._read(process, errors)) + + async def _send(self, message): + process = self.process + if process is None or process.stdin is None or process.returncode is not None: + raise ConnectionError("Claude Code is not running") + async with self.write_lock: + process.stdin.write(encode(message)) + await process.stdin.drain() + + def _settle(self, status, text=None, error="", kind=""): + if self.result is not None and not self.result.done(): + self.result.set_result({"status": status, "text": text, "error": error, "kind": kind}) + + async def run(self, turn, text, image=None, model=None): + outcome = await self._attempt(turn, text, image, model) + if outcome["kind"] == "crashed" and self.resume_failed and not turn.stopping: + # A session Claude Code no longer has (deleted, or from another + # machine's home) must not fail every later question too. + LOG.warning("could not resume Claude Code session %s; starting a new one", self.conversation_id) + self.resume_failed = False + self.conversation_id = None + self.on_conversation(self.name, None) + outcome = await self._attempt(turn, text, image, model) + return outcome + + async def _attempt(self, turn, text, image, model): + self._cancel_idle() + await self._start(model or None) + loop = asyncio.get_running_loop() + self.turn, self.result = turn, loop.create_future() + content = [{"type": "text", "text": text}] + if image: + data, media = image + content.append({"type": "image", "source": {"type": "base64", "media_type": media, + "data": base64.b64encode(data).decode("ascii")}}) + try: + if turn.stopping: + return {"status": "stopped", "text": None, "error": "", "kind": ""} + await self._send({"type": "user", "message": {"role": "user", "content": content}, + "parent_tool_use_id": None, "session_id": self.conversation_id or ""}) + return await self.result + except (ConnectionError, OSError) as error: + return {"status": "failed", "text": None, "error": self._last_error() or str(error), "kind": "crashed"} + finally: + self.turn = None + self.result = None + self._schedule_idle() + + async def interrupt(self): + turn, result = self.turn, self.result + if turn is None or result is None: + return + turn.stopping = True + turn.deny_all() + with contextlib.suppress(ConnectionError, OSError): + await self._send({"type": "control_request", "request_id": f"interrupt-{next(self.requests)}", + "request": {"subtype": "interrupt"}}) + try: + await asyncio.wait_for(asyncio.shield(result), STOP_GRACE) + except asyncio.TimeoutError: + # The session is on disk; the next question resumes it. + await self.close() + self._settle("stopped") + + async def close(self): + process, self.process = self.process, None + await stop_process(process) + + def _last_error(self): + return self.stderr[-1] if self.stderr else "" + + def _cancel_idle(self): + if self.idle is not None: + self.idle.cancel() + self.idle = None + + def _schedule_idle(self): + self._cancel_idle() + if self.process is not None: + self.idle = asyncio.get_running_loop().call_later(IDLE_SECONDS, lambda: self._spawn(self.close())) + + # ---- the stream -------------------------------------------------------- + + async def _read_errors(self, process): + async for line in iter_lines(process.stderr, 64 * 1024): + text = line.decode("utf-8", "replace").strip() + if text: + self.stderr.append(text[:500]) + + async def _read(self, process, errors): + try: + async for line in iter_lines(process.stdout): + message = parse_json(line) + if message is not None: + if self.process is process: + self.received += 1 + try: + self._handle(message) + except Exception: # one malformed event must not end the session + LOG.exception("unexpected Claude Code event") + finally: + await process.wait() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(asyncio.shield(errors), 1) + # close() detaches the process first; only an unexpected exit + # ends the turn here. + if self.process is process: + self.process = None + self._exited(process) + + def _exited(self, process): + self.resume_failed = self.resumed and self.received == 0 and process.returncode != 0 + turn = self.turn + if turn is not None and turn.stopping: + self._settle("stopped") + else: + detail = self._last_error() or f"Claude Code exited with status {process.returncode}." + self._settle("failed", error=detail, kind=classify(detail) or "crashed") + + def _handle(self, message): + kind = message.get("type") + turn = self.turn + if kind in ("system", "result"): + self._record(message.get("session_id")) + if kind == "control_request": + request = message.get("request") or {} + if request.get("subtype") == "can_use_tool": + self._spawn(self._permission(message.get("request_id"), request)) + else: + self._spawn(self._send_quietly({"type": "control_response", "response": { + "subtype": "error", "request_id": message.get("request_id"), + "error": "Not supported by the NerdOS Assistant"}})) + return + if kind == "control_cancel_request": + if turn is not None: + turn.cancel(message.get("request_id")) + return + if turn is None or message.get("parent_tool_use_id"): + return + if kind == "stream_event": + event = message.get("event") or {} + name = event.get("type") + if name == "message_start": + turn.reset_text() + turn.set_step("Thinking") + elif name == "content_block_delta": + delta = event.get("delta") or {} + if delta.get("type") == "text_delta": + turn.delta(delta.get("text", "")) + elif name == "content_block_start": + block = event.get("content_block") or {} + if block.get("type") == "tool_use": + turn.set_step(steps.claude_tool(block.get("name", ""), {})) + elif kind == "assistant": + for block in (message.get("message") or {}).get("content") or []: + if isinstance(block, dict) and block.get("type") == "tool_use": + name, tool_input = block.get("name", ""), block.get("input") or {} + turn.set_step(steps.claude_tool(name, tool_input)) + # Best effort for edits the person's own rules allow without a prompt. + path = tool_input.get(policy.EDIT_TOOLS.get(name, "")) if isinstance(tool_input, dict) else None + if isinstance(path, str) and path and not policy.vendor(policy.expand(path, self.home)): + turn.snapshots.take(path) + elif kind == "result": + if turn.stopping: + self._settle("stopped", text=turn.text) + return + text = message.get("result") + if message.get("is_error") or message.get("subtype") not in (None, "success"): + errors = message.get("errors") + detail = text if isinstance(text, str) and text else \ + "; ".join(str(item) for item in errors) if isinstance(errors, list) and errors else \ + "Claude Code could not finish (" + str(message.get("subtype") or "error") + ")." + self._settle("failed", error=detail, kind=classify(detail)) + else: + self._settle("completed", text=text if isinstance(text, str) and text.strip() else turn.text) + + async def _send_quietly(self, message): + with contextlib.suppress(ConnectionError, OSError): + await self._send(message) + + async def _permission(self, request_id, request): + turn = self.turn + tool, tool_input = request.get("tool_name", ""), request.get("input") + tool_input = tool_input if isinstance(tool_input, dict) else {} + if turn is None: + answer, note = "deny", "No question is in progress." + else: + decision = policy.decide_tool(tool, tool_input, self.home, self.status_path) + answer, note = await turn.authorize(decision, cancel_key=request_id) + if answer == "cancelled": + return + response = {"behavior": "allow", "updatedInput": tool_input} if answer in ("allow", "always") \ + else {"behavior": "deny", "message": note} + await self._send_quietly({"type": "control_response", "response": { + "subtype": "success", "request_id": request_id, "response": response}}) + + +def media_type(path): + extension = os.path.splitext(str(path))[1].lower() + return "image/jpeg" if extension in (".jpg", ".jpeg") else "image/png" diff --git a/desktop/assistant/nerdos_assistant/cli.py b/desktop/assistant/nerdos_assistant/cli.py new file mode 100644 index 00000000..467f0e1c --- /dev/null +++ b/desktop/assistant/nerdos_assistant/cli.py @@ -0,0 +1,146 @@ +"""`nerdos-assistant`: the service, a status probe and a small debugging client.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import sys + +from .protocol import MAX_LINE, default_socket, encode, iter_lines, parse_json + + +def build_parser(): + parser = argparse.ArgumentParser(prog="nerdos-assistant", description="The NerdOS Assistant service") + commands = parser.add_subparsers(dest="command", required=True) + serve = commands.add_parser("serve", help="run the service (the user unit does this)") + serve.add_argument("--socket", default=None) + serve.add_argument("--state-dir", default=None) + status = commands.add_parser("status", help="print the service state as JSON") + status.add_argument("--socket", default=None) + ask = commands.add_parser("ask", help="ask a question and print the reply") + ask.add_argument("--agent", choices=("auto", "claude", "codex"), default="auto") + ask.add_argument("--socket", default=None) + ask.add_argument("text", nargs="+") + for verb in ("show", "hide", "toggle"): + commands.add_parser(verb, help=f"{verb} the character") + return parser + + +class Connection: + def __init__(self, reader, writer): + self.reader, self.writer = reader, writer + self.lines = iter_lines(reader, MAX_LINE) + self.next_id = 0 + + @classmethod + async def open(cls, path): + reader, writer = await asyncio.open_unix_connection(str(path)) + return cls(reader, writer) + + async def send(self, method, params): + self.next_id += 1 + self.writer.write(encode({"jsonrpc": "2.0", "id": self.next_id, "method": method, "params": params})) + await self.writer.drain() + return self.next_id + + async def receive(self): + async for line in self.lines: + message = parse_json(line) + if message is not None: + return message + raise ConnectionError("the assistant service closed the connection") + + async def call(self, method, params, on_notification=None): + identifier = await self.send(method, params) + while True: + message = await self.receive() + if message.get("id") == identifier: + if "error" in message: + raise RuntimeError(message["error"].get("message", "request failed")) + return message.get("result") + if on_notification is not None and "method" in message: + on_notification(message) + + def close(self): + self.writer.close() + + +async def status(path): + connection = await Connection.open(path) + try: + result = await connection.call("hello", {"client": "nerdos-assistant status", "protocol": 1}) + finally: + connection.close() + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + +async def ask(path, agent, text): + connection = await Connection.open(path) + current = {"id": None, "printed": 0, "told": set(), "done": None} + + def follow(message): + method, params = message.get("method"), message.get("params") or {} + if method == "turn.delta" and params.get("id") == current["id"]: + sys.stdout.write(params.get("text", "")) + sys.stdout.flush() + current["printed"] += len(params.get("text", "")) + elif method == "turn.updated": + turn = params.get("turn") or {} + if turn.get("id") != current["id"]: + return + for card in turn.get("approvals") or []: + if card.get("id") not in current["told"]: + current["told"].add(card.get("id")) + print(f"\n[waiting in the assistant: {card.get('title')}]", file=sys.stderr) + if turn.get("status") in ("completed", "stopped", "failed"): + current["done"] = turn + + try: + await connection.call("hello", {"client": "nerdos-assistant ask", "protocol": 1}) + started = await connection.call("turn.start", {"text": text, "agent": agent}, follow) + current["id"] = started["turn"]["id"] + while current["done"] is None: + follow(await connection.receive()) + finally: + connection.close() + turn = current["done"] + if not current["printed"] and turn.get("text"): + sys.stdout.write(turn["text"]) + sys.stdout.write("\n") + if turn.get("status") == "failed": + print(turn.get("error") or "The assistant could not answer.", file=sys.stderr) + return 1 + return 0 if turn.get("status") == "completed" else 1 + + +def main(argv=None): + args = build_parser().parse_args(argv) + if args.command in ("show", "hide", "toggle"): + try: + os.execvp("nerdos-runtime", ["nerdos-runtime", "ipc", "assistant", args.command]) + except OSError as error: + print(f"nerdos-assistant: {error}", file=sys.stderr) + return 127 + path = args.socket or default_socket() + if args.command == "serve": + from .server import Server + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + os.umask(0o077) + asyncio.run(Server(path, args.state_dir).serve()) + return 0 + try: + if args.command == "status": + return asyncio.run(status(path)) + return asyncio.run(ask(path, args.agent, " ".join(args.text))) + except (ConnectionError, FileNotFoundError, OSError) as error: + print(f"nerdos-assistant: the assistant service is not running ({error})", file=sys.stderr) + return 1 + except RuntimeError as error: + print(f"nerdos-assistant: {error}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 diff --git a/desktop/assistant/nerdos_assistant/codex.py b/desktop/assistant/nerdos_assistant/codex.py new file mode 100644 index 00000000..8e6822b4 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/codex.py @@ -0,0 +1,418 @@ +"""Codex through `codex app-server`: threads, turns and approval requests. + +The thread runs with a read-only sandbox and on-request approvals, so every +write and every escalation becomes a server request that the policy answers +or turns into a card. The app-server's JSON-RPC omits the "jsonrpc" member. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import itertools +import json +import shlex + +from . import policy, steps +from .protocol import LOG, encode, iter_lines, parse_json, stop_process +from .turn import classify + + +IDLE_SECONDS = 15 * 60 +STOP_GRACE = 5.0 +REQUEST_TIMEOUT = 60.0 +# Starting or resuming a thread also starts the person's MCP servers. +THREAD_TIMEOUT = 180.0 + + +class CodexError(Exception): + pass + + +class CodexAgent: + name = "codex" + label = "Codex" + + def __init__(self, executable, home, instructions, status_path=None, conversation_id=None, + on_conversation=None, env=None): + self.executable = executable + self.home = str(home) + self.instructions = instructions + self.status_path = status_path + self.conversation_id = conversation_id + self.on_conversation = on_conversation or (lambda agent, identifier: None) + self.env = env + self.process = None + self.loaded = None + self.pending = {} + self.ids = itertools.count(1) + self.turn = None + self.result = None + self.turn_id = None + self.items = {} + self.message_item = None + self.final_text = None + self.last_message = None + self.turn_error = "" + self.turn_kind = "" + self.stderr = collections.deque(maxlen=20) + self.tasks = set() + self.write_lock = asyncio.Lock() + self.idle = None + + @property + def resumable(self): + return bool(self.conversation_id) + + def handoff(self): + return [self.executable, "resume", self.conversation_id] if self.conversation_id else None + + def _spawn(self, coroutine): + task = asyncio.create_task(coroutine) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + return task + + def _record(self, identifier): + if isinstance(identifier, str) and identifier and identifier != self.conversation_id: + self.conversation_id = identifier + self.on_conversation(self.name, identifier) + + # ---- transport --------------------------------------------------------- + + async def _send(self, message): + process = self.process + if process is None or process.stdin is None or process.returncode is not None: + raise ConnectionError("Codex is not running") + async with self.write_lock: + process.stdin.write(encode(message)) + await process.stdin.drain() + + async def request(self, method, params, timeout=REQUEST_TIMEOUT): + identifier = next(self.ids) + future = asyncio.get_running_loop().create_future() + self.pending[identifier] = future + try: + await self._send({"id": identifier, "method": method, "params": params}) + return await asyncio.wait_for(future, timeout) + except asyncio.TimeoutError: + raise CodexError(f"Codex did not answer {method}") from None + finally: + self.pending.pop(identifier, None) + + async def _reply(self, identifier, result=None, error=None): + message = {"id": identifier} + if error is not None: + message["error"] = {"code": error[0], "message": error[1]} + else: + message["result"] = result + with contextlib.suppress(ConnectionError, OSError): + await self._send(message) + + async def _start(self): + if self.process is not None and self.process.returncode is None: + return + self.process = await asyncio.create_subprocess_exec( + self.executable, "app-server", cwd=self.home, env=self.env, stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=True) + self.loaded = None + self.stderr.clear() + process = self.process + errors = self._spawn(self._read_errors(process)) + self._spawn(self._read(process, errors)) + await self.request("initialize", { + "clientInfo": {"name": "nerdos_assistant", "title": "NerdOS Assistant", "version": "1"}, + "capabilities": {"experimentalApi": False, "requestAttestation": False}}) + await self._send({"method": "initialized"}) + + async def _thread(self, model): + if self.conversation_id and self.loaded == self.conversation_id: + return + params = {"cwd": self.home, "approvalPolicy": "on-request", "sandbox": "read-only", + "developerInstructions": self.instructions} + if model: + params["model"] = model + if self.conversation_id: + try: + result = await self.request("thread/resume", dict(params, threadId=self.conversation_id), + timeout=THREAD_TIMEOUT) + self.loaded = ((result or {}).get("thread") or {}).get("id") or self.conversation_id + self._record(self.loaded) + return + except CodexError as error: + # A thread removed from Codex's history starts over. + LOG.warning("could not resume Codex thread: %s", error) + result = await self.request("thread/start", params, timeout=THREAD_TIMEOUT) + identifier = ((result or {}).get("thread") or {}).get("id") + if not isinstance(identifier, str) or not identifier: + raise CodexError("Codex did not start a thread") + self.conversation_id = None + self._record(identifier) + self.loaded = identifier + + async def run(self, turn, text, image=None, model=None): + self._cancel_idle() + loop = asyncio.get_running_loop() + try: + await self._start() + await self._thread(model or None) + self.turn, self.result = turn, loop.create_future() + self.turn_id, self.items, self.message_item = None, {}, None + self.final_text = self.last_message = None + self.turn_error, self.turn_kind = "", "" + items = [{"type": "text", "text": text, "text_elements": []}] + if image: + items.append({"type": "localImage", "path": image}) + params = {"threadId": self.conversation_id, "input": items} + if model: + params["model"] = model + if turn.stopping: + return {"status": "stopped", "text": None, "error": "", "kind": ""} + started = await self.request("turn/start", params) + if self.turn_id is None: + self.turn_id = ((started or {}).get("turn") or {}).get("id") + return await self.result + except FileNotFoundError: + return {"status": "failed", "text": None, "error": "Codex is not installed.", "kind": "missing"} + except (CodexError, ConnectionError, OSError) as error: + detail = str(error) if isinstance(error, CodexError) else (self._last_error() or str(error)) + return {"status": "failed", "text": None, "error": detail, "kind": classify(detail) or "crashed"} + finally: + self.turn = None + self.result = None + self._schedule_idle() + + async def interrupt(self): + turn, result = self.turn, self.result + if turn is None or result is None: + return + turn.stopping = True + turn.deny_all() + if self.turn_id: + with contextlib.suppress(CodexError, ConnectionError, OSError): + await self.request("turn/interrupt", {"threadId": self.conversation_id, "turnId": self.turn_id}, + timeout=STOP_GRACE) + try: + await asyncio.wait_for(asyncio.shield(result), STOP_GRACE) + except asyncio.TimeoutError: + await self.close() + self._settle("stopped") + + async def close(self): + process, self.process = self.process, None + self.loaded = None + await stop_process(process) + + def _settle(self, status, text=None, error="", kind=""): + if self.result is not None and not self.result.done(): + self.result.set_result({"status": status, "text": text, "error": error, "kind": kind}) + + def _last_error(self): + return self.stderr[-1] if self.stderr else "" + + def _cancel_idle(self): + if self.idle is not None: + self.idle.cancel() + self.idle = None + + def _schedule_idle(self): + self._cancel_idle() + if self.process is not None: + self.idle = asyncio.get_running_loop().call_later(IDLE_SECONDS, lambda: self._spawn(self.close())) + + # ---- the stream -------------------------------------------------------- + + async def _read_errors(self, process): + async for line in iter_lines(process.stderr, 64 * 1024): + text = line.decode("utf-8", "replace").strip() + if text: + self.stderr.append(text[:500]) + + async def _read(self, process, errors): + try: + async for line in iter_lines(process.stdout): + message = parse_json(line) + if message is None: + continue + try: + self._dispatch(message) + except Exception: # one malformed event must not end the session + LOG.exception("unexpected Codex message") + finally: + await process.wait() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(asyncio.shield(errors), 1) + if self.process is process: + self.process = None + self.loaded = None + self._exited(process) + + def _exited(self, process): + detail = self._last_error() or f"Codex exited with status {process.returncode}." + for future in list(self.pending.values()): + if not future.done(): + future.set_exception(CodexError(detail)) + turn = self.turn + if turn is not None and turn.stopping: + self._settle("stopped") + else: + self._settle("failed", error=detail, kind=classify(detail) or "crashed") + + def _dispatch(self, message): + method = message.get("method") + if method is None: + future = self.pending.get(message.get("id")) + if future is not None and not future.done(): + error = message.get("error") + if error: + future.set_exception(CodexError(str((error or {}).get("message") or error))) + else: + future.set_result(message.get("result")) + return + params = message.get("params") if isinstance(message.get("params"), dict) else {} + if "id" in message: + self._spawn(self._server_request(message["id"], method, params)) + else: + self._notification(method, params) + + def _notification(self, method, params): + turn = self.turn + if turn is None: + return + if self.conversation_id and params.get("threadId") not in (None, self.conversation_id): + return + if method == "turn/started": + self.turn_id = (params.get("turn") or {}).get("id") or self.turn_id + elif method == "item/started": + item = params.get("item") or {} + kind = item.get("type") + if kind == "agentMessage": + self.message_item = item.get("id") + turn.reset_text() + turn.set_step("Thinking") + return + if kind == "fileChange": + self.items[item.get("id")] = item + turn.set_step(steps.codex_item(item)) + elif method == "item/agentMessage/delta": + if self.message_item in (None, params.get("itemId")): + turn.delta(params.get("delta", "")) + elif method == "item/completed": + item = params.get("item") or {} + if item.get("type") == "agentMessage" and isinstance(item.get("text"), str): + self.last_message = item["text"] + if item.get("phase") == "final_answer": + self.final_text = item["text"] + elif item.get("type") == "fileChange": + self.items[item.get("id")] = item + elif method == "error": + error = params.get("error") or {} + if not params.get("willRetry"): + self.turn_error = str(error.get("message") or "") + if error.get("codexErrorInfo") == "unauthorized": + self.turn_kind = "auth" + elif method == "turn/completed": + completed = params.get("turn") or {} + if self.turn_id and completed.get("id") not in (None, self.turn_id): + return + status = completed.get("status") + text = self.final_text if self.final_text is not None else self.last_message + if text is None or not text.strip(): + text = turn.text + if status == "interrupted" or turn.stopping: + self._settle("stopped", text=text) + elif status == "failed": + error = completed.get("error") or {} + detail = str(error.get("message") or self.turn_error or "Codex could not finish.") + kind = "auth" if error.get("codexErrorInfo") == "unauthorized" else self.turn_kind + self._settle("failed", text=text, error=detail, kind=kind or classify(detail)) + else: + self._settle("completed", text=text) + + # ---- approvals --------------------------------------------------------- + + async def _server_request(self, identifier, method, params): + turn = self.turn + try: + if method == "item/commandExecution/requestApproval": + answer = await self._command(turn, params.get("command") or "", params.get("reason")) + decision = {"allow": "accept", "always": "acceptForSession"}.get(answer, "decline") + await self._reply(identifier, {"decision": decision}) + elif method == "item/fileChange/requestApproval": + item = self.items.get(params.get("itemId")) or {} + changes = [change for change in item.get("changes") or [] if isinstance(change, dict)] + paths = [change.get("path") for change in changes] + if params.get("grantRoot"): + paths.append(params["grantRoot"]) + diff = "\n".join(str(change.get("diff") or "") for change in changes) + answer = await self._edit(turn, paths, diff, params.get("reason")) + decision = {"allow": "accept", "always": "acceptForSession"}.get(answer, "decline") + await self._reply(identifier, {"decision": decision}) + elif method == "item/permissions/requestApproval": + requested = params.get("permissions") or {} + answer = await self._permissions(turn, requested, params.get("reason")) + granted = {key: value for key, value in requested.items() + if key in ("network", "fileSystem") and value} if answer in ("allow", "always") else {} + await self._reply(identifier, {"permissions": granted, + "scope": "session" if answer == "always" else "turn"}) + elif method == "execCommandApproval": + command = params.get("command") + command = shlex.join(command) if isinstance(command, list) else str(command or "") + answer = await self._command(turn, command, params.get("reason")) + await self._reply(identifier, {"decision": review_decision(answer)}) + elif method == "applyPatchApproval": + changes = params.get("fileChanges") or {} + paths = list(changes) + ([params["grantRoot"]] if params.get("grantRoot") else []) + diff = "\n".join(str((change or {}).get("unified_diff") or (change or {}).get("content") or "") + for change in changes.values()) + answer = await self._edit(turn, paths, diff, params.get("reason")) + await self._reply(identifier, {"decision": review_decision(answer)}) + elif method == "item/tool/requestUserInput": + await self._reply(identifier, {"answers": {}}) + elif method == "mcpServer/elicitation/request": + await self._reply(identifier, {"action": "decline", "content": None, "_meta": None}) + elif method == "item/tool/call": + await self._reply(identifier, {"contentItems": [], "success": False}) + else: + await self._reply(identifier, error=(-32601, f"{method} is not supported by the NerdOS Assistant")) + except Exception as error: # an answer is always owed, or Codex waits forever + LOG.exception("Codex request %s failed", method) + await self._reply(identifier, error=(-32603, str(error) or "internal error")) + + async def _authorize(self, turn, decision, reason=None): + if turn is None: + return "deny" + if decision.action == "ask" and reason and decision.card is not None and not decision.card.get("detail"): + decision.card["detail"] = str(reason)[:400] + answer, _ = await turn.authorize(decision) + return answer + + async def _command(self, turn, command, reason=None): + return await self._authorize(turn, policy.decide_command(command, self.home, self.status_path), reason) + + async def _edit(self, turn, paths, diff, reason=None): + return await self._authorize(turn, policy.decide_edit(paths, self.home, diff, reason or "")) + + async def _permissions(self, turn, requested, reason=None): + detail = [] + if reason: + detail.append(str(reason)) + network = requested.get("network") or {} + if network.get("enabled"): + detail.append("Network access") + files = requested.get("fileSystem") or {} + for key, label in (("write", "Write to"), ("read", "Read")): + for path in files.get(key) or []: + detail.append(f"{label} {policy.display(policy.expand(path, self.home), self.home)}") + card = {"kind": "other", "title": "Allow more access", "detail": "\n".join(detail)[:1500]} + key = ("permissions", json.dumps(requested, sort_keys=True)) + return await self._authorize(turn, policy.ask(card, key=key)) + + +def review_decision(answer): + if answer == "allow": + return "approved" + if answer == "always": + return "approved_for_session" + return {"denied": {"rejection": "The person declined this action."}} diff --git a/desktop/assistant/nerdos_assistant/policy.py b/desktop/assistant/nerdos_assistant/policy.py new file mode 100644 index 00000000..332efe34 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/policy.py @@ -0,0 +1,583 @@ +"""What the agent may do without asking, and the file snapshots behind Undo. + +Every permission prompt from either agent reaches decide_tool(), +decide_command() or decide_edit(). They are pure apart from reading the +lifecycle status for an apply card, so the table in docs/assistant.md is +tested here rather than through a model. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shlex +import tempfile + + +READ_ONLY_TOOLS = frozenset({ + "Read", "Glob", "Grep", "LS", "NotebookRead", "WebSearch", "WebFetch", + "TodoWrite", "Task", "Agent", "Skill", "ToolSearch", "BashOutput", + "KillShell", "KillBash", "ExitPlanMode", +}) +EDIT_TOOLS = {"Edit": "file_path", "Write": "file_path", "MultiEdit": "file_path", + "NotebookEdit": "notebook_path"} +VENDOR_PREFIXES = ("/nix/store", "/etc/nerdos", "/run/current-system", "/etc/static") +PERSONAL_PREFIXES = (".config/nerdos", ".local/share/nerdos/plugins") +TRUSTED_BIN = ("/run/current-system/sw/bin/", "/etc/profiles/per-user/", "/usr/bin/", "/bin/") + +VENDOR_MESSAGE = ("Vendor files are read-only. Personal changes belong in ~/.config/nerdos, " + "a personal plugin under ~/.local/share/nerdos/plugins, or /etc/nixos/local " + "staged with nerd update stage local.") +ADMIN_MESSAGE = ("The assistant does not run commands as administrator. Stage system changes " + "with nerd software stage or nerd update stage local; the person reviews and " + "authenticates the staged change.") +REBUILD_MESSAGE = ("NerdOS applies only reviewed changes. Use nerd update stage local, then request " + "nerd update apply TRANSACTION REVIEW_TOKEN instead of nixos-rebuild.") +POWER_MESSAGE = ("The assistant never restarts, suspends or powers off the computer. Tell the " + "person what is needed so they can do it when they are ready.") +RECOVERY_MESSAGE = ("Rollback, boot confirmation and retrying a failed deployment are the " + "person's decision. Explain the command instead of running it.") + +ADMIN_COMMANDS = {"sudo", "doas", "pkexec", "su", "run0"} +POWER_COMMANDS = {"reboot", "poweroff", "shutdown", "halt", "kexec"} +POWER_VERBS = {"reboot", "poweroff", "halt", "suspend", "hibernate", "hybrid-sleep", + "suspend-then-hibernate", "kexec", "soft-reboot"} + +SIMPLE_READ = { + "ls", "cat", "head", "tail", "grep", "egrep", "fgrep", "rg", "fd", "find", "jq", "yq", + "stat", "file", "wc", "which", "type", "uname", "id", "whoami", "date", "df", "du", + "free", "lsblk", "lscpu", "lspci", "lsusb", "uptime", "readlink", + "realpath", "basename", "dirname", "sort", "uniq", "cut", "tr", "column", "diff", + "sha256sum", "md5sum", "pwd", "echo", "printf", "tree", "hostname", "groups", + "locale", "nproc", "command", "cd", "test", "true", +} +FIND_ACTIONS = {"-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", + "-fprintf", "-fls"} +GIT_READ = {"status", "log", "diff", "show", "rev-parse", "ls-files", "blame", "describe", + "shortlog"} +HYPR_READ = {"monitors", "clients", "workspaces", "activeworkspace", "activewindow", + "devices", "binds", "layers", "getoption", "version", "systeminfo", + "cursorpos", "splash", "workspacerules", "layouts", "animations", "globalshortcuts"} +SYSTEMCTL_READ = {"status", "is-active", "is-enabled", "is-failed", "list-units", + "list-timers", "list-unit-files", "list-sockets", "list-dependencies", + "show", "cat", "show-environment"} +SYSTEMCTL_VALUE_OPTIONS = {"-t", "--type", "--state", "-n", "--lines", "-p", "--property", + "-o", "--output"} +JOURNAL_WRITES = ("--vacuum", "--rotate", "--flush", "--sync", "--relinquish-var", + "--smart-relinquish-var", "--setup-keys", "--update-catalog") +STAGE_SCOPES = {"local", "upstream", "nerdos", "both"} +SHELLS = {"bash", "sh", "zsh", "dash"} +# Redirections that cannot write anything a person would miss. +SAFE_REDIRECT = re.compile(r"(?\s*/dev/null|2>&1)(?!\S)") +HOME_VARIABLE = re.compile(r"^\$(?:HOME|\{HOME\})(?:/|$)") +OPERATORS = set(";&|<>()") +MAX_DIFF = 6000 +MAX_CHANGES = 16000 + + +class Decision: + """allow, deny or ask; ask carries the card the person sees.""" + + def __init__(self, action, message="", card=None, step="", paths=(), key=None): + self.action = action + self.message = message + self.card = card + self.step = step + self.paths = list(paths) + # What an "always" answer remembers for the rest of the conversation. + self.key = key + + def __repr__(self): + return f"Decision({self.action!r}, {self.message or self.step or ''!r})" + + +def allow(step="", paths=()): + return Decision("allow", step=step, paths=paths) + + +def deny(message): + return Decision("deny", message=message) + + +def ask(card, key=None, paths=()): + return Decision("ask", card=card, key=key, paths=paths) + + +def clip(text, limit): + text = str(text) + return text if len(text) <= limit else text[:limit - 1] + "…" + + +# ---- paths ----------------------------------------------------------------- + +def expand(path, home): + path = str(path) + if path == "~" or path.startswith("~/"): + path = str(home) + path[1:] + if not os.path.isabs(path): + path = os.path.join(str(home), path) + return os.path.normpath(path) + + +def under(path, prefix): + prefix = prefix.rstrip("/") + return path == prefix or path.startswith(prefix + "/") + + +def display(path, home): + home = str(home).rstrip("/") + return "~" + path[len(home):] if under(path, home) else path + + +def vendor(path): + real = os.path.realpath(path) + return any(under(candidate, prefix) for candidate in (path, real) for prefix in VENDOR_PREFIXES) + + +def personal(path, home): + # The write lands where the path resolves: a link out of the personal + # directories (a dotfile manager's link to ~/.bashrc) is an ordinary edit. + def inside(candidate, base): + return any(under(candidate, os.path.join(base, prefix)) for prefix in PERSONAL_PREFIXES) + return inside(path, str(home)) and inside(os.path.realpath(path), os.path.realpath(str(home))) + + +def decide_edit(paths, home, diff="", reason=""): + """One edit (Claude) or one patch of several files (Codex).""" + expanded = [expand(path, home) for path in paths if isinstance(path, str) and path] + if not expanded: + return ask({"kind": "edit", "title": "Edit a file", "detail": reason or "", "diff": clip(diff, MAX_DIFF)}) + if any(vendor(path) for path in expanded): + return deny(VENDOR_MESSAGE) + if all(personal(path, home) for path in expanded): + return allow(paths=expanded) + shown = [display(path, home) for path in expanded] + title = ("Edit " + os.path.basename(expanded[0])) if len(expanded) == 1 else f"Edit {len(expanded)} files" + card = {"kind": "edit", "title": title, "detail": reason or "\n".join(shown[:8]), + "path": shown[0], "diff": clip(diff, MAX_DIFF)} + return ask(card, key=("edit", tuple(sorted(expanded))), paths=expanded) + + +def edit_preview(tool, tool_input): + def lines(prefix, text): + return "\n".join(prefix + line for line in str(text).splitlines()[:40]) + if tool == "Edit": + return lines("- ", tool_input.get("old_string", "")) + "\n" + lines("+ ", tool_input.get("new_string", "")) + if tool == "MultiEdit": + parts = [] + for edit in (tool_input.get("edits") or [])[:6]: + if isinstance(edit, dict): + parts.append(lines("- ", edit.get("old_string", "")) + "\n" + lines("+ ", edit.get("new_string", ""))) + return "\n…\n".join(parts) + if tool == "Write": + return lines("+ ", tool_input.get("content", "")) + if tool == "NotebookEdit": + return lines("+ ", tool_input.get("new_source", "")) + return "" + + +# ---- commands -------------------------------------------------------------- + +def tokens(command): + lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|<>()") + lexer.whitespace_split = True + lexer.commenters = "" + return list(lexer) + + +def operator(token): + return bool(token) and all(character in OPERATORS for character in token) + + +def segments(command): + """Every command word list in a command line, for refusal; None if unparsable.""" + try: + words = tokens(command.replace("`", " ` ").replace("$(", " $( ")) + except ValueError: + return None + found, current = [], [] + for token in words: + if operator(token) or token in ("`", "$("): + if current: + found.append(current) + current = [] + else: + current.append(token) + if current: + found.append(current) + return found + + +def command_list(command): + """Pipelines joined by ;, && or ||, each a list of stages; None otherwise. + + Anything else a shell would interpret (redirection, background jobs, + subshells, substitution) makes the line not simple. + """ + if any(character in command for character in "`\n\r") or "$(" in command: + return None + try: + words = tokens(SAFE_REDIRECT.sub(" ", command)) + except ValueError: + return None + pipelines = [[[]]] + for token in words: + if token == "|": + pipelines[-1].append([]) + elif token in (";", "&&", "||"): + pipelines.append([[]]) + elif operator(token): + return None + else: + pipelines[-1][-1].append(token) + if pipelines[-1] == [[]] and len(pipelines) > 1: + pipelines.pop() + return pipelines if all(all(stages) for stages in pipelines) else None + + +def program(argv): + """The executable's name, or "" for a path outside the system profile.""" + name = argv[0] + if "/" in name: + if not name.startswith(TRUSTED_BIN): + return "" + name = os.path.basename(name) + return name + + +def refusal(argv): + words = list(argv) + while words and "=" in words[0] and not words[0].startswith("="): + words.pop(0) + if not words: + return "" + name = os.path.basename(words[0]) + if name == "env": + return refusal(words[1:]) + if name in ADMIN_COMMANDS: + return ADMIN_MESSAGE + if name == "nixos-rebuild": + return REBUILD_MESSAGE + if name in POWER_COMMANDS: + return POWER_MESSAGE + verbs = [word for word in words[1:] if not word.startswith("-")] + if name in ("systemctl", "loginctl") and verbs and verbs[0] in POWER_VERBS: + return POWER_MESSAGE + if name == "nerd" and verbs[:1] == ["update"] and len(verbs) > 1 \ + and verbs[1] in ("rollback", "confirm", "retry-failed"): + return RECOVERY_MESSAGE + return "" + + +def short_flags(args, letters): + return any(arg.startswith("-") and not arg.startswith("--") and any(letter in arg[1:] for letter in letters) + for arg in args) + + +def git_read_only(args): + args = list(args) + while args and args[0] in ("-C", "--no-pager", "-P"): + args = args[2:] if args[0] == "-C" else args[1:] + if not args or args[0].startswith("-"): + return False + verb, rest = args[0], args[1:] + if any(arg.startswith("--output") or arg in ("--ext-diff", "-c") for arg in rest): + return False + if verb in GIT_READ: + return True + if verb == "branch": + return all(arg in ("-a", "-r", "-v", "-vv", "--list", "--all", "--remotes", "--show-current") for arg in rest) + if verb == "remote": + return all(arg in ("-v", "--verbose") for arg in rest) + return False + + +def hyprctl_read_only(args): + args = list(args) + while args and args[0].startswith("-"): + if args[0] in ("-j", "--json"): + args = args[1:] + elif args[0] in ("-i", "--instance") and len(args) > 1: + args = args[2:] + else: + return False + return bool(args) and args[0] in HYPR_READ + + +def systemctl_read_only(args): + args = list(args) + while args and args[0].startswith("-"): + option = args[0] + if option in ("-H", "--host", "-M", "--machine") or option.startswith(("--host=", "--machine=")): + return False + args = args[2:] if option in SYSTEMCTL_VALUE_OPTIONS else args[1:] + return bool(args) and args[0] in SYSTEMCTL_READ + + +def nerd_read_only(args): + verbs = list(args) + if verbs == ["version"]: + return True + head = verbs[:2] + return (head[:1] == ["update"] and head[1:] in (["status"], ["check"], ["history"], ["log"]) + or head[:1] == ["software"] and head[1:] in (["list"], ["search"], ["request"]) + or head == ["plugin", "list"] + or head[:1] == ["agent"] and head[1:] in (["list"], ["get"]) + or head[:1] == ["maintenance"] and head[1:] in (["status"], ["request"]) + or head[:1] == ["assistant"] and head[1:] == ["status"]) + + +def read_only(argv): + name = program(argv) + args = argv[1:] + if not name or any("$" in word and not HOME_VARIABLE.match(word) for word in argv): + return False + if name in SIMPLE_READ: + if name == "find": + return not any(arg in FIND_ACTIONS for arg in args) + if name == "fd": + return not any(arg in ("--exec", "--exec-batch") or arg.startswith(("--exec=", "--exec-batch=")) + for arg in args) and not short_flags(args, "xX") + if name == "rg": + return not any(arg == "--pre" or arg.startswith("--pre=") for arg in args) + if name == "sort": + return not any(arg.startswith("--output") for arg in args) and not short_flags(args, "o") + if name == "uniq": + return len([arg for arg in args if not arg.startswith("-")]) <= 1 + if name == "tree": + return "-o" not in args + if name == "yq": + return not any(arg.startswith("--inplace") for arg in args) and not short_flags(args, "i") + if name == "date": + return not any(arg.startswith("--set") for arg in args) and not short_flags(args, "s") + if name == "hostname": + return all(arg.startswith("-") for arg in args) + if name == "command": + return args[:1] == ["-v"] + return True + if name == "git": + return git_read_only(args) + if name == "hyprctl": + return hyprctl_read_only(args) + if name == "nerd": + return nerd_read_only(args) + if name == "systemctl": + return systemctl_read_only(args) + if name == "journalctl": + return not any(arg.startswith(JOURNAL_WRITES) for arg in args) + # Evaluation is not reading: an expression can read the environment, + # fetch from the network and build through import-from-derivation. + if name == "nix": + return args == ["--version"] + return False + + +def read_json(path): + try: + value = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def apply_card(command, transaction, status_path): + status = read_json(status_path) if status_path else {} + same = status.get("transaction") == transaction + detail = (status.get("phase") if same else "") or \ + "Review the package changes, then authenticate as administrator to apply them." + return {"kind": "apply", "title": "Apply the staged change", "detail": clip(detail, 400), + "command": command, "transaction": transaction, + "changes": clip(status.get("changes", ""), MAX_CHANGES) if same else "", + "ready": bool(same and status.get("state") == "ready")} + + +def decide_command(command, home=None, status_path=None, depth=0): + if not isinstance(command, str) or not command.strip(): + return ask({"kind": "command", "title": "Run a command", "detail": "", "command": ""}) + command = command.strip() + for argv in segments(command) or []: + message = refusal([word.lstrip("`$(") for word in argv]) + if message: + return deny(message) + pipelines = command_list(command) + if pipelines is not None and len(pipelines) == 1 and len(pipelines[0]) == 1: + argv = pipelines[0][0] + name = program(argv) + if name in SHELLS and len(argv) == 3 and argv[1] in ("-c", "-lc", "-cl", "-ec", "-ce") and depth < 3: + inner = decide_command(argv[2], home, status_path, depth + 1) + if inner.action != "ask": + return inner + if name == "nerd": + verbs = argv[1:] + if verbs[:2] == ["update", "stage"] and len(verbs) == 3 and verbs[2] in STAGE_SCOPES: + return allow(step="Waiting for administrator authentication") + if verbs[:2] == ["software", "stage"] and len(verbs) == 3: + return allow(step="Waiting for administrator authentication") + if verbs[:2] == ["update", "apply"] and len(verbs) == 4: + card = apply_card(command, verbs[2], status_path) + return ask(card, key=None) + if pipelines is not None and all(read_only(stage) for stages in pipelines for stage in stages): + return allow() + card = {"kind": "command", "title": "Run a command", "detail": "", "command": clip(command, 4000)} + return ask(card, key=("command", command)) + + +def decide_tool(name, tool_input, home, status_path=None): + """Claude Code's can_use_tool: one tool call and its input.""" + tool_input = tool_input if isinstance(tool_input, dict) else {} + if name in READ_ONLY_TOOLS: + return allow() + if name == "Bash": + return decide_command(tool_input.get("command"), home, status_path) + if name in EDIT_TOOLS: + path = tool_input.get(EDIT_TOOLS[name]) + decision = decide_edit([path] if isinstance(path, str) else [], home, edit_preview(name, tool_input)) + return decision + if name.startswith("mcp__"): + parts = name.split("__") + server, tool = (parts[1], "__".join(parts[2:])) if len(parts) > 2 else ("", name) + title = f"Use {tool}" + (f" from {server}" if server else "") + else: + title = f"Use {name}" + detail = clip(json.dumps(tool_input, ensure_ascii=False, indent=1), 1500) + return ask({"kind": "other", "title": title, "detail": detail, "tool": name}, key=("tool", name)) + + +# ---- undo ------------------------------------------------------------------ + +WATCHED = (".config/nerdos/shell.json", ".config/nerdos/displays.json", ".config/nerdos/hypr/user.lua") +MAX_SNAPSHOT = 8 * 1024 * 1024 +UNSNAPSHOTTABLE = object() + + +def file_state(path): + """None when absent, (bytes, mode) for a regular file, or UNSNAPSHOTTABLE.""" + try: + status = os.lstat(path) + except FileNotFoundError: + return None + except OSError: + return UNSNAPSHOTTABLE + if not os.path.isfile(path) or os.path.islink(path) or status.st_size > MAX_SNAPSHOT: + return UNSNAPSHOTTABLE + try: + return Path(path).read_bytes(), status.st_mode & 0o7777 + except OSError: + return UNSNAPSHOTTABLE + + +def content(state): + return None if state is None else state[0] + + +def write_atomic(path, data, mode): + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + handle, temporary = tempfile.mkstemp(prefix=".nerdos-assistant-", dir=directory) + try: + with os.fdopen(handle, "wb") as stream: + os.fchmod(stream.fileno(), mode) + stream.write(data) + os.replace(temporary, path) + except BaseException: + try: + os.unlink(temporary) + except OSError: + pass + raise + + +class Snapshots: + """Pre-images of the files one turn may change, so Undo can restore them.""" + + def __init__(self, home): + self.home = str(home) + self.before = {} + self.after = {} + + def begin(self): + for relative in WATCHED: + self.take(os.path.join(self.home, relative)) + + def take(self, path): + path = expand(path, self.home) + if path not in self.before: + self.before[path] = file_state(path) + + def finish(self): + """Record post-images; returns the changed paths for display.""" + self.after = {} + for path, before in self.before.items(): + if before is UNSNAPSHOTTABLE: + continue + now = file_state(path) + if now is UNSNAPSHOTTABLE or content(now) == content(before): + continue + self.after[path] = now + return [display(path, self.home) for path in sorted(self.after)] + + def undo(self): + restored, skipped = [], [] + plugins = os.path.join(self.home, ".local/share/nerdos/plugins") + for path, after in sorted(self.after.items()): + shown = display(path, self.home) + now = file_state(path) + if now is UNSNAPSHOTTABLE or content(now) != content(after): + skipped.append(shown) + continue + before = self.before[path] + try: + if before is None: + if now is not None: + os.unlink(path) + # A widget the turn created leaves no empty folders behind. + parent = os.path.dirname(path) + while under(parent, plugins) and parent != plugins: + try: + os.rmdir(parent) + except OSError: + break + parent = os.path.dirname(parent) + else: + write_atomic(path, before[0], before[1]) + except OSError: + skipped.append(shown) + continue + restored.append(shown) + self.after = {} + return restored, skipped + + +# ---- what a turn produced --------------------------------------------------- + +PLUGIN_ID = re.compile(r"^[a-z0-9]+(\.[a-z0-9-]+)+$") + + +def plugin_manifests(home): + root = Path(home) / ".local/share/nerdos/plugins" + found = {} + try: + manifests = sorted(root.glob("*/manifest.json")) + except OSError: + return found + for manifest in manifests: + data = read_json(manifest) + identifier = data.get("id") + if isinstance(identifier, str) and PLUGIN_ID.fullmatch(identifier): + name = data.get("name") + found[identifier] = clip(name, 80) if isinstance(name, str) and name.strip() else identifier + return found + + +def new_plugins(before, after): + return [{"id": identifier, "name": after[identifier]} for identifier in sorted(after) if identifier not in before] + + +def staged_transaction(before, after): + transaction = after.get("transaction") + if after.get("state") == "ready" and isinstance(transaction, str) and transaction \ + and transaction != before.get("transaction"): + return transaction + return "" diff --git a/desktop/assistant/nerdos_assistant/prompt.py b/desktop/assistant/nerdos_assistant/prompt.py new file mode 100644 index 00000000..3a143af4 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/prompt.py @@ -0,0 +1,87 @@ +"""Instructions appended to the agent's own system prompt, and per-turn notes. + +The system prompt stays the same for a whole conversation so a long-lived +agent process never has to restart when a reply should be spoken or a +screenshot is attached; those arrive as a short note in the user message. +""" + +from __future__ import annotations + +import datetime +import json +from pathlib import Path + + +INSTRUCTIONS = """\ +You are the NerdOS Assistant, a small character floating on the person's NerdOS desktop. +Your replies appear in a box about 360 pixels wide under the character. + +How to reply: +- Lead with the answer. Keep it short: a few sentences or a short list. +- Markdown is fine, but do not use tables or headings. +- A user message may start with a note in square brackets from NerdOS, for example that a + screenshot of the display the person is looking at is attached, or that your reply will be + read aloud. When it will be read aloud, write plain speakable sentences: no code blocks, no + list longer than three items, under about 80 words. + +How to work on this computer: +- For anything about NerdOS, use the installed `nerdos` skill and follow its ownership rules. + Vendor files are read-only. +- Desktop preferences apply live through ~/.config/nerdos/shell.json; no rebuild is needed. + Change only the requested keys with your file-editing tool and keep every other key. The + assistant snapshots the file before you edit it, so skip the skill's manual backup and jq + replacement steps here; check accepted values in + /etc/nerdos/runtime/quickshell/Common/SettingsHelpers.js. The person can undo your + preference changes from the reply. +- Create widgets as personal plugins under ~/.local/share/nerdos/plugins/ and leave them + disabled. The assistant offers the person an "Add to bar" button. +- Stage system changes with `nerd software stage` or `nerd update stage local`, inspect + `nerd update status`, then request `nerd update apply TRANSACTION REVIEW_TOKEN`. The person + reviews the actual package changes and authenticates in the assistant's card. +- Never use sudo, nixos-rebuild, or restart or power off the computer. +- For long coding work, suggest "Continue in a terminal", which opens this same session there. +""" + + +def release(path="/etc/nerdos/release.json"): + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return "" + version = data.get("version") if isinstance(data, dict) else None + return version if isinstance(version, str) else "" + + +def features(directory="/etc/nerdos"): + try: + names = sorted(path.name[:-len("-enabled")] for path in Path(directory).glob("*-enabled")) + except OSError: + return [] + return names + + +def build(today=None, release_path="/etc/nerdos/release.json", feature_dir="/etc/nerdos"): + today = today or datetime.date.today() + lines = [INSTRUCTIONS, f"Today is {today.strftime('%A %d %B %Y')}."] + version = release(release_path) + if version: + lines.append(f"This computer runs NerdOS {version} on NixOS.") + enabled = features(feature_dir) + if enabled: + lines.append("Installed NerdOS feature groups: " + ", ".join(enabled) + ".") + return "\n".join(lines) + + +def turn_text(text, context=None, screenshot=False): + """The person's words, preceded by a NerdOS note when there is one.""" + context = context if isinstance(context, dict) else {} + notes = [] + output = context.get("output") + if screenshot: + where = f" ({output})" if isinstance(output, str) and output else "" + notes.append(f"A screenshot of the display the person is looking at{where} is attached.") + if context.get("speak") is True: + notes.append("Your reply will be read aloud.") + if not notes: + return text + return "[NerdOS: " + " ".join(notes) + "]\n" + text diff --git a/desktop/assistant/nerdos_assistant/protocol.py b/desktop/assistant/nerdos_assistant/protocol.py new file mode 100644 index 00000000..2ec9a2cb --- /dev/null +++ b/desktop/assistant/nerdos_assistant/protocol.py @@ -0,0 +1,136 @@ +"""Wire framing, limits and paths shared by the service and its clients.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from pathlib import Path +import time + + +LOG = logging.getLogger("nerdos-assistant") +PROTOCOL = 1 +# One JSON object per line. The shell never sends images through the socket +# (only a path), so a line this long is a confused or hostile client. +MAX_LINE = 4 * 1024 * 1024 +# Agent CLIs echo tool results, which can be large files; a line beyond this +# is dropped rather than buffered without bound. +MAX_AGENT_LINE = 64 * 1024 * 1024 +MAX_PROMPT = 32 * 1024 +MAX_SCREENSHOT = 20 * 1024 * 1024 +# Frames queued for one client: a long streamed reply is one frame per +# chunk. A shell that stops reading is disconnected (it reconciles with +# hello) rather than holding every agent event in memory. +MAX_CLIENT_BACKLOG = 16384 + +BUSY = -32001 +NOT_FOUND = -32002 +UNAVAILABLE = -32003 + + +class RpcFault(Exception): + """A JSON-RPC error safe to return to a local client.""" + + def __init__(self, code, message): + super().__init__(message) + self.code = code + self.message = message + + +def encode(frame): + return (json.dumps(frame, ensure_ascii=False, separators=(",", ":")) + "\n").encode() + + +def result_frame(request_id, result): + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def error_frame(request_id, code, message): + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + + +def notification(method, params): + return {"jsonrpc": "2.0", "method": method, "params": params} + + +def now_ms(): + return int(time.time() * 1000) + + +def runtime_dir(env=os.environ): + return Path(env.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") + + +def default_socket(env=os.environ): + return runtime_dir(env) / "nerdos" / "assistant.sock" + + +def screenshot_dir(env=os.environ): + return runtime_dir(env) / "nerdos" / "assistant" + + +def default_state_dir(home): + return Path(home) / ".local" / "state" / "nerdos" / "assistant" + + +async def iter_lines(reader, limit=MAX_AGENT_LINE): + """Yield complete lines without the newline; an overlong line is skipped. + + StreamReader.readline() would raise and leave the reader mid-line, so + chunks are split here and an oversized line is discarded up to its end. + """ + buffer = bytearray() + discarding = False + while True: + chunk = await reader.read(65536) + if not chunk: + if buffer and not discarding: + yield bytes(buffer) + return + start = 0 + while True: + end = chunk.find(b"\n", start) + if end == -1: + if not discarding: + buffer += chunk[start:] + if len(buffer) > limit: + LOG.warning("dropping a line longer than %d bytes", limit) + buffer.clear() + discarding = True + break + if discarding: + discarding = False + else: + buffer += chunk[start:end] + if len(buffer) <= limit: + yield bytes(buffer) + buffer.clear() + start = end + 1 + + +def parse_json(line): + try: + value = json.loads(line) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +async def stop_process(process, grace=3.0): + """Terminate a child and wait for it, escalating to SIGKILL.""" + if process is None or process.returncode is not None: + return + try: + process.terminate() + except ProcessLookupError: + return + try: + await asyncio.wait_for(process.wait(), grace) + except asyncio.TimeoutError: + try: + process.kill() + except ProcessLookupError: + return + await process.wait() diff --git a/desktop/assistant/nerdos_assistant/server.py b/desktop/assistant/nerdos_assistant/server.py new file mode 100644 index 00000000..50970d67 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/server.py @@ -0,0 +1,576 @@ +"""The socket, the conversation and the current turn. + +The service owns the agent processes so a Quickshell restart, including one +the assistant's own change caused, never ends a turn. Clients reconcile with +`hello`; nothing is replayed after a reconnect. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +from pathlib import Path +import re +import secrets +import shutil +import signal +import socket +import tempfile +import time + +from . import auth, policy, prompt, speech +from .claude import ClaudeAgent, media_type +from .codex import CodexAgent +from .protocol import ( + BUSY, LOG, MAX_CLIENT_BACKLOG, MAX_LINE, MAX_PROMPT, MAX_SCREENSHOT, NOT_FOUND, PROTOCOL, + UNAVAILABLE, RpcFault, default_state_dir, encode, error_frame, iter_lines, notification, + now_ms, parse_json, result_frame, screenshot_dir, +) +from .turn import FINAL, Turn + + +AGENTS = {"claude": ClaudeAgent, "codex": CodexAgent} +LABELS = {"claude": "Claude Code", "codex": "Codex"} +DISCOVERY_SECONDS = 30.0 +STOP_WAIT = 8.0 +CONVERSATION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,200}$") +MODEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,79}$") +LANGUAGE = re.compile(r"^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})?$") +RESTARTED = "The assistant service restarted." + + +class Client: + """One connection; frames are queued so a slow reader never blocks the others.""" + + def __init__(self, writer): + self.writer = writer + self.queue = asyncio.Queue() + self.closed = False + self.task = asyncio.create_task(self._write()) + + def send(self, frame): + if self.closed: + return + if self.queue.qsize() >= MAX_CLIENT_BACKLOG: + LOG.warning("disconnecting a client that stopped reading") + self.close() + return + self.queue.put_nowait(frame) + + async def _write(self): + try: + while True: + frame = await self.queue.get() + self.writer.write(frame) + await self.writer.drain() + except (ConnectionError, OSError): + self.close() + + def close(self): + if self.closed: + return + self.closed = True + self.task.cancel() + with contextlib.suppress(Exception): + self.writer.close() + + +class Server: + def __init__(self, socket_path, state_dir=None, home=None, env=None): + self.env = dict(os.environ if env is None else env) + self.home = Path(home or self.env.get("HOME") or Path.home()) + self.socket_path = Path(socket_path) + self.state_path = Path(state_dir or default_state_dir(self.home)) / "state.json" + self.status_path = self.env.get("NERDOS_ASSISTANT_STATUS") or "/var/lib/nerdos/status.json" + self.shots = screenshot_dir(self.env) + self.instructions = prompt.build() + self.clients = set() + self.tasks = set() + self.adapters = {} + self.conversation = {"agent": None, "claude": None, "codex": None} + self.memory = set() + self.turn = None + self.task = None + self.discovered = (0.0, {}) + self.authentication = {} + self.auth_lock = asyncio.Lock() + self.speaker = speech.Speaker(speech.default_socket(self.env), self.publish) + self.listener = None + self.closing = False + self.methods = { + "hello": self.hello, "turn.start": self.turn_start, "turn.stop": self.turn_stop, + "approval.respond": self.approval_respond, "turn.undo": self.turn_undo, + "conversation.reset": self.conversation_reset, "conversation.handoff": self.conversation_handoff, + "speech.say": self.speech_say, "speech.stop": self.speech_stop, + "auth.status": self.auth_status, "auth.login": self.auth_login, + } + self._load() + + # ---- agents ------------------------------------------------------------ + + def agent_env(self): + env = dict(self.env) + local = str(self.home / ".local" / "bin") + if local not in env.get("PATH", "").split(os.pathsep): + env["PATH"] = os.pathsep.join(filter(None, [env.get("PATH", ""), local])) + return env + + def agents(self): + stamp, found = self.discovered + if time.monotonic() - stamp < DISCOVERY_SECONDS and found: + return found + found = {} + search = self.agent_env()["PATH"] + for name in AGENTS: + override = self.env.get("NERDOS_ASSISTANT_" + name.upper()) + if override: + found[name] = override if os.path.isfile(override) and os.access(override, os.X_OK) else None + else: + found[name] = shutil.which(name, path=search) + self.discovered = (time.monotonic(), found) + return found + + def preferred(self): + config = Path(self.env.get("XDG_CONFIG_HOME") or self.home / ".config") + try: + value = (config / "nerdos" / "defaults" / "agent").read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return None + return value if value in AGENTS else None + + def resolve(self, requested, statuses=None): + if requested in AGENTS: + return requested + found = self.agents() + preferred = self.preferred() + order = ([preferred] if preferred else []) + list(AGENTS) + if statuses: + for name in order: + if statuses.get(name, {}).get('auth') == 'ready': + return name + for name in order: + if found.get(name): + return name + return preferred or "claude" + + def adapter(self, name, executable): + adapter = self.adapters.get(name) + if adapter is not None and adapter.executable != executable: + self._spawn(adapter.close()) + adapter = None + if adapter is None: + adapter = AGENTS[name](executable, self.home, self.instructions, status_path=self.status_path, + conversation_id=self.conversation.get(name), + on_conversation=self._conversation_id, env=self.agent_env()) + self.adapters[name] = adapter + return adapter + + def _conversation_id(self, agent, identifier): + self.conversation[agent] = identifier or None + self.conversation["agent"] = agent if identifier else None + self.save() + if self.turn is not None: + self._turn_event("updated", self.turn) + + async def _reset_conversation(self): + adapters, self.adapters = list(self.adapters.values()), {} + for adapter in adapters: + await adapter.close() + self.memory = set() + self.conversation = {"agent": None, "claude": None, "codex": None} + self.save() + + # ---- notifications ----------------------------------------------------- + + def _spawn(self, coroutine): + task = asyncio.create_task(coroutine) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + return task + + def publish(self, method, params): + frame = encode(notification(method, params)) + for client in list(self.clients): + client.send(frame) + + def resumable(self, turn): + return self.conversation.get("agent") == turn.agent and bool(self.conversation.get(turn.agent)) + + def snapshot(self, turn): + return turn.snapshot(self.resumable(turn)) if turn is not None else None + + def _turn_event(self, kind, turn, text=None): + if turn is not self.turn: + return + if kind == "delta": + self.publish("turn.delta", {"id": turn.id, "text": text}) + else: + self.publish("turn.updated", {"turn": self.snapshot(turn)}) + + # ---- methods ----------------------------------------------------------- + + async def hello(self, params): + agent = self.conversation.get("agent") + return { + "protocol": PROTOCOL, + "agents": await self.agent_status(), + "turn": self.snapshot(self.turn), + "conversation": {"agent": agent, "resumable": bool(self.conversation.get(agent))} if agent else None, + "speech": {"available": self.speaker.available}, + } + + async def agent_status(self, refresh=False): + async with self.auth_lock: + if refresh: + self.discovered = (0.0, {}) + found = self.agents() + async def inspect(name): + executable = found.get(name) + if not executable: + self.authentication.pop(name, None) + return name, {"available": False, "label": LABELS[name], "auth": "missing"} + stamp, previous_executable, state = self.authentication.get(name, (0.0, None, "unknown")) + if refresh or previous_executable != executable or time.monotonic() - stamp >= DISCOVERY_SECONDS: + state = await auth.status(name, executable, self.home, self.agent_env()) + self.authentication[name] = (time.monotonic(), executable, state) + return name, {"available": True, "label": LABELS[name], "auth": state} + return dict(await asyncio.gather(*(inspect(name) for name in AGENTS))) + + async def auth_status(self, params): + agents = await self.agent_status(refresh=True) + self.publish("agents.updated", {"agents": agents}) + return {"agents": agents} + + async def auth_login(self, params): + name = params.get('agent') + if name not in AGENTS: + raise RpcFault(-32602, 'Choose Claude Code or Codex to sign in.') + if self.busy(): + raise RpcFault(BUSY, 'Wait for the current question before signing in.') + self.discovered = (0.0, {}) + executable = self.agents().get(name) + if not executable: + raise RpcFault(UNAVAILABLE, f'{LABELS[name]} is not installed. Enable it in Software first.') + adapter = self.adapters.pop(name, None) + if adapter is not None: + await adapter.close() + self.authentication.pop(name, None) + # A terminal runs the agent's own browser authentication. Credentials + # never pass through the assistant, QML or the JSON-RPC connection. + return {"argv": [executable, *auth.LOGIN_ARGS[name]], "cwd": str(self.home)} + + def busy(self): + return self.task is not None and not self.task.done() + + def screenshot(self, value): + if value in (None, ""): + return None + if not isinstance(value, str): + raise RpcFault(-32602, "Invalid screenshot") + try: + real = Path(value).resolve(strict=True) + root = self.shots.resolve(strict=True) + except OSError: + raise RpcFault(-32602, "The screenshot is no longer available.") from None + if not policy.under(str(real), str(root)) or real == root or not real.is_file() \ + or real.suffix.lower() not in (".png", ".jpg", ".jpeg") or real.stat().st_size > MAX_SCREENSHOT: + raise RpcFault(-32602, "The screenshot must be a PNG or JPEG taken by the assistant.") + return real + + async def turn_start(self, params): + if self.busy(): + raise RpcFault(BUSY, "The assistant is still working on the previous question.") + text = params.get("text") + if not isinstance(text, str) or not text.strip(): + raise RpcFault(-32602, "Ask a question first.") + text = text.strip() + if len(text) > MAX_PROMPT: + raise RpcFault(-32602, "That question is too long.") + requested = params.get("agent") or "auto" + if requested not in ("auto", *AGENTS): + raise RpcFault(-32602, "Unknown agent") + model = params.get("model") + if model in (None, "", "default"): + model = None + elif not isinstance(model, str) or not MODEL.fullmatch(model): + raise RpcFault(-32602, "Invalid model") + context = params.get("context") if isinstance(params.get("context"), dict) else {} + shot = self.screenshot(params.get("screenshot")) + statuses = await self.agent_status() + # Auth probing yields to other clients; a second start may have won. + if self.busy(): + raise RpcFault(BUSY, "The assistant is still working on the previous question.") + agent = self.resolve(requested, statuses) + if statuses[agent]['available'] and statuses[agent]['auth'] != 'ready': + raise RpcFault(-32004, f'Sign in to {LABELS[agent]} before asking the assistant. Your question has been kept.') + if self.conversation.get("agent") not in (None, agent): + await self._reset_conversation() + turn = Turn("t-" + secrets.token_hex(8), agent, text, self.home, self.memory, self._turn_event, + screenshot=shot is not None) + turn.snapshots.begin() + plugins = policy.plugin_manifests(self.home) + status = policy.read_json(self.status_path) + self.turn = turn + self.task = asyncio.create_task(self._run(turn, text, shot, model, context, plugins, status, requested)) + self.save() + self._turn_event("updated", turn) + return {"turn": self.snapshot(turn)} + + async def _run(self, turn, text, shot, model, context, plugins, status, requested): + try: + executable = self.agents().get(turn.agent) + if not executable: + message = (f"{LABELS[turn.agent]} is not installed." if requested != "auto" + else "Neither Claude Code nor Codex is installed.") + turn.finish("failed", error=message, kind="missing") + return + image = None + if shot is not None: + image = (shot.read_bytes(), media_type(shot)) if turn.agent == "claude" else str(shot) + adapter = self.adapter(turn.agent, executable) + outcome = await adapter.run(turn, prompt.turn_text(text, context, shot is not None), image, model) + turn.finish(outcome.get("status", "failed"), outcome.get("text"), outcome.get("error", ""), + outcome.get("kind", "")) + if turn.error_kind == 'auth': + self.authentication[turn.agent] = (time.monotonic(), executable, 'required') + self.publish('agents.updated', {'agents': await self.agent_status()}) + except FileNotFoundError: + turn.finish("failed", error=f"{LABELS[turn.agent]} is not installed.", kind="missing") + except asyncio.CancelledError: + if self.closing: + turn.finish("failed", error=RESTARTED, kind="crashed") + else: + turn.finish("stopped") + raise + except Exception as error: + LOG.exception("turn failed") + turn.finish("failed", error=str(error) or "The assistant could not finish.", kind="crashed") + finally: + if shot is not None: + with contextlib.suppress(OSError): + shot.unlink() + if not turn.finished: + turn.finish("failed", error="The assistant could not finish.", kind="crashed") + turn.undo = turn.snapshots.finish() + turn.plugins = policy.new_plugins(plugins, policy.plugin_manifests(self.home)) + turn.staged = policy.staged_transaction(status, policy.read_json(self.status_path)) + self.save() + self._turn_event("updated", turn) + + async def turn_stop(self, params): + turn, task = self.turn, self.task + if task is None or task.done(): + return {"stopped": False} + turn.stopping = True + turn.deny_all() + adapter = self.adapters.get(turn.agent) + if adapter is not None: + await adapter.interrupt() + try: + await asyncio.wait_for(asyncio.shield(task), STOP_WAIT) + except asyncio.TimeoutError: + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + return {"stopped": True} + + async def approval_respond(self, params): + decision = params.get("decision") + if decision not in ("allow", "always", "deny"): + raise RpcFault(-32602, "Invalid decision") + if self.turn is None or not self.turn.respond(params.get("id"), decision): + raise RpcFault(NOT_FOUND, "That approval is no longer pending.") + return {"ok": True} + + async def turn_undo(self, params): + turn = self.turn + if turn is None or turn.id != params.get("id") or not turn.finished or not turn.undo: + raise RpcFault(NOT_FOUND, "There is nothing to undo.") + restored, skipped = turn.snapshots.undo() + turn.undo = [] + self.save() + self._turn_event("updated", turn) + return {"restored": restored, "skipped": skipped} + + async def conversation_reset(self, params): + if self.busy(): + raise RpcFault(BUSY, "The assistant is still working on the previous question.") + await self._reset_conversation() + if self.turn is not None: + self._turn_event("updated", self.turn) + return {"ok": True} + + async def conversation_handoff(self, params): + if self.busy(): + raise RpcFault(BUSY, "Wait until the assistant has answered, then continue in a terminal.") + agent = self.conversation.get("agent") + identifier = self.conversation.get(agent) if agent else None + if not identifier: + raise RpcFault(UNAVAILABLE, "There is no conversation to continue.") + executable = self.agents().get(agent) or agent + argv = [executable, "--resume", identifier] if agent == "claude" else [executable, "resume", identifier] + # The terminal continues the session from disk; a later question + # here resumes it again instead of forking the history. + adapter = self.adapters.pop(agent, None) + if adapter is not None: + await adapter.close() + return {"argv": argv, "cwd": str(self.home)} + + async def speech_say(self, params): + text = params.get("text") + if not isinstance(text, str) or len(text) > 64 * 1024: + raise RpcFault(-32602, "Invalid text") + language = params.get("language") + if language is not None and (not isinstance(language, str) or not LANGUAGE.fullmatch(language)): + language = None + return await self.speaker.say(text, language) + + async def speech_stop(self, params): + await self.speaker.stop() + return {"ok": True} + + # ---- persistence ------------------------------------------------------- + + def _load(self): + data = policy.read_json(self.state_path) + conversation = data.get("conversation") if isinstance(data.get("conversation"), dict) else {} + for name in AGENTS: + value = conversation.get(name) + if isinstance(value, str) and CONVERSATION_ID.fullmatch(value): + self.conversation[name] = value + if conversation.get("agent") in AGENTS and self.conversation.get(conversation["agent"]): + self.conversation["agent"] = conversation["agent"] + stored = data.get("turn") + if isinstance(stored, dict) and stored.get("agent") in AGENTS and isinstance(stored.get("id"), str): + turn = Turn(stored["id"], stored["agent"], str(stored.get("prompt") or ""), self.home, + self.memory, self._turn_event, screenshot=bool(stored.get("screenshot"))) + turn.text = str(stored.get("text") or "") + turn.started_at = int(stored.get("startedAt") or 0) + if stored.get("status") in FINAL: + turn.status = stored["status"] + turn.error = str(stored.get("error") or "") + turn.error_kind = str(stored.get("errorKind") or "") + turn.finished_at = int(stored.get("finishedAt") or 0) + else: + turn.status, turn.error, turn.error_kind, turn.finished_at = "failed", RESTARTED, "crashed", now_ms() + turn.step = "" + plugins = stored.get("plugins") + turn.plugins = [entry for entry in plugins if isinstance(entry, dict) + and isinstance(entry.get("id"), str) and policy.PLUGIN_ID.fullmatch(entry["id"])] \ + if isinstance(plugins, list) else [] + turn.staged = stored.get("staged") if isinstance(stored.get("staged"), str) else "" + # Pre-images lived in the previous process; Undo cannot survive it. + turn.undo = [] + self.turn = turn + + def save(self): + turn = self.snapshot(self.turn) + if turn is not None: + turn["approvals"] = [] + document = {"version": 1, "conversation": self.conversation, "turn": turn} + try: + self.state_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + handle, temporary = tempfile.mkstemp(prefix=".state-", dir=self.state_path.parent) + with os.fdopen(handle, "wb") as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(encode(document)) + os.replace(temporary, self.state_path) + except OSError as error: + LOG.warning("could not save assistant state: %s", error) + + # ---- socket ------------------------------------------------------------ + + async def _connection(self, reader, writer): + client = Client(writer) + self.clients.add(client) + try: + async for line in iter_lines(reader, MAX_LINE): + message = parse_json(line) + if message is None: + client.send(encode(error_frame(None, -32700, "Parse error"))) + continue + self._spawn(self._request(client, message)) + except (ConnectionError, OSError): + pass + finally: + self.clients.discard(client) + client.close() + + async def _request(self, client, message): + identifier = message.get("id") + method = message.get("method") + params = message.get("params") if message.get("params") is not None else {} + try: + if not isinstance(method, str) or not isinstance(params, dict): + raise RpcFault(-32600, "Invalid request") + handler = self.methods.get(method) + if handler is None: + raise RpcFault(-32601, f"Unknown method {method}") + result = await handler(params) + except RpcFault as fault: + if identifier is not None: + client.send(encode(error_frame(identifier, fault.code, fault.message))) + except Exception: + LOG.exception("%s failed", method) + if identifier is not None: + client.send(encode(error_frame(identifier, -32603, "Internal error"))) + else: + if identifier is not None: + client.send(encode(result_frame(identifier, result))) + + def _prepare(self): + for directory in (self.socket_path.parent, self.shots): + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + with contextlib.suppress(OSError): + os.chmod(directory, 0o700) + # A screenshot belongs to one question; none survives the service. + for leftover in self.shots.iterdir(): + if leftover.is_file() or leftover.is_symlink(): + with contextlib.suppress(OSError): + leftover.unlink() + if self.socket_path.exists() or self.socket_path.is_symlink(): + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.settimeout(1) + probe.connect(str(self.socket_path)) + except OSError: + self.socket_path.unlink() + else: + raise SystemExit(f"nerdos-assistant: already running at {self.socket_path}") + finally: + probe.close() + + async def start(self): + self._prepare() + self.listener = await asyncio.start_unix_server(self._connection, path=str(self.socket_path)) + os.chmod(self.socket_path, 0o600) + + async def shutdown(self): + self.closing = True + if self.listener is not None: + self.listener.close() + for client in list(self.clients): + client.close() + await self.speaker.stop() + if self.busy(): + self.task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self.task + for adapter in list(self.adapters.values()): + await adapter.close() + self.save() + with contextlib.suppress(OSError): + self.socket_path.unlink() + for task in list(self.tasks): + task.cancel() + + async def serve(self): + await self.start() + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for number in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(number, stop.set) + LOG.info("listening on %s", self.socket_path) + await stop.wait() + await self.shutdown() diff --git a/desktop/assistant/nerdos_assistant/speech.py b/desktop/assistant/nerdos_assistant/speech.py new file mode 100644 index 00000000..bb6772a7 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/speech.py @@ -0,0 +1,251 @@ +"""Spoken replies through speech-dispatcher, one sentence per message. + +Speaking sentence by sentence lets the reply follow along (speech.progress) +and lets Stop cancel exactly this client's speech: SSIP's `CANCEL self` leaves +a screen reader talking through the same server untouched. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import getpass +import os +from pathlib import Path +import re +import shutil + +from .protocol import LOG + + +MAX_SENTENCES = 40 +CODE_NOTE = "I put the code in the reply." + +_FENCE = re.compile(r"```.*?(?:```|\Z)|~~~.*?(?:~~~|\Z)", re.S) +_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)") +_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)") +_AUTOLINK = re.compile(r"<(https?://[^>]+)>") +_CODE = re.compile(r"`([^`]*)`") +_EMPHASIS = ( + re.compile(r"\*\*(?=\S)(.+?)(?<=\S)\*\*"), + re.compile(r"(?]*>") +_PREFIX = re.compile(r"^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)") +_RULE = re.compile(r"^[\s\-*_=|:]+$") +_SENTENCE_END = re.compile(r"(?<=[.!?…])\s+(?=[\"'(\[]?[A-Z0-9À-ÖØ-Þ])") + + +def sentences(markdown, limit=MAX_SENTENCES): + """Markdown as plain sentences that read well aloud.""" + text = _FENCE.sub("\n" + CODE_NOTE + "\n", str(markdown or "")) + text = _IMAGE.sub(r"\1", text) + text = _LINK.sub(r"\1", text) + text = _AUTOLINK.sub(r"\1", text) + text = _CODE.sub(r"\1", text) + for pattern in _EMPHASIS: + text = pattern.sub(r"\1", text) + text = _TAG.sub("", text) + found = [] + for line in text.splitlines(): + line = _PREFIX.sub("", line).strip() + if not line or _RULE.fullmatch(line): + continue + for part in _SENTENCE_END.split(line): + part = " ".join(part.split()) + if part and not (part == CODE_NOTE and found and found[-1] == CODE_NOTE): + found.append(part) + return found[:limit] + + +class SsipError(Exception): + pass + + +class Ssip: + """A minimal SSIP client: commands, replies and event notifications.""" + + def __init__(self): + self.reader = None + self.writer = None + self.replies = asyncio.Queue() + self.events = asyncio.Queue() + self.task = None + + async def connect(self, path): + self.reader, self.writer = await asyncio.wait_for(asyncio.open_unix_connection(str(path)), 5) + self.task = asyncio.create_task(self._read()) + + async def _read(self): + lines = [] + try: + while True: + raw = await self.reader.readline() + if not raw: + break + line = raw.decode("utf-8", "replace").rstrip("\r\n") + if len(line) < 4 or not line[:3].isdigit(): + continue + lines.append(line[4:]) + if line[3] == "-": + continue + code, body = int(line[:3]), lines + lines = [] + (self.events if 700 <= code < 800 else self.replies).put_nowait((code, body)) + finally: + self.replies.put_nowait((0, ["connection closed"])) + self.events.put_nowait((0, ["connection closed"])) + + async def command(self, line, timeout=5): + self.writer.write((line + "\r\n").encode("utf-8")) + await self.writer.drain() + code, body = await asyncio.wait_for(self.replies.get(), timeout) + if not 200 <= code < 300: + raise SsipError(f"{code} {' '.join(body)}") + return code, body + + async def speak(self, text): + await self.command("SPEAK") + lines = [("." + line if line.startswith(".") else line) for line in text.splitlines()] or [""] + _, body = await self.command("\r\n".join(lines) + "\r\n.") + return body[0] if len(body) > 1 else "" + + async def wait_end(self, message, timeout): + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return "timeout" + code, body = await asyncio.wait_for(self.events.get(), remaining) + if code == 0: + raise SsipError("speech-dispatcher closed the connection") + if message and body and body[0] != message: + continue + if code == 702: + return "end" + if code == 703: + return "cancelled" + + async def close(self): + if self.writer is not None: + with contextlib.suppress(Exception): + self.writer.write(b"QUIT\r\n") + await asyncio.wait_for(self.writer.drain(), 1) + self.writer.close() + with contextlib.suppress(Exception): + await self.writer.wait_closed() + if self.task is not None: + self.task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self.task + + +class Speaker: + """One spoken reply at a time; a new one replaces the current one.""" + + def __init__(self, socket_path, publish): + self.socket_path = Path(socket_path) + self.publish = publish + self.task = None + self.counter = 0 + + @property + def available(self): + return self.socket_path.exists() or shutil.which("spd-say") is not None + + @property + def speaking(self): + return self.task is not None and not self.task.done() + + async def say(self, text, language=None): + await self.stop() + self.counter += 1 + identifier = f"s-{self.counter}" + items = sentences(text) + language = language if isinstance(language, str) and re.fullmatch(r"[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})?", language) else None + self.task = asyncio.create_task(self._run(identifier, items, language)) + return {"id": identifier, "sentences": items} + + async def stop(self): + task, self.task = self.task, None + if task is not None and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + async def _run(self, identifier, items, language): + status = "done" + try: + if items: + if self.socket_path.exists(): + await self._ssip(identifier, items, language) + else: + await self._spd_say(identifier, items, language) + except asyncio.CancelledError: + status = "stopped" + raise + except Exception as error: # a broken speech server must not take the service down + LOG.warning("speech failed: %s", error) + status = "failed" + finally: + self.publish("speech.done", {"id": identifier, "status": status}) + + async def _ssip(self, identifier, items, language): + client = Ssip() + try: + await client.connect(self.socket_path) + await client.command(f"SET self CLIENT_NAME {user()}:nerdos:assistant") + await client.command("SET self NOTIFICATION end on") + await client.command("SET self NOTIFICATION cancel on") + if language: + with contextlib.suppress(SsipError): + await client.command(f"SET self LANGUAGE {language}") + for index, sentence in enumerate(items): + self.publish("speech.progress", {"id": identifier, "index": index}) + message = await client.speak(sentence) + outcome = await client.wait_end(message, 15 + len(sentence) / 6) + if outcome == "cancelled": + raise SsipError("speech was cancelled by another client") + except asyncio.CancelledError: + # Only this client's queue: a screen reader keeps talking. + with contextlib.suppress(Exception): + await asyncio.shield(client.command("CANCEL self", timeout=1)) + raise + finally: + await client.close() + + async def _spd_say(self, identifier, items, language): + for index, sentence in enumerate(items): + self.publish("speech.progress", {"id": identifier, "index": index}) + command = ["spd-say", "-w"] + (["-l", language] if language else []) + ["--", sentence] + process = await asyncio.create_subprocess_exec( + *command, stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL) + try: + await process.wait() + except asyncio.CancelledError: + with contextlib.suppress(ProcessLookupError): + process.terminate() + with contextlib.suppress(Exception): + stopper = await asyncio.create_subprocess_exec( + "spd-say", "-S", stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL) + await asyncio.wait_for(stopper.wait(), 2) + raise + + +def user(): + try: + name = getpass.getuser() + except (KeyError, OSError): + name = str(os.getuid()) + return re.sub(r"[^A-Za-z0-9_.-]", "_", name) or "user" + + +def default_socket(env=os.environ): + runtime = env.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + return Path(env.get("NERDOS_ASSISTANT_SPEECHD") or Path(runtime) / "speech-dispatcher" / "speechd.sock") diff --git a/desktop/assistant/nerdos_assistant/steps.py b/desktop/assistant/nerdos_assistant/steps.py new file mode 100644 index 00000000..c5d62dbc --- /dev/null +++ b/desktop/assistant/nerdos_assistant/steps.py @@ -0,0 +1,73 @@ +"""Short, human-readable lines for the working pill.""" + +from __future__ import annotations + +import os +from urllib.parse import urlparse + + +def short(text, limit=60): + text = " ".join(str(text).split()) + return text if len(text) <= limit else text[:limit - 1] + "…" + + +def basename(path): + return os.path.basename(str(path).rstrip("/")) or str(path) + + +def running(command): + return "Running " + short(command) if command else "Running a command" + + +def editing(path): + return "Editing " + basename(path) if path else "Editing a file" + + +def claude_tool(name, tool_input): + tool_input = tool_input if isinstance(tool_input, dict) else {} + if name == "Read": + return "Reading " + basename(tool_input.get("file_path", "a file")) + if name == "Bash": + return running(tool_input.get("command", "")) + if name in ("Edit", "Write", "MultiEdit"): + return editing(tool_input.get("file_path", "")) + if name == "NotebookEdit": + return editing(tool_input.get("notebook_path", "")) + if name == "WebSearch": + return "Searching the web" + if name == "WebFetch": + host = urlparse(str(tool_input.get("url", ""))).hostname + return "Reading " + host if host else "Reading a web page" + if name in ("Grep", "Glob", "LS"): + return "Searching files" + if name == "TodoWrite": + return "Planning" + if name in ("Task", "Agent"): + return "Delegating" + if name == "Skill": + skill = tool_input.get("skill") or tool_input.get("name") or tool_input.get("command") + return f"Using the {short(skill, 30)} skill" if skill else "Using a skill" + if name.startswith("mcp__"): + return "Using " + short(name.split("__")[-1], 40) + return "Using " + short(name, 40) + + +def codex_item(item): + kind = item.get("type") if isinstance(item, dict) else None + if kind == "commandExecution": + return running(item.get("command", "")) + if kind == "fileChange": + changes = item.get("changes") or [] + first = changes[0].get("path") if changes and isinstance(changes[0], dict) else "" + return editing(first) if len(changes) <= 1 else f"Editing {len(changes)} files" + if kind == "webSearch": + return "Searching the web" + if kind == "mcpToolCall": + return "Using " + short(item.get("tool", "a tool"), 40) + if kind == "dynamicToolCall": + return "Using " + short(item.get("tool", "a tool"), 40) + if kind == "reasoning": + return "Thinking" + if kind == "imageView": + return "Looking at the screenshot" + return "" diff --git a/desktop/assistant/nerdos_assistant/turn.py b/desktop/assistant/nerdos_assistant/turn.py new file mode 100644 index 00000000..12de0f04 --- /dev/null +++ b/desktop/assistant/nerdos_assistant/turn.py @@ -0,0 +1,152 @@ +"""One question and its answer: streamed text, steps and approval cards.""" + +from __future__ import annotations + +import asyncio +import re + +from . import policy +from .protocol import now_ms + + +FINAL = ("completed", "stopped", "failed") +STOPPED_MESSAGE = "The person stopped this request." +DECLINED_MESSAGE = ("The person declined this action. Do not retry it; explain what you wanted " + "to do so they can decide.") +AUTH_PATTERN = re.compile(r"\b(?:log ?in|/login|logged in|authenticat\w*|api key|401|oauth|unauthori[sz]ed|sign in)\b", re.I) + + +def classify(error): + return "auth" if error and AUTH_PATTERN.search(str(error)) else "" + + +class Turn: + def __init__(self, identifier, agent, prompt, home, memory, publish, screenshot=False): + self.id = identifier + self.agent = agent + self.prompt = prompt + self.status = "running" + self.step = "Thinking" + self.text = "" + self.error = "" + self.error_kind = "" + self.started_at = now_ms() + self.finished_at = 0 + self.undo = [] + self.plugins = [] + self.staged = "" + self.screenshot = screenshot + self.snapshots = policy.Snapshots(home) + # Conversation-wide "always" answers, shared with the next turns. + self.memory = memory + self.stopping = False + self._publish = publish + self._approvals = {} + self._counter = 0 + + @property + def finished(self): + return self.status in FINAL + + def snapshot(self, resumable=False): + return { + "id": self.id, "agent": self.agent, "prompt": self.prompt, "status": self.status, + "step": self.step, "text": self.text, "error": self.error, "errorKind": self.error_kind, + "approvals": [entry["card"] for entry in self._approvals.values()], + "startedAt": self.started_at, "finishedAt": self.finished_at, "undo": list(self.undo), + "plugins": list(self.plugins), "staged": self.staged, "screenshot": self.screenshot, + "resumable": resumable, + } + + def updated(self): + self._publish("updated", self) + + def delta(self, text): + if text and not self.finished: + self.text += text + self._publish("delta", self, text) + + def reset_text(self): + if self.text and not self.finished: + self.text = "" + self.updated() + + def set_step(self, step): + if step and step != self.step and not self.finished and self.status != "waiting": + self.step = step + self.updated() + + def finish(self, status, text=None, error="", kind=""): + self.status = status + if text is not None: + self.text = text + self.error = error or "" + self.error_kind = kind or (classify(error) if status == "failed" else "") + self.step = "" + self.finished_at = now_ms() + self.deny_all() + + # ---- approvals --------------------------------------------------------- + + async def authorize(self, decision, cancel_key=None): + """("allow" | "always" | "deny" | "cancelled", message for a denial).""" + if decision.action == "deny": + return "deny", decision.message + if self.stopping or self.finished: + return "deny", STOPPED_MESSAGE + if decision.action == "ask" and not (decision.key and decision.key in self.memory): + answer = await self.ask(decision.card, cancel_key) + if answer == "cancelled": + return "cancelled", "" + if answer not in ("allow", "always"): + return "deny", STOPPED_MESSAGE if self.stopping else DECLINED_MESSAGE + if answer == "always" and decision.key: + self.memory.add(decision.key) + result = answer + else: + result = "allow" + for path in decision.paths: + self.snapshots.take(path) + if decision.step: + self.set_step(decision.step) + return result, "" + + async def ask(self, card, cancel_key=None): + self._counter += 1 + identifier = f"a-{self._counter}" + future = asyncio.get_running_loop().create_future() + self._approvals[identifier] = {"card": dict(card, id=identifier), "future": future, "cancel": cancel_key} + previous = self.step + self.status = "waiting" + self.step = "Waiting for your review" if card.get("kind") == "apply" else "Waiting for your approval" + self.updated() + try: + return await future + finally: + self._approvals.pop(identifier, None) + if not self._approvals and self.status == "waiting": + self.status = "running" + self.step = previous or "Working" + if not self.finished: + self.updated() + + # Answers leave the card list at once, so the snapshot published for the + # change never still shows the card that was just answered. + def _resolve(self, identifier, answer): + entry = self._approvals.pop(identifier, None) + if entry is None or entry["future"].done(): + return False + entry["future"].set_result(answer) + return True + + def respond(self, identifier, decision): + return self._resolve(identifier, decision) + + def cancel(self, cancel_key): + for identifier, entry in list(self._approvals.items()): + if entry["cancel"] is not None and entry["cancel"] == cancel_key: + self._resolve(identifier, "cancelled") + + def deny_all(self): + for identifier in list(self._approvals): + self._resolve(identifier, "deny") diff --git a/desktop/bin/nerdos-session b/desktop/bin/nerdos-session new file mode 100755 index 00000000..b582a884 --- /dev/null +++ b/desktop/bin/nerdos-session @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=Hyprland XDG_SESSION_DESKTOP=Hyprland +export MOZ_ENABLE_WAYLAND=1 QT_QPA_PLATFORM=wayland XMODIFIERS=@im=ibus +export NERDOS_USER_CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/nerdos" +export NERDOS_PLUGIN_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/nerdos/plugins" +# SDDM must get control again after a compositor crash. +trap 'systemctl --user stop hyprland-session.target' EXIT +Hyprland --config /etc/nerdos/runtime/hypr/hyprland.lua "$@" diff --git a/desktop/bin/nerdos-session-action b/desktop/bin/nerdos-session-action new file mode 100755 index 00000000..50478443 --- /dev/null +++ b/desktop/bin/nerdos-session-action @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Session actions shared by Quickshell and other desktop entry points. +# Locking is delegated to one systemd unit, so idle, suspend, and manual lock +# requests cannot accumulate competing hyprlock processes. +set -euo pipefail + +action=${1:-} + +# The compositor's own answer: true once the ext-session-lock is active. +# logind's LockedHint is not consulted. Nothing in this session sets it, so it +# reads "no" behind a working lock screen, and a stale "yes" would let a +# suspend skip the lock. +locked() { + [[ $(hyprctl locked 2>/dev/null) == true ]] +} + +ensure_locked() { + local i + if locked; then + return 0 + fi + + systemctl --user reset-failed nerdos-session-lock.service >/dev/null 2>&1 || true + systemctl --user start nerdos-session-lock.service + + # Do not use a fixed sleep: suspend is allowed only after the compositor + # reports the ext-session-lock active. A failed locker therefore leaves the + # machine awake and returns an actionable error to the caller. + for ((i = 0; i < 80; i++)); do + if locked; then + return 0 + fi + if ! systemctl --user is-active --quiet nerdos-session-lock.service; then + printf 'The lock screen exited before the session was secured.\n' >&2 + return 1 + fi + sleep 0.1 + done + + # A locker that is still running is left running: it may lock a moment + # later, and stopping it would unlock the session with nobody at the + # keyboard. A lid close suspends while this loop waits, so its eight + # seconds can run out just after resume. Staying locked is the only safe + # failure; the caller just does not suspend. + printf 'The lock screen did not secure the session within eight seconds.\n' >&2 + return 1 +} + +# UPower's aggregate answer, the same source the shell's battery state reads. +# An unknown answer counts as mains power, so it never suspends by mistake. +# The timeout bounds a hung UPower: hypridle waits for this as a condition. +on_battery() { + [[ $(busctl --system --timeout=2 get-property org.freedesktop.UPower \ + /org/freedesktop/UPower org.freedesktop.UPower OnBattery 2>/dev/null) == 'b true' ]] +} + +case $action in + lock) + ensure_locked + ;; + suspend) + ensure_locked + systemctl suspend + ;; + idle-suspend) + # hypridle's suspend step. `on-battery` limits it to battery power, which + # is checked when the timeout fires rather than when hypridle starts. + case ${2:-} in + '') ;; + on-battery) on_battery || exit 0 ;; + *) + printf 'Usage: nerdos-session-action idle-suspend [on-battery]\n' >&2 + exit 2 + ;; + esac + ensure_locked + systemctl suspend + ;; + on-battery) + # hypridle's condition for battery-only idle suspend. While it fails, + # hypridle retries it until the next input, so a laptop unplugged after + # the timeout still suspends instead of waiting for another idle stretch. + on_battery + ;; + logout) + # Hyprland's Lua config wraps the argument in hl.dispatch(), so the + # dispatcher is a Lua expression; a bare `exit` is nil there. + hyprctl dispatch 'hl.dsp.exit()' + ;; + reboot) + systemctl reboot + ;; + shutdown) + systemctl poweroff + ;; + *) + printf 'Usage: nerdos-session-action \n' >&2 + exit 2 + ;; +esac diff --git a/desktop/bin/nerdos-session-start b/desktop/bin/nerdos-session-start new file mode 100755 index 00000000..1e2f51bf --- /dev/null +++ b/desktop/bin/nerdos-session-start @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +hyprctl setcursor "${XCURSOR_THEME:-Bibata-Modern-Classic}" "${XCURSOR_SIZE:-24}" +systemctl --user import-environment WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE XCURSOR_THEME XCURSOR_SIZE XCURSOR_PATH +dbus-update-activation-environment --systemd WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE XCURSOR_THEME XCURSOR_SIZE XCURSOR_PATH +systemctl --user start hyprland-session.target diff --git a/desktop/boot/splash.png b/desktop/boot/splash.png new file mode 100644 index 00000000..57ff8ac0 Binary files /dev/null and b/desktop/boot/splash.png differ diff --git a/desktop/boot/theme.txt b/desktop/boot/theme.txt new file mode 100644 index 00000000..0d540f2f --- /dev/null +++ b/desktop/boot/theme.txt @@ -0,0 +1,33 @@ +# NerdOS ISO menu appearance. Logo reused unchanged from Plymouth. +desktop-color: "#0d0d0d" +title-text: "" +terminal-font: "Unifont Regular 16" + ++ image { + left = 50%-180 + top = 12% + width = 360 + height = 48 + file = "logo.png" +} ++ label { + left = 0 + top = 23% + width = 100% + align = "center" + text = "NerdOS - Opinionated System" + color = "#F3F2EC" + font = "Unifont Regular 16" +} ++ boot_menu { + left = 15% + top = 35% + width = 70% + height = 50% + item_font = "Unifont Regular 16" + item_color = "#F3F2EC" + selected_item_color = "#C8FF1F" + item_height = 32 + item_padding = 8 + item_spacing = 4 +} diff --git a/roles/dotfiles/files/fastfetch.jsonc b/desktop/defaults/fastfetch.jsonc similarity index 88% rename from roles/dotfiles/files/fastfetch.jsonc rename to desktop/defaults/fastfetch.jsonc index ec57d13f..444ae6d0 100644 --- a/roles/dotfiles/files/fastfetch.jsonc +++ b/desktop/defaults/fastfetch.jsonc @@ -1,6 +1,6 @@ { "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", - "logo": { "source": "Fedora", "padding": { "top": 2, "right": 6, "left": 2 } }, + "logo": { "type": "file", "source": "@NERDOS_FASTFETCH@", "color": { "1": "#F3F2EC", "2": "#C8FF1F" }, "padding": { "top": 2, "right": 6, "left": 2 } }, "modules": [ "break", { "type": "custom", "format": "\u001b[90m┌──────────────────────Hardware──────────────────────┐" }, @@ -8,7 +8,7 @@ { "type": "cpu", "key": "│ ├", "showPeCoreCount": true, "keyColor": "green" }, { "type": "gpu", "key": "│ ├", "detectionMethod": "pci", "keyColor": "green" }, { "type": "display", "key": "│ ├󱄄", "keyColor": "green" }, - { "type": "command", "key": "│ ├󰈀", "keyColor": "green", "text": "$HOME/.local/bin/fastfetch-link-speed" }, + { "type": "command", "key": "│ ├󰈀", "keyColor": "green", "text": "fastfetch-link-speed" }, { "type": "disk", "key": "│ ├󰋊", "keyColor": "green" }, { "type": "memory", "key": "│ ├", "keyColor": "green" }, { "type": "swap", "key": "└ └󰓡 ", "keyColor": "green" }, diff --git a/roles/dotfiles/files/fish-config.fish b/desktop/defaults/fish-config.fish similarity index 85% rename from roles/dotfiles/files/fish-config.fish rename to desktop/defaults/fish-config.fish index 0818303d..27215b9d 100644 --- a/roles/dotfiles/files/fish-config.fish +++ b/desktop/defaults/fish-config.fish @@ -5,8 +5,12 @@ set -g fish_cursor_replace_one underscore set -g fish_cursor_visual underscore fish_add_path --prepend ~/.local/bin ~/.npm-global/bin ~/Android/Sdk/platform-tools ~/Android/Sdk/cmdline-tools/latest/bin -set -gx EDITOR nvim -set -gx VISUAL nvim +if not set -q EDITOR + set -gx EDITOR 'nerdos-apps edit' +end +if not set -q VISUAL + set -gx VISUAL 'nerdos-apps edit' +end set -gx ANDROID_HOME "$HOME/Android/Sdk" set -gx ANDROID_SDK_ROOT "$ANDROID_HOME" set -gx RUSTC_WRAPPER sccache @@ -17,7 +21,7 @@ set -gx SCCACHE_DIR "$HOME/.cache/sccache" # the PATH and variables above, but not the six processes behind a prompt, # hooks and aliases they never use. if status is-interactive - # Keep the prompt icon accurate on Fedora and inside each Distrobox. One + # Keep the prompt icon accurate on NixOS and inside each Distrobox. One # builtin read of os-release; the quotes are optional in that format. set -l posh_os_id set -l posh_os_like @@ -32,8 +36,8 @@ if status is-interactive end Any: provider_status = await self.refresh_provider_status() return { **self.snapshot(), - "server": "cybexos-hermes-menubar-bridge", + "server": "nerdos-hermes-menubar-bridge", "bridgeVersion": str(BRIDGE_VERSION), "backendStatus": self.connection, "capabilities": self.bridge_capabilities(), @@ -1253,6 +1259,7 @@ async def delete_conversation(self, params: dict[str, Any]) -> dict[str, Any]: {"session_id": conversation_id}, timeout=30, ) + self.remote_deleted[conversation_id] = self.remote_list_generation runtime = self.runtime_by_conversation.pop(conversation_id, "") if runtime: self.conversation_by_runtime.pop(runtime, None) @@ -1266,7 +1273,9 @@ async def delete_conversation(self, params: dict[str, Any]) -> dict[str, Any]: await self.stop_remote_stream(conversation_id) if self.remote_observed_conversation_id == conversation_id: await self.observe_remote_conversation("") - self.registry.conversations.pop(conversation_id) + # A list refresh during the awaits above may already have removed the + # row; removal is idempotent and still completes with one event. + self.registry.conversations.pop(conversation_id, None) if self.registry.selected_conversation_id == conversation_id: self.registry.selected_conversation_id = "" self.registry.save() @@ -2259,13 +2268,33 @@ async def refresh_remote_conversations(self) -> dict[str, Any]: raise RemoteLoginFault( "Remote Hermes authentication is required", self.remote_auth.status ) - result = await self.remote_request( - "GET", "/api/sessions?exclude_hidden=1", timeout=45 - ) + self.remote_list_generation += 1 + generation = self.remote_list_generation + self.remote_list_requests.add(generation) + try: + result = await self.remote_request( + "GET", "/api/sessions?exclude_hidden=1", timeout=45 + ) + finally: + self.remote_list_requests.discard(generation) rows = result.get("sessions") if not isinstance(rows, list): raise RpcFault(-32045, "Remote Hermes returned no conversation list") + # Only lists requested after a confirmed delete are authoritative for + # it; older responses must not restore the row or its stream. Forget a + # delete once no list request that predates it remains in flight. + deleted = { + session_id + for session_id, confirmed in self.remote_deleted.items() + if confirmed >= generation + } + oldest = min(self.remote_list_requests, default=self.remote_list_generation + 1) + self.remote_deleted = { + session_id: confirmed + for session_id, confirmed in self.remote_deleted.items() + if confirmed >= oldest + } previous = self.registry.conversations incoming: dict[str, dict[str, Any]] = {} for raw in rows: @@ -2279,6 +2308,8 @@ async def refresh_remote_conversations(self) -> dict[str, Any]: except RpcFault: LOG.warning("ignoring an invalid remote Hermes conversation row") continue + if record["id"] in deleted: + continue incoming[record["id"]] = record # A just-created first turn can be absent from the sidebar snapshot for diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py b/desktop/hermes-menubar-bridge/nerdos_hermes/__init__.py similarity index 100% rename from roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py rename to desktop/hermes-menubar-bridge/nerdos_hermes/__init__.py diff --git a/desktop/hermes-menubar-bridge/nerdos_hermes/auth.py b/desktop/hermes-menubar-bridge/nerdos_hermes/auth.py new file mode 100644 index 00000000..2f67097b --- /dev/null +++ b/desktop/hermes-menubar-bridge/nerdos_hermes/auth.py @@ -0,0 +1,1314 @@ +"""Origin-scoped HTTP credentials and authenticated remote transport.""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from contextlib import suppress +from contextvars import copy_context +from http.cookiejar import Cookie, CookieJar +import json +import os +from pathlib import Path +import re +import threading +import time +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin, urlparse, urlunparse +from urllib.request import ( + HTTPRedirectHandler, + HTTPCookieProcessor, + Request, + build_opener, +) +import uuid + + +from .protocol import ( + LOG, + MAX_REMOTE_AUTH_RESPONSE, + MAX_REMOTE_ATTACHMENT_BYTES, + REMOTE_AUTH_VERSION, + RpcFault, + AmbiguousDelivery, + utc_now, + is_loopback, +) + +class _RemoteAuthRequired(Exception): + """A remote response is an authentication challenge, not API data.""" + + def __init__(self, status_code: int = 401): + super().__init__("remote authentication required") + self.status_code = status_code + + +class _RemoteRedirectBlocked(Exception): + """A redirect attempted to leave the configured WebUI origin.""" + + +class _RemoteTransportError(Exception): + """The remote WebUI could not be reached.""" + + +def _remote_origin(url: str) -> tuple[str, str, int]: + parsed = urlparse(url) + try: + port = parsed.port + except ValueError as exc: + raise RpcFault(-32602, "Remote Hermes URL has an invalid port") from exc + scheme = parsed.scheme.lower() + hostname = (parsed.hostname or "").lower().rstrip(".") + if not hostname or scheme not in {"http", "https"}: + raise RpcFault(-32602, "Remote Hermes URL must use http:// or https://") + return scheme, hostname, port or (443 if scheme == "https" else 80) + + +def normalize_remote_url(raw: Any) -> str: + """Validate and canonicalize a user-provided Hermes WebUI base URL.""" + + if not isinstance(raw, str) or not raw.strip(): + raise RpcFault(-32602, "Remote Hermes URL is required") + value = raw.strip().rstrip("/") + if len(value) > 2048 or any(ord(character) < 0x20 for character in value): + raise RpcFault(-32602, "Remote Hermes URL is invalid") + if any(character.isspace() or character == "\\" for character in value): + raise RpcFault(-32602, "Remote Hermes URL must not contain whitespace") + try: + parsed = urlparse(value) + hostname = parsed.hostname + port = parsed.port + except ValueError as exc: + raise RpcFault(-32602, "Remote Hermes URL is invalid") from exc + scheme = parsed.scheme.lower() + if ( + scheme not in {"http", "https"} + or not hostname + or parsed.username is not None + or parsed.password is not None + or parsed.params + or parsed.query + or parsed.fragment + ): + raise RpcFault( + -32602, + "Use an http(s) Hermes URL without credentials, query, or fragment", + ) + if scheme == "http" and not is_loopback(hostname): + raise RpcFault( + -32602, + "Remote Hermes URLs must use HTTPS; HTTP is allowed only on loopback", + ) + try: + ascii_hostname = hostname.rstrip(".").encode("idna").decode("ascii").lower() + except UnicodeError as exc: + raise RpcFault(-32602, "Remote Hermes URL has an invalid hostname") from exc + host_for_netloc = ( + f"[{ascii_hostname}]" if ":" in ascii_hostname else ascii_hostname + ) + default_port = 443 if scheme == "https" else 80 + if port is not None and port != default_port: + host_for_netloc = f"{host_for_netloc}:{port}" + path = parsed.path.rstrip("/") + decoded_segments = [ + segment.lower().replace("%2e", ".") for segment in path.split("/") + ] + if any(segment in {".", ".."} for segment in decoded_segments): + raise RpcFault(-32602, "Remote Hermes URL path must not traverse directories") + normalized = urlunparse((scheme, host_for_netloc, path, "", "", "")) + _remote_origin(normalized) + return normalized + + +def _is_login_url(url: str) -> bool: + try: + path = urlparse(url).path.rstrip("/").lower() + except ValueError: + return False + if path.endswith("/api/auth/login") or path.endswith("/api/auth/passkey/login"): + return False + return path == "/login" or path.endswith("/login") + + +class _SameOriginRedirectHandler(HTTPRedirectHandler): + """Follow only redirects that remain on the originally requested origin.""" + + max_redirections = 5 + + def __init__(self, allowed_origin: tuple[str, str, int]): + super().__init__() + self.allowed_origin = allowed_origin + self.redirects: list[str] = [] + + def redirect_request( + self, + request: Request, + file_pointer: Any, + code: int, + message: str, + headers: Any, + new_url: str, + ) -> Request | None: + target = urljoin(request.full_url, new_url) + # Login redirects are a normal expired-session signal. Do not follow + # them, even when a reverse proxy points at a different origin. + if _is_login_url(target): + raise _RemoteAuthRequired(code) + parsed = urlparse(target) + if parsed.username is not None or parsed.password is not None: + raise _RemoteRedirectBlocked() + try: + target_origin = _remote_origin(target) + except RpcFault as exc: + raise _RemoteRedirectBlocked() from exc + if target_origin != self.allowed_origin: + raise _RemoteRedirectBlocked() + self.redirects.append(target) + return super().redirect_request( + request, file_pointer, code, message, headers, target + ) + + +class RemoteLoginFault(RpcFault): + def __init__(self, message: str, status: dict[str, Any], code: int = -32040): + super().__init__(code, message, status) + + +RemoteSession = tuple[str, CookieJar] + + +class RemoteWebUIAuth: + """Origin-bound Hermes WebUI cookie session manager. + + The password is used only to build one in-memory login request. The file + contains the normalized origin and cookies issued by that origin; it never + contains a password, request body, or server response body. + + ``_lock`` guards only the in-memory configuration (origin, cookie jar, + source, and status) and the credential file. It is never held across a + network request: the event loop reads ``status`` constantly, so one slow + or unreachable WebUI request would otherwise stall every local RPC, + stream relay, and keepalive for its whole timeout, and serialize all + remote traffic behind it. Requests snapshot the configuration, run + unlocked, and apply an expiry only if that configuration is still current. + """ + + def __init__(self, path: Path, environment_url: str | None = None): + self.path = path + self._lock = threading.RLock() + # Optional discovery and stream reads can occupy every default worker + # on a small machine. Authentication must still reach the server. + self._auth_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="hermes-auth") + self.cookie_jar = CookieJar() + self.base_url = "" + self.source = "none" + self.environment_url = "" + self._status = self._make_status( + "disconnected", message="Remote Hermes is not configured" + ) + configured_environment = ( + environment_url + if environment_url is not None + else os.environ.get("HERMES_REMOTE_URL", "") + ) + if configured_environment: + try: + self.environment_url = normalize_remote_url(configured_environment) + except RpcFault: + self._status = self._make_status( + "error", + message="HERMES_REMOTE_URL is invalid", + error_kind="configuration", + ) + file_exists = self.path.exists() + if file_exists: + self._load() + elif self.environment_url: + self.base_url = self.environment_url + self.source = "environment" + self._status = self._make_status( + "disconnected", + configured=True, + url=self.base_url, + message="Remote Hermes has not been checked", + ) + + async def _auth_call(self, function: Callable[..., Any], *args: Any) -> Any: + context = copy_context() + return await asyncio.get_running_loop().run_in_executor( + self._auth_executor, context.run, function, *args + ) + + def close(self) -> None: + # Existing HTTP deadlines bound requests already running; discard work + # that has not started without blocking the bridge's event loop. + self._auth_executor.shutdown(wait=False, cancel_futures=True) + + @property + def status(self) -> dict[str, Any]: + # Writers replace ``_status`` wholesale under ``_lock``, so copying the + # current reference always yields one complete status. Reading it + # lock-free keeps the event loop off a writer's critical section. + return dict(self._status) + + @property + def session_identity(self) -> RemoteSession: + """Private identity for work that must not outlive an origin/sign-in.""" + + with self._lock: + return self.base_url, self.cookie_jar + + def is_current_session(self, session: RemoteSession) -> bool: + with self._lock: + return self.base_url == session[0] and self.cookie_jar is session[1] + + @staticmethod + def _jar_cookies(jar: CookieJar) -> list[Cookie]: + # Requests update a shared jar from worker threads under the jar's + # own lock. Iterate under that lock as well, so a concurrent + # Set-Cookie cannot resize its dictionaries mid-iteration. + with jar._cookies_lock: + return list(jar) + + def connecting_status(self, message: str, url: Any = None) -> dict[str, Any]: + with self._lock: + display_url = self.base_url + if url: + display_url = normalize_remote_url(url) + self._status = self._make_status( + "connecting", + configured=bool(display_url), + url=display_url, + message=message, + ) + return dict(self._status) + + def _make_status( + self, + state: str, + *, + configured: bool | None = None, + url: str | None = None, + reachable: bool = False, + auth_enabled: bool = False, + authenticated: bool = False, + logged_in: bool = False, + password_auth_enabled: bool = False, + message: str = "", + error_kind: str = "", + status_code: int = 0, + source: str | None = None, + ) -> dict[str, Any]: + selected_url = self.base_url if url is None else url + selected_configured = bool(selected_url) if configured is None else configured + return { + "state": state, + "configured": selected_configured, + "url": selected_url, + "origin": selected_url, + "reachable": reachable, + "authEnabled": auth_enabled, + "authenticated": authenticated, + "loggedIn": logged_in, + "passwordAuthEnabled": password_auth_enabled, + "authRequired": state == "expired", + "hasSessionCredential": bool(self._jar_cookies(self.cookie_jar)), + "source": self.source if source is None else source, + "message": message, + "error": message if state in {"expired", "error"} else "", + "errorKind": error_kind, + "statusCode": status_code, + "updatedAt": utc_now(), + } + + def _load(self) -> None: + try: + document = json.loads(self.path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("credential document is not an object") + base_url = normalize_remote_url(document.get("base_url")) + rows = document.get("cookies", []) + if not isinstance(rows, list): + raise ValueError("credential cookie list is invalid") + jar = CookieJar() + for row in rows: + cookie = self._cookie_from_row(row, base_url) + if cookie is not None: + jar.set_cookie(cookie) + self.base_url = base_url + self.cookie_jar = jar + self.source = "persisted" + with suppress(OSError): + os.chmod(self.path, 0o600) + self._status = self._make_status( + "disconnected", + configured=True, + url=base_url, + message="Saved remote session has not been checked", + ) + except FileNotFoundError: + return + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, RpcFault): + # Never include credential document contents in diagnostics. + LOG.warning("could not load remote Hermes credentials from %s", self.path) + self.base_url = "" + self.cookie_jar = CookieJar() + self.source = "none" + self._status = self._make_status( + "error", + configured=False, + url="", + message="Saved remote Hermes credentials are invalid", + error_kind="credentials", + ) + + @staticmethod + def _cookie_from_row(row: Any, base_url: str) -> Cookie | None: + if not isinstance(row, dict): + return None + name = row.get("name") + value = row.get("value") + domain = str(row.get("domain") or "").lstrip(".").lower().rstrip(".") + origin_host = (urlparse(base_url).hostname or "").lower().rstrip(".") + path = str(row.get("path") or "/") + if ( + not isinstance(name, str) + or not name + or len(name) > 256 + or not isinstance(value, str) + or len(value) > 16384 + or any(ord(character) < 0x20 for character in name + value) + or domain != origin_host + or not path.startswith("/") + or len(path) > 2048 + ): + return None + expires_raw = row.get("expires") + try: + expires = int(expires_raw) if expires_raw is not None else None + except (TypeError, ValueError): + return None + if expires is not None and expires <= int(time.time()): + return None + return Cookie( + version=0, + name=name, + value=value, + port=None, + port_specified=False, + domain=domain, + domain_specified=bool(row.get("domain_specified", False)), + domain_initial_dot=False, + path=path, + path_specified=True, + secure=bool(row.get("secure", False)), + expires=expires, + discard=expires is None, + comment=None, + comment_url=None, + rest={"HttpOnly": None} if row.get("http_only", True) else {}, + rfc2109=False, + ) + + def _cookie_rows(self, base_url: str, jar: CookieJar) -> list[dict[str, Any]]: + origin_host = (urlparse(base_url).hostname or "").lower().rstrip(".") + now = time.time() + rows: list[dict[str, Any]] = [] + for cookie in self._jar_cookies(jar): + if cookie.is_expired(now) or cookie.domain.lstrip(".").lower() != origin_host: + continue + rows.append( + { + "name": cookie.name, + "value": cookie.value, + "domain": origin_host, + "domain_specified": cookie.domain_specified, + "path": cookie.path or "/", + "secure": cookie.secure, + "expires": cookie.expires, + "http_only": "HttpOnly" in cookie._rest, + } + ) + return rows + + def _save(self) -> None: + if not self.base_url: + self._delete_file() + return + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with suppress(OSError): + os.chmod(self.path.parent, 0o700) + document = { + "version": REMOTE_AUTH_VERSION, + "base_url": self.base_url, + "cookies": self._cookie_rows(self.base_url, self.cookie_jar), + } + temporary = self.path.with_name(f".{self.path.name}.tmp.{os.getpid()}") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(document, stream, ensure_ascii=False, separators=(",", ":")) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, self.path) + os.chmod(self.path, 0o600) + finally: + with suppress(FileNotFoundError): + temporary.unlink() + + def _delete_file(self) -> None: + try: + self.path.unlink() + except FileNotFoundError: + return + except OSError as exc: + raise RpcFault(-32043, "Could not remove saved remote session") from exc + + @staticmethod + def _login_page_response(response: dict[str, Any]) -> bool: + if response["status"] == 401 or _is_login_url(response["url"]): + return True + location = response["headers"].get("Location", "") + if location and _is_login_url(urljoin(response["url"], location)): + return True + content_type = response["headers"].get("Content-Type", "").lower() + if "text/html" not in content_type: + return False + sample = response["body"][:256 * 1024].decode("utf-8", errors="ignore").lower() + return ( + "login.js" in sample + or "/api/auth/login" in sample + or ("sign in" in sample and "hermes" in sample) + ) + + def _request( + self, + base_url: str, + jar: CookieJar, + method: str, + path: str, + payload: dict[str, Any] | None, + timeout: float, + *, + encoded_body: bytes | None = None, + content_type: str = "", + ) -> dict[str, Any]: + if not path.startswith("/") or "://" in path: + raise RpcFault(-32602, "Remote Hermes API path is invalid") + body = encoded_body + headers = { + "Accept": "application/json", + "User-Agent": "nerdos-hermes-menubar-bridge/1", + } + if encoded_body is not None: + if not content_type: + raise RpcFault(-32602, "Remote Hermes request content type is required") + headers["Content-Type"] = content_type + elif payload is not None: + body = json.dumps( + payload, ensure_ascii=False, separators=(",", ":") + ).encode("utf-8") + headers["Content-Type"] = "application/json" + redirect_handler = _SameOriginRedirectHandler(_remote_origin(base_url)) + opener = build_opener(redirect_handler, HTTPCookieProcessor(jar)) + request = Request( + f"{base_url}{path}", body, headers=headers, method=method.upper() + ) + try: + with opener.open(request, timeout=timeout) as response: + raw = response.read(MAX_REMOTE_AUTH_RESPONSE + 1) + result = { + "status": int(response.status), + "url": response.geturl(), + "headers": response.headers, + "body": raw, + "redirects": list(redirect_handler.redirects), + } + except (_RemoteAuthRequired, _RemoteRedirectBlocked): + raise + except HTTPError as exc: + raw = exc.read(MAX_REMOTE_AUTH_RESPONSE + 1) + result = { + "status": int(exc.code), + "url": exc.geturl(), + "headers": exc.headers, + "body": raw, + "redirects": list(redirect_handler.redirects), + } + except (URLError, TimeoutError, OSError) as exc: + raise _RemoteTransportError() from exc + if len(result["body"]) > MAX_REMOTE_AUTH_RESPONSE: + raise RpcFault(-32041, "Remote Hermes response is too large") + if self._login_page_response(result): + raise _RemoteAuthRequired(result["status"]) + return result + + @staticmethod + def _json_response(response: dict[str, Any]) -> dict[str, Any]: + try: + value = json.loads(response["body"].decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc: + raise RpcFault(-32041, "Remote Hermes returned invalid JSON") from exc + if not isinstance(value, dict): + raise RpcFault(-32041, "Remote Hermes returned invalid JSON") + return value + + @classmethod + def _http_error_fault(cls, response: dict[str, Any]) -> RpcFault: + """Translate a bounded WebUI error without reflecting secrets. + + Hermes WebUI returns typed JSON for recoverable conflicts. Only a + small scalar allow-list crosses the loopback RPC boundary; arbitrary + response objects, headers, cookies, and request content never do. + """ + + status_code = int(response.get("status") or 0) + data: dict[str, Any] = {"statusCode": status_code} + try: + value = cls._json_response(response) + except RpcFault: + value = {} + + error_type = str(value.get("type") or "").strip().lower() + if re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", error_type): + data["errorType"] = error_type + else: + error_type = "" + + error_code = str(value.get("code") or "").strip().lower() + if re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", error_code): + data["errorCode"] = error_code + else: + error_code = "" + + if isinstance(value.get("retryable"), bool): + data["retryable"] = value["retryable"] + + active_stream_id = str(value.get("active_stream_id") or "").strip() + if re.fullmatch(r"[A-Za-z0-9_-]{1,128}", active_stream_id): + data["activeStreamId"] = active_stream_id + else: + active_stream_id = "" + + raw_message = value.get("error") + if not isinstance(raw_message, str): + raw_message = value.get("message") + if not isinstance(raw_message, str): + raw_message = "" + remote_message = re.sub(r"\s+", " ", "".join( + character for character in raw_message + if ord(character) >= 0x20 and ord(character) != 0x7f + )).strip()[:500] + sensitive_words = re.compile( + r"password|passphrase|api[ _-]?key|authorization|cookie|secret|token", + re.IGNORECASE, + ) + if remote_message and not sensitive_words.search(remote_message): + data["remoteMessage"] = remote_message + + if error_type == "agent_runtime_stale": + message = ( + "Remote Hermes fell back to a stale in-process Agent runtime. " + "Restore gateway-backed chat, then retry; this prompt was not accepted." + ) + elif active_stream_id or error_type in { + "active_stream", + "chat_already_running", + "session_busy", + "stream_conflict", + }: + message = ( + "This Hermes session already has an active response; " + "the new prompt was not accepted." + ) + elif error_code == "stale_regeneration_revision": + message = "This conversation changed; refresh it before regenerating" + elif error_code == "unsupported_regeneration_backend": + message = "Regeneration is unavailable on this Hermes backend" + elif error_code == "invalid_regeneration_request": + message = "Hermes rejected the regeneration request" + else: + message = f"Remote Hermes returned HTTP {status_code}" + + return RpcFault(-32041, message, data) + + def _probe_base( + self, + base_url: str, + jar: CookieJar, + *, + configured: bool, + source: str, + timeout: float, + ) -> dict[str, Any]: + # The source is passed through rather than set on ``self``: probes run + # without ``_lock``, so shared state must not change for their sake. + try: + response = self._request( + base_url, jar, "GET", "/api/auth/status", None, timeout + ) + if not 200 <= response["status"] < 300: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=True, + message=f"Remote Hermes returned HTTP {response['status']}", + error_kind="http", + status_code=response["status"], + ) + value = self._json_response(response) + if "auth_enabled" not in value or "logged_in" not in value: + raise RpcFault(-32041, "Remote endpoint is not a compatible Hermes WebUI") + auth_enabled = value.get("auth_enabled") is True + logged_in = value.get("logged_in") is True + connected = not auth_enabled or logged_in + return self._make_status( + "connected" if connected else "expired", + configured=configured, + url=base_url, + source=source, + reachable=True, + auth_enabled=auth_enabled, + authenticated=connected, + logged_in=logged_in, + password_auth_enabled=value.get("password_auth_enabled") is True, + message=( + "Remote Hermes is connected" + if connected + else "Remote Hermes authentication is required" + ), + ) + except _RemoteAuthRequired as exc: + return self._make_status( + "expired", + configured=configured, + url=base_url, + source=source, + reachable=True, + auth_enabled=True, + message="Remote Hermes authentication is required", + status_code=exc.status_code, + ) + except _RemoteRedirectBlocked: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=True, + message="Remote Hermes attempted a cross-origin redirect", + error_kind="redirect", + ) + except _RemoteTransportError: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=False, + message="Remote Hermes is unreachable", + error_kind="offline", + ) + except RpcFault as fault: + return self._make_status( + "error", + configured=configured, + url=base_url, + source=source, + reachable=True, + message=fault.message, + error_kind="protocol", + ) + + async def probe(self, url: Any = None, timeout: float = 10.0) -> dict[str, Any]: + return await self._auth_call(self._probe_sync, url, timeout) + + def _probe_sync(self, url: Any, timeout: float) -> dict[str, Any]: + with self._lock: + if url is not None and str(url).strip(): + base_url = normalize_remote_url(url) + else: + base_url = self.base_url + if not base_url: + self._status = self._make_status( + "disconnected", message="Remote Hermes is not configured" + ) + return dict(self._status) + is_current = base_url == self.base_url + jar = self.cookie_jar if is_current else CookieJar() + source = self.source if is_current else "candidate" + status = self._probe_base( + base_url, + jar, + configured=is_current, + source=source, + timeout=timeout, + ) + if not is_current: + return status + with self._lock: + if self.base_url != base_url or self.cookie_jar is not jar: + # A sign-in or sign-out replaced this session while it was + # being probed; the replacement's status is authoritative. + return dict(self._status) + if status.get("errorKind") == "offline": + # Keep a known password requirement across a network outage; + # losing it would turn missing credentials into retryable auth. + for key in ("authEnabled", "passwordAuthEnabled"): + status[key] = self._status.get(key, False) + if status["state"] == "expired" and self._jar_cookies(jar): + self.cookie_jar = CookieJar() + if self.source == "persisted": + self._save() + status["hasSessionCredential"] = False + self._status = status + return dict(status) + + async def login( + self, url: Any, password: Any, timeout: float = 15.0 + ) -> dict[str, Any]: + if not isinstance(password, str) or not password: + raise RpcFault(-32602, "Password is required") + if len(password.encode("utf-8")) > 65536: + raise RpcFault(-32602, "Password is too large") + return await self._auth_call(self._login_sync, url, password, timeout) + + def _login_sync( + self, url: Any, password: str, timeout: float + ) -> dict[str, Any]: + # The sign-in uses its own jar, so the requests need no shared state; + # only committing the resulting session below takes the lock. + base_url = normalize_remote_url(url) + jar = CookieJar() + try: + response = self._request( + base_url, + jar, + "POST", + "/api/auth/login", + {"password": password}, + timeout, + ) + except _RemoteAuthRequired as exc: + with self._lock: + status = self._make_status( + "expired", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + auth_enabled=True, + message="Remote Hermes rejected the sign-in", + status_code=exc.status_code, + ) + if base_url == self.base_url or not self.base_url: + self._status = status + raise RemoteLoginFault("Remote Hermes rejected the sign-in", status) from None + except _RemoteRedirectBlocked: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + message="Remote Hermes attempted a cross-origin redirect", + error_kind="redirect", + ) + raise RemoteLoginFault(status["message"], status, -32041) from None + except _RemoteTransportError: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=False, + message="Remote Hermes is unreachable", + error_kind="offline", + ) + raise RemoteLoginFault(status["message"], status, -32042) from None + if response["status"] == 429: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + message="Remote Hermes temporarily rate-limited sign-in", + error_kind="rate-limit", + ) + raise RemoteLoginFault(status["message"], status, -32044) + if not 200 <= response["status"] < 300: + status = self._make_status( + "error", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + message=f"Remote Hermes returned HTTP {response['status']}", + error_kind="http", + status_code=response["status"], + ) + raise RemoteLoginFault(status["message"], status, -32041) + value = self._json_response(response) + if value.get("ok") is not True: + status = self._make_status( + "expired", + configured=base_url == self.base_url, + url=base_url, + reachable=True, + auth_enabled=True, + message="Remote Hermes rejected the sign-in", + ) + raise RemoteLoginFault(status["message"], status) + status = self._probe_base( + base_url, + jar, + configured=True, + source="persisted", + timeout=timeout, + ) + if status["state"] != "connected": + message = ( + "Remote Hermes rejected the sign-in" + if status["state"] == "expired" + else status["message"] + ) + raise RemoteLoginFault(message, status) + with self._lock: + self.base_url = base_url + self.cookie_jar = jar + self.source = "persisted" + status["source"] = self.source + status["hasSessionCredential"] = bool(self._jar_cookies(jar)) + self._status = status + self._save() + return dict(status) + + async def logout(self, timeout: float = 10.0) -> dict[str, Any]: + return await self._auth_call(self._logout_sync, timeout) + + def _logout_sync(self, timeout: float) -> dict[str, Any]: + with self._lock: + base_url, jar = self.base_url, self.cookie_jar + remote_logout = False + if base_url: + try: + response = self._request( + base_url, + jar, + "POST", + "/api/auth/logout", + {}, + timeout, + ) + remote_logout = 200 <= response["status"] < 300 + except ( + _RemoteAuthRequired, + _RemoteRedirectBlocked, + _RemoteTransportError, + RpcFault, + ): + # Local credential removal is authoritative even when the + # remote session has already expired or is unreachable. + remote_logout = False + with self._lock: + self.cookie_jar = CookieJar() + self._delete_file() + self.base_url = self.environment_url + self.source = "environment" if self.environment_url else "none" + self._status = self._make_status( + "disconnected", + configured=bool(self.base_url), + url=self.base_url, + message=( + "Remote Hermes signed out" + if remote_logout + else "Saved remote session was removed" + ), + ) + result = dict(self._status) + result["remoteLogout"] = remote_logout + return result + + async def request_json( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + timeout: float = 30.0, + *, + session: RemoteSession | None = None, + ) -> dict[str, Any]: + """Reusable authenticated request primitive for the remote adapter.""" + + session = self.session_identity if session is None else session + result = await asyncio.to_thread( + self._request_json_sync, method, path, payload, timeout, session + ) + self._current_session(session) + return result + + async def upload_file( + self, + path: str, + fields: dict[str, str], + filename: str, + data: bytes, + mime_type: str, + timeout: float = 60.0, + ) -> dict[str, Any]: + """Upload one bounded file with the saved WebUI session cookie.""" + + return await asyncio.to_thread( + self._upload_file_sync, + path, + fields, + filename, + data, + mime_type, + timeout, + ) + + async def probe_multipart_route( + self, path: str, timeout: float = 15.0, *, session: RemoteSession | None = None + ) -> int: + """Return a multipart route's status after a fully consumed empty form.""" + + session = self.session_identity if session is None else session + result = await asyncio.to_thread( + self._probe_multipart_route_sync, path, timeout, session + ) + self._current_session(session) + return result + + def _probe_multipart_route_sync( + self, path: str, timeout: float, session: RemoteSession | None = None + ) -> int: + boundary = "----HermesMenubarProbe" + uuid.uuid4().hex + encoded = ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="session_id"\r\n\r\n' + "\r\n" + f"--{boundary}--\r\n" + ).encode("ascii") + base_url, jar = self._current_session(session) + try: + response = self._request( + base_url, + jar, + "POST", + path, + None, + timeout, + encoded_body=encoded, + content_type=f"multipart/form-data; boundary={boundary}", + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + if response["status"] == 401: + raise self._session_fault(base_url, jar, 401) + self._current_session((base_url, jar)) + return int(response["status"]) + + def _upload_file_sync( + self, + path: str, + fields: dict[str, str], + filename: str, + data: bytes, + mime_type: str, + timeout: float, + ) -> dict[str, Any]: + if len(data) > MAX_REMOTE_ATTACHMENT_BYTES: + raise RpcFault(-32602, "Attachment is larger than 20 MiB") + safe_name = re.sub(r"[^A-Za-z0-9._-]", "_", Path(filename).name)[:200] + if not safe_name or safe_name.strip(".") == "": + safe_name = "attachment" + boundary = "----HermesMenubar" + uuid.uuid4().hex + chunks: list[bytes] = [] + for name, value in fields.items(): + safe_field = re.sub(r"[^A-Za-z0-9_-]", "", str(name))[:80] + if not safe_field: + continue + chunks.extend([ + f"--{boundary}\r\n".encode("ascii"), + ( + f'Content-Disposition: form-data; name="{safe_field}"\r\n\r\n' + ).encode("ascii"), + str(value).encode("utf-8"), + b"\r\n", + ]) + chunks.extend([ + f"--{boundary}\r\n".encode("ascii"), + ( + 'Content-Disposition: form-data; name="file"; ' + f'filename="{safe_name}"\r\n' + ).encode("ascii"), + f"Content-Type: {mime_type or 'application/octet-stream'}\r\n\r\n".encode( + "ascii", errors="replace" + ), + data, + b"\r\n", + f"--{boundary}--\r\n".encode("ascii"), + ]) + encoded = b"".join(chunks) + + base_url, jar = self._current_session() + try: + response = self._request( + base_url, + jar, + "POST", + path, + None, + timeout, + encoded_body=encoded, + content_type=f"multipart/form-data; boundary={boundary}", + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + if response["status"] == 401: + raise self._session_fault(base_url, jar, 401) + if not 200 <= response["status"] < 300: + raise self._http_error_fault(response) + return self._json_response(response) + + def _request_json_sync( + self, + method: str, + path: str, + payload: dict[str, Any] | None, + timeout: float, + session: RemoteSession | None = None, + ) -> dict[str, Any]: + base_url, jar = self._current_session(session) + try: + response = self._request( + base_url, + jar, + method, + path, + payload, + timeout, + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + if method.upper() == "POST" and path == "/api/chat/start": + raise AmbiguousDelivery("remote prompt.submit") from None + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + if response["status"] == 401: + raise self._session_fault(base_url, jar, 401) + self._current_session((base_url, jar)) + if not 200 <= response["status"] < 300: + raise self._http_error_fault(response) + return self._json_response(response) + + async def probe_contract( + self, timeout: float = 10.0, *, session: RemoteSession | None = None + ) -> dict[str, Any]: + """Read the WebUI's non-streaming SSE capability probe and server tag.""" + + session = self.session_identity if session is None else session + result = await asyncio.to_thread(self._probe_contract_sync, timeout, session) + self._current_session(session) + return result + + def _probe_contract_sync( + self, timeout: float, session: RemoteSession | None = None + ) -> dict[str, Any]: + base_url, jar = self._current_session(session) + try: + response = self._request( + base_url, + jar, + "GET", + "/api/sessions/gateway/stream?probe=1", + None, + timeout, + ) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except _RemoteTransportError: + raise RpcFault(-32042, "Remote Hermes is unreachable") from None + + self._current_session((base_url, jar)) + + server = re.sub( + r"[^A-Za-z0-9._/ +()-]", "", str(response["headers"].get("Server", "")) + ).strip()[:128] + try: + value = self._json_response(response) + except RpcFault: + value = {} + session_path = str(value.get("session_stream_path") or "") + if not session_path.startswith("/api/") or "://" in session_path: + session_path = "/api/session/stream" + try: + fallback_poll_ms = int(value.get("fallback_poll_ms") or 30000) + except (TypeError, ValueError): + fallback_poll_ms = 30000 + return { + "checked": True, + "probeStatus": int(response.get("status") or 0), + "server": server, + "gatewaySessions": value.get("ok") is True, + "gatewayWatcher": value.get("watcher_running") is True, + "sessionStream": value.get("session_stream_available") is True, + "sessionStreamPath": session_path, + "fallbackPollMs": max(5000, min(300000, fallback_poll_ms)), + } + + def _current_session(self, expected: RemoteSession | None = None) -> RemoteSession: + """Snapshot the origin and cookie jar one unlocked request will use.""" + + with self._lock: + if expected is not None and not self.is_current_session(expected): + raise RpcFault(-32042, "Remote Hermes session changed during the request") + if not self.base_url: + raise RpcFault(-32040, "Remote Hermes is not configured") + return self.base_url, self.cookie_jar + + def _session_fault( + self, base_url: str, jar: CookieJar, status_code: int = 401 + ) -> RpcFault: + """Expire the session a challenged request used, if it is still current.""" + + with self._lock: + if self.base_url == base_url and self.cookie_jar is jar: + status = self._expire_session_locked(status_code) + elif self._status.get("state") == "expired": + # A concurrent request already expired this session. + status = dict(self._status) + else: + # A sign-in, sign-out, or origin change finished while this + # request was in flight. Its challenge describes a session that + # is already gone, so it must neither clear the replacement's + # cookies nor report the replacement as expired. + return RpcFault( + -32042, "Remote Hermes session changed during the request" + ) + return RemoteLoginFault(status["message"], status) + + def _expire_session_locked(self, status_code: int = 401) -> dict[str, Any]: + self.cookie_jar = CookieJar() + if self.source == "persisted": + self._save() + self._status = self._make_status( + "expired", + configured=bool(self.base_url), + url=self.base_url, + reachable=True, + auth_enabled=True, + message="Remote Hermes authentication is required", + status_code=status_code, + ) + return dict(self._status) + + def open_sse( + self, + path: str, + *, + last_event_id: str = "", + timeout: float = 45.0, + session: RemoteSession | None = None, + ) -> Any: + """Open one authenticated, same-origin WebUI SSE response. + + This synchronous primitive is intended to be called through + ``asyncio.to_thread``. The caller owns and must close the returned + response. Cookie values and redirect destinations never leave the + bridge process. + """ + + base_url, jar = self._current_session(session) + if not isinstance(path, str) or not path.startswith("/") or "://" in path: + raise RpcFault(-32602, "Remote Hermes API path is invalid") + headers = { + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + "User-Agent": "nerdos-hermes-menubar-bridge/1", + } + if last_event_id: + headers["Last-Event-ID"] = str(last_event_id)[:1024] + redirect_handler = _SameOriginRedirectHandler(_remote_origin(base_url)) + opener = build_opener(redirect_handler, HTTPCookieProcessor(jar)) + request = Request(f"{base_url}{path}", headers=headers, method="GET") + try: + response = opener.open(request, timeout=timeout) + except _RemoteAuthRequired as exc: + raise self._session_fault(base_url, jar, exc.status_code) from None + except _RemoteRedirectBlocked: + raise RpcFault( + -32041, "Remote Hermes attempted a cross-origin redirect" + ) from None + except HTTPError as exc: + status_code = int(exc.code) + with suppress(Exception): + exc.close() + if status_code == 401: + raise self._session_fault(base_url, jar, status_code) from None + raise RpcFault( + -32041, f"Remote Hermes returned HTTP {status_code}" + ) from None + except (URLError, TimeoutError, OSError): + raise RpcFault(-32042, "Remote Hermes stream is unreachable") from None + + status_code = int(getattr(response, "status", 0) or 0) + content_type = str(response.headers.get("Content-Type", "")).lower() + final_url = str(response.geturl() or "") + if _is_login_url(final_url) or "text/html" in content_type: + with suppress(Exception): + response.close() + raise self._session_fault(base_url, jar, status_code or 302) + if status_code != 200 or "text/event-stream" not in content_type: + with suppress(Exception): + response.close() + raise RpcFault( + -32041, "Remote Hermes returned an invalid event stream" + ) + try: + self._current_session((base_url, jar)) + except RpcFault: + response.close() + raise + return response + + def authenticated_headers(self, path: str = "/") -> dict[str, str]: + """Return an origin-scoped Cookie header for an internal SSE adapter. + + The returned value is a credential and must never be sent downstream or + logged. Accepting only an absolute-path reference prevents callers from + accidentally forwarding it to another origin. + """ + + with self._lock: + if not self.base_url: + raise RpcFault(-32040, "Remote Hermes is not configured") + if not isinstance(path, str) or not path.startswith("/") or "://" in path: + raise RpcFault(-32602, "Remote Hermes API path is invalid") + request = Request(f"{self.base_url}{path}") + self.cookie_jar.add_cookie_header(request) + cookie = request.get_header("Cookie") + return {"Cookie": cookie} if cookie else {} diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py b/desktop/hermes-menubar-bridge/nerdos_hermes/gateway.py similarity index 99% rename from roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py rename to desktop/hermes-menubar-bridge/nerdos_hermes/gateway.py index 80f427cb..63890369 100644 --- a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py +++ b/desktop/hermes-menubar-bridge/nerdos_hermes/gateway.py @@ -244,7 +244,7 @@ def _api_request_sync( ) headers = { "Accept": "application/json", - "User-Agent": "cybexos-hermes-menubar-bridge/1", + "User-Agent": "nerdos-hermes-menubar-bridge/1", "X-Hermes-Session-Token": token, } if body is not None: @@ -357,7 +357,7 @@ def _fetch_token(self) -> str: return configured request = Request( f"{self.base_url}/", - headers={"User-Agent": "cybexos-hermes-menubar-bridge/1"}, + headers={"User-Agent": "nerdos-hermes-menubar-bridge/1"}, ) with urlopen(request, timeout=10) as response: body = response.read(1024 * 1024).decode("utf-8", errors="replace") diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py b/desktop/hermes-menubar-bridge/nerdos_hermes/protocol.py similarity index 100% rename from roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py rename to desktop/hermes-menubar-bridge/nerdos_hermes/protocol.py diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py b/desktop/hermes-menubar-bridge/nerdos_hermes/registry.py similarity index 100% rename from roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py rename to desktop/hermes-menubar-bridge/nerdos_hermes/registry.py diff --git a/desktop/hypr/autostart.lua b/desktop/hypr/autostart.lua new file mode 100644 index 00000000..346a8caa --- /dev/null +++ b/desktop/hypr/autostart.lua @@ -0,0 +1,3 @@ +hl.on("hyprland.start", function() + hl.exec_cmd("nerdos-session-start") +end) diff --git a/roles/desktop/files/bindings.lua b/desktop/hypr/bindings.lua similarity index 76% rename from roles/desktop/files/bindings.lua rename to desktop/hypr/bindings.lua index 9f415c30..f9d7cf2a 100644 --- a/roles/desktop/files/bindings.lua +++ b/desktop/hypr/bindings.lua @@ -1,36 +1,32 @@ local mainMod = "SUPER" -local terminal = "kitty" +local terminal = "nerdos-apps terminal --" local home = os.getenv("HOME") local features = require("features") -local browser = "firefox" -local privateBrowser = "firefox --private-window" -if features.proprietary_apps then - browser = "brave-origin-stable --enable-features=TouchpadOverscrollHistoryNavigation,PipeWireCamera --enable-wayland-ime --restore-last-session --hide-crash-restore-bubble" - privateBrowser = browser .. " --incognito" -end +local browser = "nerdos-apps browser" +local privateBrowser = "nerdos-apps browser --private" --- Web apps are installed per account (cybexos-webapp; Install web app… in --- the launcher). The helper picks the browser as Super+B does, and tells the +-- Web apps are installed per account (nerdos-webapp; Install web app… in +-- the launcher). The helper follows the selected browser and tells the -- user how to reinstall one that was removed. local function web_app(id) - return home .. "/.local/bin/cybexos-webapp launch " .. id + return "nerdos-webapp launch " .. id end -local previous = rawget(_G, "__fedora_hypr_binds") or {} +local previous = rawget(_G, "__nerdos_hypr_binds") or {} for _, keybind in ipairs(previous) do if keybind and keybind.remove then keybind:remove() elseif keybind and keybind.unbind then keybind:unbind() end end -_G.__fedora_hypr_binds = {} +_G.__nerdos_hypr_binds = {} -- Every binding the user should know about carries a description, "Group: -- Label". The Super+K cheatsheet is drawn from `hyprctl binds -j`, so these -- strings are the cheatsheet: a binding without one does not appear there. --- Bindings added in ~/.config/cybexos/hypr/user.lua join it the same way. +-- Bindings added in ~/.config/nerdos/hypr/user.lua join it the same way. local function bind(keys, dispatcher, opts) hl.unbind(keys) local keybind = hl.bind(keys, dispatcher, opts) - table.insert(_G.__fedora_hypr_binds, keybind) + table.insert(_G.__nerdos_hypr_binds, keybind) return keybind end @@ -41,26 +37,32 @@ end bind(mainMod .. " + Return", hl.dsp.exec_cmd(terminal), { description = "Apps: Terminal" }) if features.podman then - bind(mainMod .. " + SHIFT + F", hl.dsp.exec_cmd(terminal .. " -e " .. home .. "/.local/bin/dev-fedora-shell"), - { description = "Development: Fedora container shell" }) - bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd(terminal .. " -e " .. home .. "/.local/bin/dev-arch-shell"), + bind(mainMod .. " + SHIFT + F", hl.dsp.exec_cmd(terminal .. " dev-nix-shell"), + { description = "Development: Nix development shell" }) + bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd(terminal .. " dev-arch-shell"), { description = "Development: Arch container shell" }) - bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd(terminal .. " -e " .. home .. "/.local/bin/dev-debian-shell"), + bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd(terminal .. " dev-debian-shell"), { description = "Development: Debian container shell" }) end bind(mainMod .. " + SPACE", hl.dsp.global("quickshell:launcherToggle"), { description = "Shell: Launcher" }) -bind(mainMod .. " + CTRL + SHIFT + A", hl.dsp.exec_cmd(home .. "/.local/bin/cybex agent --window"), +-- A global shortcut also reports the key's release, which is what lets a +-- tap open the assistant's box and a hold record a question (docs/assistant.md). +if features.assistant then + bind(mainMod .. " + I", hl.dsp.global("quickshell:assistantTalk"), + { description = "Shell: Assistant" }) +end +bind(mainMod .. " + CTRL + SHIFT + A", hl.dsp.exec_cmd("nerd agent --window"), { description = "Apps: AI agent" }) --- The shell runs from the CybexOS runtime by path, where a bare --- `qs ipc call` finds no configuration; cybexos-runtime names the active one. -bind(mainMod .. " + comma", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc settings toggle"), - { description = "Shell: CybexOS Settings" }) +-- The shell runs from the NerdOS runtime by path, where a bare +-- `qs ipc call` finds no configuration; nerdos-runtime names the active one. +bind(mainMod .. " + comma", hl.dsp.exec_cmd("nerdos-runtime ipc settings toggle"), + { description = "Shell: NerdOS Settings" }) -- Shell surfaces the menubar also opens by click. -bind(mainMod .. " + N", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc popouts toggle notifications"), +bind(mainMod .. " + N", hl.dsp.exec_cmd("nerdos-runtime ipc popouts toggle notifications"), { description = "Shell: Notifications" }) -bind(mainMod .. " + A", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc popouts toggle control"), +bind(mainMod .. " + A", hl.dsp.exec_cmd("nerdos-runtime ipc popouts toggle control"), { description = "Shell: Control Panel" }) -bind(mainMod .. " + K", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc session keys"), +bind(mainMod .. " + K", hl.dsp.exec_cmd("nerdos-runtime ipc session keys"), { description = "Shell: Keyboard shortcuts" }) bind(mainMod .. " + E", hl.dsp.exec_cmd("nautilus --new-window"), { description = "Apps: Files" }) bind(mainMod .. " + B", hl.dsp.exec_cmd(browser), { description = "Apps: Browser" }) @@ -76,15 +78,15 @@ end if features.developer_tools then -- Docker group membership is root-equivalent and opt-in, so without it the -- socket is reachable only through sudo. The binary is resolved before - -- sudo because sudo's secure_path need not include /usr/local/bin. + -- sudo because sudo's secure_path may differ from the user's package profile. local lazydocker = [[sh -c 'docker info >/dev/null 2>&1 && exec lazydocker; exec sudo "$(command -v lazydocker)"']] - bind(mainMod .. " + D", hl.dsp.exec_cmd(terminal .. " -e " .. lazydocker), + bind(mainMod .. " + D", hl.dsp.exec_cmd(terminal .. " " .. lazydocker), { description = "Development: Docker (lazydocker)" }) end if features.connected_widgets then bind(mainMod .. " + T", hl.dsp.exec_cmd("t3code-desktop"), { description = "Apps: T3 Code" }) end -bind(mainMod .. " + SHIFT + T", hl.dsp.exec_cmd(terminal .. " -e btop"), { description = "Apps: System monitor" }) +bind(mainMod .. " + SHIFT + T", hl.dsp.exec_cmd(terminal .. " btop"), { description = "Apps: System monitor" }) bind(mainMod .. " + W", hl.dsp.exec_cmd(web_app("whatsapp")), { description = "Web apps: WhatsApp" }) bind(mainMod .. " + Y", hl.dsp.exec_cmd(web_app("youtube")), { description = "Web apps: YouTube" }) bind(mainMod .. " + SHIFT + P", hl.dsp.exec_cmd(web_app("google-photos")), @@ -102,9 +104,9 @@ end bind(mainMod .. " + C", function() send_shortcut_once("CTRL", "Insert") end, { description = "Clipboard: Copy" }) bind(mainMod .. " + V", function() send_shortcut_once("SHIFT", "Insert") end, { description = "Clipboard: Paste" }) bind(mainMod .. " + X", function() send_shortcut_once("CTRL", "X") end, { description = "Clipboard: Cut" }) -bind(mainMod .. " + SHIFT + V", hl.dsp.exec_cmd(home .. "/.local/bin/clipboard-image-to-file"), +bind(mainMod .. " + SHIFT + V", hl.dsp.exec_cmd("clipboard-image-to-file"), { description = "Clipboard: Save copied image to a file" }) -bind(mainMod .. " + CTRL + V", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc launcher open clipboard"), +bind(mainMod .. " + CTRL + V", hl.dsp.exec_cmd("nerdos-runtime ipc launcher open clipboard"), { description = "Clipboard: Clipboard history" }) bind(mainMod .. " + Q", hl.dsp.window.close(), { description = "Windows: Close window" }) @@ -116,11 +118,11 @@ bind(mainMod .. " + BACKSPACE", hl.dsp.window.set_prop({ prop = "alpha", value = -- `overview` IPC target serves the touchpad gesture. bind(mainMod .. " + TAB", hl.dsp.global("quickshell:overviewToggle"), { description = "Windows: Overview" }) bind(mainMod .. " + SHIFT + M", hl.dsp.exit(), { description = "Shell: Exit Hyprland" }) -bind(mainMod .. " + L", hl.dsp.exec_cmd("systemctl --user start cybexos-session-lock.service"), +bind(mainMod .. " + L", hl.dsp.exec_cmd("nerdos-session-action lock"), { description = "Shell: Lock screen" }) -- Effects, gaps and idle lock off for games; the helper notifies and the -- menubar shows an indicator while it is on. -bind(mainMod .. " + SHIFT + G", hl.dsp.exec_cmd(home .. "/.local/bin/game-mode toggle"), +bind(mainMod .. " + SHIFT + G", hl.dsp.exec_cmd("game-mode toggle"), { description = "Shell: Game mode" }) for _, direction in ipairs({ "left", "right", "up", "down" }) do @@ -192,44 +194,44 @@ bind(mainMod .. " + CTRL + equal", function() step_zoom(1) end, bind(mainMod .. " + CTRL + minus", function() step_zoom(-1) end, { repeating = true, description = "Windows: Adjust zoom" }) -bind(mainMod .. " + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot region"), +bind(mainMod .. " + grave", hl.dsp.exec_cmd("screenshot region"), { description = "Capture: Screenshot a region or window" }) -bind(mainMod .. " + SHIFT + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screen-record"), +bind(mainMod .. " + SHIFT + grave", hl.dsp.exec_cmd("screen-record"), { description = "Capture: Start or stop screen recording" }) -bind("Print", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot region"), +bind("Print", hl.dsp.exec_cmd("screenshot region"), { description = "Capture: Screenshot a region or window" }) -bind("SHIFT + Print", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot fullscreen"), +bind("SHIFT + Print", hl.dsp.exec_cmd("screenshot fullscreen"), { description = "Capture: Screenshot the whole screen" }) -bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd(home .. "/.local/bin/screen-ocr"), +bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("screen-ocr"), { description = "Capture: Copy text or QR code from a region" }) -- Pressing the scrolling screenshot's key again finishes it, as do Enter and -- Escape, which the script binds only while a capture runs. -bind("ALT + Print", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot scroll"), +bind("ALT + Print", hl.dsp.exec_cmd("screenshot scroll"), { description = "Capture: Scrolling screenshot" }) -bind(mainMod .. " + ALT + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot scroll"), +bind(mainMod .. " + ALT + grave", hl.dsp.exec_cmd("screenshot scroll"), { description = "Capture: Scrolling screenshot" }) -bind(mainMod .. " + CTRL + SHIFT + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screen-record --webcam"), +bind(mainMod .. " + CTRL + SHIFT + grave", hl.dsp.exec_cmd("screen-record --webcam"), { description = "Capture: Record with webcam" }) -bind(mainMod .. " + Print", hl.dsp.exec_cmd(home .. "/.local/bin/color-picker"), +bind(mainMod .. " + Print", hl.dsp.exec_cmd("color-picker"), { description = "Capture: Pick a colour" }) -bind(mainMod .. " + SHIFT + C", hl.dsp.exec_cmd(home .. "/.local/bin/color-picker"), +bind(mainMod .. " + SHIFT + C", hl.dsp.exec_cmd("color-picker"), { description = "Capture: Pick a colour" }) local volumeUp = "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+" local volumeDown = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-" local volumeMute = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle" if features.xps_2026 then - volumeUp = "/usr/local/libexec/xps-speaker-tuning control up || " .. volumeUp - volumeDown = "/usr/local/libexec/xps-speaker-tuning control down || " .. volumeDown - volumeMute = "/usr/local/libexec/xps-speaker-tuning control mute || " .. volumeMute + volumeUp = "xps-speaker-tuning control up || " .. volumeUp + volumeDown = "xps-speaker-tuning control down || " .. volumeDown + volumeMute = "xps-speaker-tuning control mute || " .. volumeMute end bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(volumeUp), { locked = true, repeating = true, description = "Hardware keys: Volume up" }) bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(volumeDown), { locked = true, repeating = true, description = "Hardware keys: Volume down" }) -bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(home .. "/.local/bin/brightness-control up 5"), +bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightness-control up 5"), { locked = true, repeating = true, description = "Hardware keys: Brightness up" }) -bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(home .. "/.local/bin/brightness-control down 5"), +bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightness-control down 5"), { locked = true, repeating = true, description = "Hardware keys: Brightness down" }) bind("XF86AudioMute", hl.dsp.exec_cmd(volumeMute), { locked = true, description = "Hardware keys: Mute audio" }) if features.developer_tools then diff --git a/roles/desktop/files/displays.lua b/desktop/hypr/displays.lua similarity index 97% rename from roles/desktop/files/displays.lua rename to desktop/hypr/displays.lua index 83a5cd90..5e38bae0 100644 --- a/roles/desktop/files/displays.lua +++ b/desktop/hypr/displays.lua @@ -1,14 +1,14 @@ -- Display preferences saved by Settings -> Displays. -- --- ~/.config/cybexos/displays.json is data, never code. It is parsed here by a +-- ~/.config/nerdos/displays.json is data, never code. It is parsed here by a -- strict, bounded JSON reader and turned into hl.monitor() rules, so an EDID -- string can never become Lua and a hostile or truncated file costs bounded -- work. A missing file changes nothing. An unreadable or invalid one is --- reported in $XDG_RUNTIME_DIR/cybexos/displays-status.json and ignored as a +-- reported in $XDG_RUNTIME_DIR/nerdos/displays-status.json and ignored as a -- whole: the vendor rules stay in effect and Hyprland always starts. -- -- hyprland.lua requires this module after the vendor `monitors` module and --- before ~/.config/cybexos/hypr/user.lua. Hyprland resolves monitor rules +-- before ~/.config/nerdos/hypr/user.lua. Hyprland resolves monitor rules -- last-first, so a saved choice overrides the vendor rule for its monitor and -- user.lua still overrides both. Each rule starts from the vendor rules that -- match the same monitor, which keeps vendor-only fields such as bitdepth, @@ -24,8 +24,8 @@ local home = os.getenv("HOME") or "" local config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config") local runtime_dir = os.getenv("XDG_RUNTIME_DIR") -M.path = config_home .. "/cybexos/displays.json" -M.status_path = runtime_dir and (runtime_dir .. "/cybexos/displays-status.json") or nil +M.path = config_home .. "/nerdos/displays.json" +M.status_path = runtime_dir and (runtime_dir .. "/nerdos/displays-status.json") or nil M.MAX_BYTES = 262144 M.MAX_ENTRIES = 64 M.MAX_DEPTH = 16 @@ -468,7 +468,7 @@ local function vendor_matches(selector, entry) end function M.vendor_rules() - local rules = rawget(_G, "__cybexos_vendor_monitor_rules") + local rules = rawget(_G, "__nerdos_vendor_monitor_rules") return type(rules) == "table" and rules or {} end @@ -539,7 +539,7 @@ end -- ------------------------------------------------------------ runtime ---- -local previous = rawget(_G, "__cybexos_displays") +local previous = rawget(_G, "__nerdos_displays") if previous then for _, subscription in ipairs(previous.subscriptions or {}) do if subscription and subscription.remove then subscription:remove() end @@ -552,7 +552,7 @@ local state = { decisions = {}, source = M.path, } -_G.__cybexos_displays = state +_G.__nerdos_displays = state local function current_monitors() local ok, monitors = pcall(hl.get_monitors) diff --git a/desktop/hypr/features.lua b/desktop/hypr/features.lua new file mode 100644 index 00000000..da11888b --- /dev/null +++ b/desktop/hypr/features.lua @@ -0,0 +1,7 @@ +return { + podman = os.getenv("NERDOS_PODMAN") == "1", developer_tools = os.getenv("NERDOS_DEVELOPER_TOOLS") ~= "0", + connected_widgets = os.getenv("NERDOS_CONNECTED_WIDGETS") ~= "0", + assistant = os.getenv("NERDOS_ASSISTANT") ~= "0", + private_portal = false, proprietary_apps = os.getenv("NERDOS_PROPRIETARY_APPS") == "1", + xps_2026 = os.getenv("NERDOS_XPS_2026") == "1", +} diff --git a/desktop/hypr/hypridle.conf b/desktop/hypr/hypridle.conf new file mode 100644 index 00000000..e20d6c18 --- /dev/null +++ b/desktop/hypr/hypridle.conf @@ -0,0 +1,38 @@ +general { + lock_cmd = systemctl --user start nerdos-session-lock.service + before_sleep_cmd = nerdos-session-action lock + after_sleep_cmd = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "on" }))' + inhibit_sleep = 3 +} + +listener { + timeout = 60 + condition_cmd = hyprctl locked | grep -qx true + on-timeout = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "off" }))' + on-resume = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "on" }))' +} + +listener { + timeout = 300 + on-timeout = systemctl --user start nerdos-session-lock.service +} + +listener { + timeout = 360 + condition_cmd = hyprctl locked | grep -qx true + on-timeout = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "off" }))' + on-resume = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "on" }))' +} + +listener { + timeout = 600 + on-timeout = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "off" }))' + on-resume = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "on" }))' +} + +listener { + timeout = 1800 + condition_cmd = nerdos-session-action on-battery + condition_retry = 60 + on-timeout = nerdos-session-action idle-suspend on-battery +} diff --git a/roles/desktop/files/hyprland.lua b/desktop/hypr/hyprland.lua similarity index 76% rename from roles/desktop/files/hyprland.lua rename to desktop/hypr/hyprland.lua index 605895f0..75f48d23 100644 --- a/roles/desktop/files/hyprland.lua +++ b/desktop/hypr/hyprland.lua @@ -1,23 +1,23 @@ local home = assert(os.getenv("HOME"), "HOME is required") local config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config") local data_home = os.getenv("XDG_DATA_HOME") or (home .. "/.local/share") -local generated_dir = data_home .. "/cybexos/runtime/hypr" +local generated_dir = "/etc/nerdos/runtime/hypr" local source_dir = generated_dir -local user_dir = config_home .. "/cybexos/hypr" +local user_dir = config_home .. "/nerdos/hypr" -- Development mode substitutes only source-controlled modules. Rendered -- machine facts remain in the installed runtime, so selecting a checkout can -- never make the compositor consume an unrendered Jinja template. -local source_file = io.open(config_home .. "/cybexos/dev-source", "r") +local source_file = io.open(config_home .. "/nerdos/dev-source", "r") if source_file then local checkout = source_file:read("*l") local trailing = source_file:read("*l") source_file:close() if checkout and checkout:sub(1, 1) == "/" and not trailing then - local required = io.open(checkout .. "/roles/desktop/files/bindings.lua", "r") + local required = io.open(checkout .. "/desktop/hypr/bindings.lua", "r") if required then required:close() - source_dir = checkout .. "/roles/desktop/files" + source_dir = checkout .. "/desktop/hypr" end end end @@ -34,8 +34,8 @@ end -- Record independent float rules from managed configuration and user.lua. -- Floating workspace opening rules must pass over those applications/dialogs. -- Keep the native function across reloads instead of wrapping a wrapper. -local native_window_rule = rawget(_G, "__cybexos_native_window_rule") or hl.window_rule -_G.__cybexos_native_window_rule = native_window_rule +local native_window_rule = rawget(_G, "__nerdos_native_window_rule") or hl.window_rule +_G.__nerdos_native_window_rule = native_window_rule local window_rules = {} hl.window_rule = function(spec) window_rules[#window_rules + 1] = spec @@ -46,20 +46,20 @@ require("monitors") -- Settings -> Displays. Its saved file is user data that must never stop the -- compositor from starting, so even an unexpected module error is contained; -- the vendor monitor rules above then stay in effect. Diagnose one with --- hyprctl repl 'return __cybexos_displays_error' +-- hyprctl repl 'return __nerdos_displays_error' local displays_ok, displays_error = pcall(require, "displays") if not displays_ok then - _G.__cybexos_displays_error = tostring(displays_error) + _G.__nerdos_displays_error = tostring(displays_error) end require("input") local input_ok, input_error = pcall(require, "input_preferences") -if not input_ok then _G.__cybexos_input_error = tostring(input_error) end +if not input_ok then _G.__nerdos_input_error = tostring(input_error) end require("bindings") require("looknfeel") require("autostart") -- The user layer is deliberately last. It is optional and never synthesized --- or changed by CybexOS. +-- or changed by NerdOS. local user_entry = io.open(user_dir .. "/user.lua", "r") if user_entry then user_entry:close() @@ -78,17 +78,17 @@ for _, rule in ipairs(window_rules) do end end end -_G.__cybexos_float_exclusions = exclusions +_G.__nerdos_float_exclusions = exclusions hl.window_rule = native_window_rule local workspaces_ok, workspaces_error = pcall(require, "workspaces") -if not workspaces_ok then _G.__cybexos_workspaces_error = tostring(workspaces_error) -else _G.__cybexos_workspaces_error = nil end +if not workspaces_ok then _G.__nerdos_workspaces_error = tostring(workspaces_error) +else _G.__nerdos_workspaces_error = nil end -- The existing system-theme owner remains the trigger; titlebars consume its -- bounded JSON tokens and need no polling or compositor reload on theme edits. -if workspaces_ok and type(cybexos_system_theme) == "function" then - local apply_theme = cybexos_system_theme - cybexos_system_theme = function(path) +if workspaces_ok and type(nerdos_system_theme) == "function" then + local apply_theme = nerdos_system_theme + nerdos_system_theme = function(path) apply_theme(path) require("workspaces").refresh_theme() end diff --git a/roles/desktop/templates/hyprlock.conf.j2 b/desktop/hypr/hyprlock.conf similarity index 100% rename from roles/desktop/templates/hyprlock.conf.j2 rename to desktop/hypr/hyprlock.conf diff --git a/desktop/hypr/input.lua b/desktop/hypr/input.lua new file mode 100644 index 00000000..bf3dfa7b --- /dev/null +++ b/desktop/hypr/input.lua @@ -0,0 +1,138 @@ +local function persisted_scroll_factor() + local home = os.getenv("HOME") + if not home then return 1.0 end + + local file = io.open(home .. "/.config/nerdos/shell.json", "r") + if not file then + file = io.open(home .. "/.local/state/quickshell/shell-settings.json", "r") + end + if not file then return 1.0 end + local contents = file:read("*a") + file:close() + + local value = tonumber(contents:match('"scrollFactor"%s*:%s*([%d%.]+)')) + if value and value >= 0.2 and value <= 2.0 then return value end + return 1.0 +end + +-- Saved input preferences reuse the release's option defaults. +_G.__nerdos_vendor_keyboard_options = "compose:caps,lv3:ralt_switch" + +hl.config({ + input = { + kb_layout = "us", + kb_variant = "", + -- Keep left Alt available to applications (for example Codex Alt+Up). + kb_options = _G.__nerdos_vendor_keyboard_options, + follow_mouse = 1, + natural_scroll = true, + repeat_rate = 40, + repeat_delay = 600, + numlock_by_default = true, + touchpad = { + natural_scroll = true, + disable_while_typing = true, + tap_to_click = true, + scroll_factor = persisted_scroll_factor(), + }, + }, +}) + +-- Touchpad gestures: three fingers sideways switch workspace, three fingers +-- up open the overview. Settings -> Touchpad can turn the workspace swipe off +-- through input_preferences.lua, which calls _G.__nerdos_gestures.remove(). +-- +-- Hyprland rejects a gesture that an earlier one overshadows, so a personal +-- gesture in user.lua on the same fingers and direction would otherwise show +-- a configuration error. While the configuration loads, hl.gesture first +-- retires any NerdOS gesture the new one overlaps: user.lua keeps winning, +-- as it does for every other setting. Each reload starts a fresh Lua state, +-- so this wrapper never stacks. +local gestures = { active = {} } +_G.__nerdos_gestures = gestures +local add_gesture = hl.gesture +local SWIPE_DIRECTIONS = { + left = { left = true }, l = { left = true }, + right = { right = true }, r = { right = true }, + up = { up = true }, u = { up = true }, top = { up = true }, t = { up = true }, + down = { down = true }, d = { down = true }, bottom = { down = true }, b = { down = true }, + horizontal = { left = true, right = true }, horiz = { left = true, right = true }, + vertical = { up = true, down = true }, vert = { up = true, down = true }, + swipe = { left = true, right = true, up = true, down = true }, +} + +local function swipe_directions(direction) + return type(direction) == "string" and SWIPE_DIRECTIONS[direction:lower()] or nil +end + +local function overlaps(first, second) + for direction in pairs(first) do + if second[direction] then return true end + end + return false +end + +local function same_directions(first, second) + for direction in pairs(first) do + if not second[direction] then return false end + end + for direction in pairs(second) do + if not first[direction] then return false end + end + return true +end + +local function unset(entry) + add_gesture({ fingers = entry.fingers, direction = entry.direction, action = "unset" }) +end + +-- Retires the named NerdOS gesture; false when it is not registered. +function gestures.remove(name) + for index, entry in ipairs(gestures.active) do + if entry.name == name then + table.remove(gestures.active, index) + unset(entry) + return true + end + end + return false +end + +if type(add_gesture) == "function" then + hl.gesture = function(spec) + local wanted = type(spec) == "table" and (spec.mods == nil or spec.mods == "") + and swipe_directions(spec.direction) + if wanted then + for index = #gestures.active, 1, -1 do + local entry = gestures.active[index] + local ours = swipe_directions(entry.direction) + if entry.fingers == spec.fingers then + if spec.action == "unset" then + -- Hyprland removes only an exact match; the caller's own unset + -- retires ours, so it must not be unset twice. + if same_directions(ours, wanted) then table.remove(gestures.active, index) end + elseif overlaps(ours, wanted) then + table.remove(gestures.active, index) + unset(entry) + end + end + end + end + return add_gesture(spec) + end + + local home = os.getenv("HOME") or "" + local function vendor_gesture(name, spec) + add_gesture(spec) + gestures.active[#gestures.active + 1] = { name = name, fingers = spec.fingers, direction = spec.direction } + end + vendor_gesture("workspace", { fingers = 3, direction = "horizontal", action = "workspace" }) + -- The shell runs from the NerdOS runtime by path; nerdos-runtime names it. + vendor_gesture("overview", { + fingers = 3, + direction = "up", + action = function() + hl.exec_cmd("nerdos-runtime ipc overview toggle") + end, + }) +end diff --git a/roles/desktop/files/input_preferences.lua b/desktop/hypr/input_preferences.lua similarity index 93% rename from roles/desktop/files/input_preferences.lua rename to desktop/hypr/input_preferences.lua index 7c1cc7b9..af72de98 100644 --- a/roles/desktop/files/input_preferences.lua +++ b/desktop/hypr/input_preferences.lua @@ -3,7 +3,7 @@ local M = {} local json = require("displays") local config = os.getenv("XDG_CONFIG_HOME") or ((os.getenv("HOME") or "") .. "/.config") -M.path = config .. "/cybexos/input.json" +M.path = config .. "/nerdos/input.json" local shortcuts = { [""] = true, ["grp:alt_shift_toggle"] = true, ["grp:ctrl_shift_toggle"] = true, ["grp:caps_toggle"] = true } local function token(value) @@ -31,7 +31,7 @@ function M.apply(document) assert(shortcuts[keyboard.shortcut], "invalid layout switching shortcut") -- Keep the shared vendor options; user.lua can still customize them. local options = {} - for option in (_G.__cybexos_vendor_keyboard_options or ""):gmatch("[^,]+") do + for option in (_G.__nerdos_vendor_keyboard_options or ""):gmatch("[^,]+") do if not option:match("^grp:") and not (keyboard.shortcut == "grp:caps_toggle" and option == "compose:caps") then options[#options + 1] = option end @@ -59,7 +59,7 @@ function M.apply(document) input.sensitivity = touchpad.sensitivity end hl.config({ input = input }) - local gestures = rawget(_G, "__cybexos_gestures") + local gestures = rawget(_G, "__nerdos_gestures") if touchpad.workspaceSwipe == false and type(gestures) == "table" and type(gestures.remove) == "function" then gestures.remove("workspace") end @@ -71,8 +71,8 @@ if file then local document, error = json.decode(contents) local ok, reason = false, error if document ~= nil then ok, reason = pcall(M.apply, document) end - _G.__cybexos_input_error = not ok and tostring(reason) or nil + _G.__nerdos_input_error = not ok and tostring(reason) or nil else - _G.__cybexos_input_error = nil + _G.__nerdos_input_error = nil end return M diff --git a/desktop/hypr/looknfeel.lua b/desktop/hypr/looknfeel.lua new file mode 100644 index 00000000..703a7980 --- /dev/null +++ b/desktop/hypr/looknfeel.lua @@ -0,0 +1,441 @@ +-- Keep compositor blur in step with the shell's persisted glass setting from +-- the first frame. Quickshell updates the named rule below at runtime; this +-- lightweight read covers compositor reloads and restarts before the shell has +-- a reason to emit a setting change. Without a stored choice glass is off, as +-- it is in the shell's defaults. High contrast turns glass off in the shell +-- (Theme.glassActive, Settings.applyGlassEffect), so it turns blur off here +-- too; reading glassEnabled alone brought blur back on every reload. +local function persisted_glass_enabled() + local home = os.getenv("HOME") + if not home then return false end + + local file = io.open(home .. "/.config/nerdos/shell.json", "r") + if not file then + file = io.open(home .. "/.local/state/quickshell/shell-settings.json", "r") + end + if not file then return false end + local contents = file:read("*a") + file:close() + + local function enabled(key) + return contents:match('[,{]%s*"' .. key .. '"%s*:%s*(%a+)') == "true" + end + return enabled("glassEnabled") and not enabled("highContrast") +end + +hl.config({ + general = { + gaps_in = 5, + gaps_out = 10, + border_size = 0, + layout = "dwindle", + -- The colours in this table are the system theme's for the default dark + -- appearance (theme_hyprland.py); the theme below replaces them. + col = { active_border = "rgb(d3d283)", inactive_border = "rgb(3a3936)" }, + }, + cursor = { no_hardware_cursors = false }, + decoration = { + -- Match Quickshell's Theme.hugCornerSize so tiled window corners continue + -- the menubar's inverted edge curve at the reserved boundary. + rounding = 16, + -- Blur separates translucent surfaces from the wallpaper. One pass keeps + -- compositing work down on high-resolution, high-refresh-rate displays; + -- vibrancy stands in for the design's saturate(1.7). + blur = { + enabled = true, size = 6, passes = 1, new_optimizations = true, + vibrancy = 0.25, noise = 0.012, + }, + shadow = { enabled = true, range = 4, render_power = 3, color = "rgba(1a1917ee)" }, + }, + animations = { enabled = true }, + -- SDDM owns crash recovery: a new desktop requires a new authenticated login. + -- Hyprland's random splash line (one reads "Segmentation fault (core + -- dumped)") shows wherever no wallpaper covers the background, as in the + -- image's installation-only session. + misc = { force_default_wallpaper = 0, disable_hyprland_logo = true, disable_splash_rendering = true, + focus_on_activate = false, disable_watchdog_warning = true }, + xwayland = { + force_zero_scaling = false, + }, + group = { + col = { + border_active = "rgb(d3d283)", border_inactive = "rgb(3a3936)", + border_locked_active = "rgb(ff8f8f)", border_locked_inactive = "rgb(3a3936)", + }, + groupbar = { + col = { + active = "rgb(d3d283)", inactive = "rgb(3a3936)", + locked_active = "rgb(ff8f8f)", locked_inactive = "rgb(3a3936)", + }, + text_color = "rgb(f2f0ea)", text_color_inactive = "rgb(b3afa4)", + text_color_locked_active = "rgb(f2f0ea)", text_color_locked_inactive = "rgb(b3afa4)", + }, + }, + dwindle = { preserve_split = true, split_width_multiplier = 1.0 }, +}) + +-- The system theme (docs/system-theme.md). The shell renders its colours into +-- ~/.local/state/nerdos/theme/hyprland.lua, a data-only table of the keys +-- below, and it is applied over the vendor colours above. That file is state +-- the shell rewrites, so nothing in it may stop the compositor from starting: +-- a missing file keeps the vendor colours, every file is loaded in an empty +-- environment and checked key by key, and a damaged one is ignored. Diagnose +-- one with +-- hyprctl repl 'return __nerdos_system_theme.error' +-- A theme change reaches a running compositor through +-- hyprctl eval 'nerdos_system_theme(PATH)' +-- rather than a config reload, which would also re-read glass and display +-- state the shell changes at runtime. user.lua loads after this and keeps +-- winning: a key whose live value is no longer the one the theme last applied +-- was set by someone else, and a live change leaves it alone until the next +-- config reload. +local system_theme = rawget(_G, "__nerdos_system_theme") or {} +_G.__nerdos_system_theme = system_theme +local THEME_KEYS = { + "general.col.active_border", "general.col.inactive_border", + "decoration.shadow.color", + "group.col.border_active", "group.col.border_inactive", + "group.col.border_locked_active", "group.col.border_locked_inactive", + "group.groupbar.col.active", "group.groupbar.col.inactive", + "group.groupbar.col.locked_active", "group.groupbar.col.locked_inactive", + "group.groupbar.text_color", "group.groupbar.text_color_inactive", + "group.groupbar.text_color_locked_active", "group.groupbar.text_color_locked_inactive", +} +local THEME_MAX_BYTES = 65536 + +local function system_theme_path() + local state = os.getenv("XDG_STATE_HOME") + if not state or state == "" then + local home = os.getenv("HOME") + if not home then return nil end + state = home .. "/.local/state" + end + return state .. "/nerdos/theme/hyprland.lua" +end + +-- {dotted key = colour} for the theme keys a file sets, nil when there is no +-- file, or an error. Keys this version does not know are ignored, so a newer +-- renderer never makes an older compositor config drop the whole theme. +local function read_system_theme(path) + local file, reason, code = io.open(path, "r") + if not file then + if code == 2 then return nil end + error(reason, 0) + end + local text = file:read(THEME_MAX_BYTES + 1) + file:close() + if not text or #text > THEME_MAX_BYTES then + error(path .. ": empty or too large", 0) + end + local chunk, syntax = load(text, "=" .. path, "t", {}) + if not chunk then error(syntax, 0) end + local data = chunk() + if type(data) ~= "table" then error(path .. ": does not return a table", 0) end + local values = {} + for _, key in ipairs(THEME_KEYS) do + local node = data + for part in key:gmatch("[^.]+") do + if type(node) ~= "table" then node = nil break end + node = node[part] + end + if node ~= nil then + if type(node) ~= "string" or not (node:match("^rgb%(%x%x%x%x%x%x%)$") + or node:match("^rgba%(%x%x%x%x%x%x%x%x%)$")) then + error(path .. ": " .. key .. " is not rgb(rrggbb) or rgba(rrggbbaa)", 0) + end + values[key] = node + end + end + return values +end + +local function apply_system_theme(values) + local config, any = {}, false + for key, value in pairs(values) do + local node, parts = config, {} + for part in key:gmatch("[^.]+") do parts[#parts + 1] = part end + for i = 1, #parts - 1 do + node[parts[i]] = node[parts[i]] or {} + node = node[parts[i]] + end + node[parts[#parts]] = value + any = true + end + if any then hl.config(config) end +end + +-- What each key holds now, comparable across calls: hl.get_config returns a +-- colour as "0xAARRGGBB" and a gradient as { colors = {...}, angle = n }. +local function system_theme_snapshot() + local snapshot = {} + if type(hl.get_config) ~= "function" then return snapshot end + for _, key in ipairs(THEME_KEYS) do + local ok, value = pcall(hl.get_config, key) + if ok and type(value) == "table" then + local colors = {} + for i, color in ipairs(type(value.colors) == "table" and value.colors or {}) do + colors[i] = tostring(color) + end + value = table.concat(colors, " ") .. " " .. tostring(value.angle) + end + snapshot[key] = ok and tostring(value) or nil + end + return snapshot +end + +function nerdos_system_theme(path) + local values = read_system_theme(path) + if not values then error(path .. ": no such file", 0) end + local applied, current, owned = system_theme.applied or {}, system_theme_snapshot(), {} + for key, value in pairs(values) do + if applied[key] == nil or applied[key] == current[key] then owned[key] = value end + end + apply_system_theme(owned) + local after = system_theme_snapshot() + for key in pairs(owned) do applied[key] = after[key] end + system_theme.applied = applied + system_theme.error = nil +end + +system_theme.error = nil +local theme_path = system_theme_path() +if theme_path then + local theme_ok, theme_values = pcall(read_system_theme, theme_path) + if theme_ok and theme_values then + theme_ok, theme_values = pcall(apply_system_theme, theme_values) + end + if not theme_ok then system_theme.error = tostring(theme_values) end +end +-- The baseline a live change compares against, taken before user.lua runs. +system_theme.applied = system_theme_snapshot() + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.0 } } }) +hl.animation({ leaf = "windows", enabled = true, speed = 7, bezier = "myBezier" }) +hl.animation({ leaf = "windowsOut", enabled = true, speed = 7, bezier = "default", style = "popin 80%" }) +hl.animation({ leaf = "border", enabled = true, speed = 10, bezier = "default" }) +hl.animation({ leaf = "borderangle", enabled = true, speed = 8, bezier = "default" }) +hl.animation({ leaf = "fade", enabled = true, speed = 7, bezier = "default" }) +hl.animation({ leaf = "workspaces", enabled = true, speed = 6, bezier = "default" }) + +-- Quickshell surfaces resize and remap as views open. Keep those operations +-- instantaneous so the menubar itself never replays a layer animation. +hl.layer_rule({ + name = "quickshell-no-animation", + match = { namespace = [[^qs-(bar|bar-popout|launcher|notifications|osd|shortcuts|wallpaper|overview|dock|assistant)$]] }, + no_anim = true, +}) + +-- Blur behind the glass. The wallpaper layer is deliberately absent: it is +-- opaque and at the bottom, so blurring it would only cost a pass. So is the +-- window overview: its backdrop is the wallpaper, and a blur behind live +-- window captures would be recomputed on every frame. +-- +-- Blur is applied per pixel of the *surface*, and each of these spans more +-- than the shape it draws: the menubar's layer runs past the slab to leave room +-- for tooltips, a panel's runs past the card. Anything drawn into that margin +-- gets blurred along with the shape, at the full size of the layer — which is +-- why none of these surfaces carries a drop shadow any more. Glass over a real +-- blur already reads as floating; a shadow into the margin read as a haze band. +-- +-- So ignore_alpha only has to skip pixels nothing was drawn on. Every surface +-- that must blur is well clear of it: the menubar's glass is 0.52, a panel's +-- 0.72, the full-screen scrims 0.42. +-- Intentionally global: Settings.qml reaches this stable handle through +-- `hyprctl eval` so switching glass never has to remap a layer surface. +quickshell_blur_rule = hl.layer_rule({ + name = "quickshell-blur", + enabled = persisted_glass_enabled(), + match = { namespace = [[^qs-(bar|bar-popout|launcher|notifications|osd|shortcuts|dock)$]] }, + blur = true, + ignore_alpha = 0.1, +}) + +-- Power saver keeps blur to one pass and disables +-- animations. Quickshell calls nerdos_power_saver() through `hyprctl eval` +-- when it starts, whenever the power profile changes, and after every config +-- reload: Hyprland 0.56 rebuilds the Lua state on a reload, so _G does not +-- survive it and the shell sends the saver again (Settings.compositorReloaded). +-- What the saver replaced is read live, so switching it off later restores +-- what was there before, including an override from user.lua. +local power_saver = rawget(_G, "__nerdos_power_saver") or {} +_G.__nerdos_power_saver = power_saver +local power_saver_config = { decoration = { blur = { passes = 1 } }, animations = { enabled = false } } +-- Game mode (below) also holds animations off. Whichever of the two is +-- switched off first hands the value it replaced to the other, so the last +-- one off restores what was there before either. +local game_mode = { error = nil } +_G.__nerdos_game_mode = game_mode + +function nerdos_power_saver(enabled) + if enabled and not power_saver.restore then + local restore = { + passes = hl.get_config("decoration.blur.passes"), + animations = hl.get_config("animations.enabled"), + } + if game_mode.restore then + restore.animations = game_mode.restore["animations.enabled"] + game_mode.restore["animations.enabled"] = false + end + hl.config(power_saver_config) + power_saver.restore = restore + elseif not enabled and power_saver.restore then + if game_mode.restore then + game_mode.restore["animations.enabled"] = power_saver.restore.animations + hl.config({ decoration = { blur = { passes = power_saver.restore.passes } } }) + else + hl.config({ + decoration = { blur = { passes = power_saver.restore.passes } }, + animations = { enabled = power_saver.restore.animations }, + }) + end + power_saver.restore = nil + end +end + +if power_saver.restore then hl.config(power_saver_config) end + +hl.window_rule({ match = { title = [[^NerdOS Settings$]] }, float = true, center = true }) +-- Keep the roomy welcome proportions, but cap them to the logical monitor +-- work area so scaled or short displays can reach its scrolling content. +-- Qt also bounds its initial request by QScreen.availableGeometry. +hl.window_rule({ match = { class = [[^nerd$]], title = [[^Welcome to NerdOS$]] }, float = true, center = true, size = "min(monitor_w-32,max(760,monitor_w*0.5)) min(monitor_h-64,max(660,monitor_h*0.48))" }) +hl.window_rule({ match = { class = [[xdg-desktop-portal-gtk]] }, float = true }) +hl.window_rule({ match = { class = [[org\.gnome\.Nautilus]], title = [[Properties]] }, float = true }) +hl.window_rule({ match = { class = [[org\.gnome\.Nautilus]], title = [[Open.*]] }, float = true }) +hl.window_rule({ match = { class = [[org\.gnome\.Nautilus]], title = [[Save.*]] }, float = true }) +-- A lone tiled window uses 70% of an external display's logical width. +-- Laptop displays and workspaces with multiple tiled windows keep normal gaps. +local previous = rawget(_G, "__nerdos_single_window_width") +if previous then + previous.subscription:remove() + for _, entry in pairs(previous.rules) do entry.rule:set_enabled(false) end +end +local policy = { rules = {}, suspended = false } +_G.__nerdos_single_window_width = policy + +-- Game mode turns every gap off, these margins included. +function policy.suspend(value) + policy.suspended = value + for _, entry in pairs(policy.rules) do entry.rule:set_enabled(not value) end +end + +local function update_single_window_width() + local connected = {} + for _, monitor in ipairs(hl.get_monitors()) do + if not monitor.name:match("^eDP%-") and not monitor.name:match("^LVDS%-") + and not monitor.is_mirror then + connected[monitor.name] = true + local rotated = monitor.transform % 2 == 1 + local width = (rotated and monitor.height or monitor.width) / monitor.scale + local margin = math.floor(width * 0.15 + 0.5) + local entry = policy.rules[monitor.name] + if not entry or entry.margin ~= margin then + if entry then entry.rule:set_enabled(false) end + policy.rules[monitor.name] = { + margin = margin, + rule = hl.workspace_rule({ + workspace = "m[" .. monitor.name .. "] w[t1]", + gaps_out = { top = 10, right = margin, bottom = 10, left = margin }, + }), + } + if policy.suspended then policy.rules[monitor.name].rule:set_enabled(false) end + end + end + end + for name, entry in pairs(policy.rules) do + if not connected[name] then + entry.rule:set_enabled(false) + policy.rules[name] = nil + end + end +end + +policy.subscription = hl.on("monitor.layout_changed", update_single_window_width) +update_single_window_width() + +-- Game mode (the game-mode helper; docs/operations.md) turns animations, +-- blur, shadows, window rounding and gaps off for games, and the helper holds +-- idle locking off. `game-mode on|off` calls nerdos_game_mode() through +-- `hyprctl eval`; what it replaced is kept here and put back exactly. +-- +-- A config reload starts a fresh Lua state with every value back at its +-- configured default, so the helper's state file is what carries game mode +-- across one: when $XDG_RUNTIME_DIR/nerdos/game-mode.json is on for this +-- compositor instance, game mode is applied again once the whole +-- configuration, user.lua included, has loaded, and turning it off then +-- restores the reloaded values. A new login is a new instance, so a file left +-- behind never applies there. The file is data the helper writes: two fields +-- are matched in it and nothing in it is evaluated. Diagnose with +-- hyprctl repl 'return __nerdos_game_mode.error' +local GAME_MODE_KEYS = { + "animations.enabled", "decoration.blur.enabled", "decoration.shadow.enabled", + "decoration.rounding", "general.gaps_in", "general.gaps_out", +} +local GAME_MODE_VALUES = { + ["animations.enabled"] = false, ["decoration.blur.enabled"] = false, + ["decoration.shadow.enabled"] = false, ["decoration.rounding"] = 0, + ["general.gaps_in"] = 0, ["general.gaps_out"] = 0, +} +local GAME_MODE_MAX_BYTES = 4096 + +local function nested_config(values) + local config = {} + for _, key in ipairs(GAME_MODE_KEYS) do + if values[key] ~= nil then + local node, parts = config, {} + for part in key:gmatch("[^.]+") do parts[#parts + 1] = part end + for i = 1, #parts - 1 do + node[parts[i]] = node[parts[i]] or {} + node = node[parts[i]] + end + node[parts[#parts]] = values[key] + end + end + return config +end + +function nerdos_game_mode(enabled) + if enabled and not game_mode.restore then + local restore = {} + for _, key in ipairs(GAME_MODE_KEYS) do restore[key] = hl.get_config(key) end + hl.config(nested_config(GAME_MODE_VALUES)) + game_mode.restore = restore + policy.suspend(true) + elseif not enabled and game_mode.restore then + hl.config(nested_config(game_mode.restore)) + game_mode.restore = nil + policy.suspend(false) + end + game_mode.error = nil + return game_mode.restore ~= nil +end + +local function game_mode_requested() + local runtime = os.getenv("XDG_RUNTIME_DIR") + local instance = os.getenv("HYPRLAND_INSTANCE_SIGNATURE") + if not runtime or runtime == "" or not instance or instance == "" then return false end + local file = io.open(runtime .. "/nerdos/game-mode.json", "r") + if not file then return false end + local text = file:read(GAME_MODE_MAX_BYTES + 1) + file:close() + if not text or #text > GAME_MODE_MAX_BYTES then return false end + return text:match('[,{]%s*"active"%s*:%s*(%a+)') == "true" + and text:match('[,{]%s*"instance"%s*:%s*"([^"\\]*)"') == instance +end + +if game_mode_requested() then + hl.on("config.reloaded", function() + local ok, failure = pcall(nerdos_game_mode, true) + if not ok then game_mode.error = tostring(failure) end + end) +end + +hl.window_rule({ match = { class = [[.*]] }, suppress_event = "maximize" }) +hl.window_rule({ match = { class = [[.*]] }, opacity = "1.0 1.0" }) +hl.window_rule({ match = { class = [[1[pP]assword]] }, float = true, center = true, size = { 875, 600 }, no_screen_share = true }) +hl.window_rule({ match = { class = [[org\.gnome\.NautilusPreviewer]] }, float = true, center = true, size = "monitor_w*0.6 monitor_h*0.7" }) +hl.window_rule({ match = { class = [[localsend_app]] }, float = true, center = true, size = { 875, 600 } }) +hl.window_rule({ match = { class = [[org\.gnome\.Calculator]] }, float = true }) +hl.window_rule({ match = { class = [[(imv|mpv)]] }, float = true, center = true }) +hl.window_rule({ match = { class = [[brave-(browser|origin)]], title = [[^Notification.*]] }, float = true, no_initial_focus = true, pin = true }) +hl.window_rule({ match = { class = [[(vlc|mpv|imv|zoom)]] }, opacity = "1 1" }) diff --git a/desktop/hypr/monitors.lua b/desktop/hypr/monitors.lua new file mode 100644 index 00000000..37992d6f --- /dev/null +++ b/desktop/hypr/monitors.lua @@ -0,0 +1,15 @@ +-- Vendor monitor rules. They are recorded as well as applied so that +-- displays.lua (Settings -> Displays) can start a saved choice from the rule +-- it overrides and keep fields the page does not own, such as bitdepth. +local vendor_monitor_rules = {} +_G.__nerdos_vendor_monitor_rules = vendor_monitor_rules +local function monitor(rule) + table.insert(vendor_monitor_rules, rule) + hl.monitor(rule) +end + + +-- Portable default: use each connected output's preferred mode and let +-- Hyprland choose its position and scale. +monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) + diff --git a/roles/desktop/files/titlebars-native.patch b/desktop/hypr/titlebars-native.patch similarity index 81% rename from roles/desktop/files/titlebars-native.patch rename to desktop/hypr/titlebars-native.patch index 6cfa1ebd..259c6c55 100644 --- a/roles/desktop/files/titlebars-native.patch +++ b/desktop/hypr/titlebars-native.patch @@ -4,7 +4,7 @@ #include -+#include "cybexosBuildId.hpp" ++#include "nerdosBuildId.hpp" + #include "barDeco.hpp" #include "globals.hpp" @@ -27,10 +27,10 @@ +} + static PLUGIN_DESCRIPTION_INFO initializeOmarchyFloating(HANDLE handle) { -+ // A Fedora release rebuild may keep the source/API hash while changing ++ // A Nixpkgs rebuild may keep the source/API hash while changing + // the executable ABI. Never enter plugin state against another ELF build. -+ if (cybexosHyprlandBuildId() != CYBEXOS_HYPRLAND_BUILD_ID) -+ throw std::runtime_error("[hyprbars] Fedora Hyprland build ID mismatch; rebuild native titlebars"); ++ if (nerdosHyprlandBuildId() != NERDOS_HYPRLAND_BUILD_ID) ++ throw std::runtime_error("[hyprbars] Nixpkgs Hyprland build ID mismatch; rebuild native titlebars"); + OMARCHY_FLOATING_PHANDLE = handle; @@ -54,25 +54,25 @@ # Hyprland exposes inline globals through its headers, so whole-library hidden # visibility breaks the host API. Our own types/globals have unique OmarchyFloating names. target_compile_options(titlebars PRIVATE -fno-gnu-unique) -+if(NOT CYBEXOS_HYPRLAND_BUILD_ID MATCHES "^[0-9a-f]+$") -+ message(FATAL_ERROR "CYBEXOS_HYPRLAND_BUILD_ID must identify the installed Fedora Hyprland executable") ++if(NOT NERDOS_HYPRLAND_BUILD_ID MATCHES "^[0-9a-f]+$") ++ message(FATAL_ERROR "NERDOS_HYPRLAND_BUILD_ID must identify the installed Nixpkgs Hyprland executable") +endif() -+target_compile_definitions(titlebars PRIVATE CYBEXOS_HYPRLAND_BUILD_ID="${CYBEXOS_HYPRLAND_BUILD_ID}") ++target_compile_definitions(titlebars PRIVATE NERDOS_HYPRLAND_BUILD_ID="${NERDOS_HYPRLAND_BUILD_ID}") find_package(PkgConfig REQUIRED) pkg_check_modules(deps REQUIRED IMPORTED_TARGET --- /dev/null -+++ b/cybexosBuildId.hpp ++++ b/nerdosBuildId.hpp @@ -0,0 +1,44 @@ -+// CybexOS native Fedora build guard. SPDX-License-Identifier: MIT ++// NerdOS native compositor build guard. SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include + -+// Read the main executable's mapped ELF notes, not /usr/bin/Hyprland: an RPM -+// update can replace that path while an older compositor is still running. -+static std::string cybexosHyprlandBuildId() { ++// Read the running executable's mapped ELF notes: a package upgrade can select ++// a new executable while the older compositor is still running. ++static std::string nerdosHyprlandBuildId() { + std::string result; + dl_iterate_phdr([](dl_phdr_info *info, size_t, void *data) -> int { + if (info->dlpi_name && info->dlpi_name[0]) diff --git a/roles/desktop/files/workspace_layouts.lua b/desktop/hypr/workspace_layouts.lua similarity index 98% rename from roles/desktop/files/workspace_layouts.lua rename to desktop/hypr/workspace_layouts.lua index c395b3a3..e85d06e0 100644 --- a/roles/desktop/files/workspace_layouts.lua +++ b/desktop/hypr/workspace_layouts.lua @@ -15,8 +15,8 @@ -- ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -- DEALINGS IN THE SOFTWARE. local model = require("workspace_model") -local M = { FLOATED = "cybexos-mode-floated", OPENED = "cybexos-mode-opened", - FLOATING = "cybexos-floating-workspace", USER = "cybexos-user-float" } +local M = { FLOATED = "nerdos-mode-floated", OPENED = "nerdos-mode-opened", + FLOATING = "nerdos-floating-workspace", USER = "nerdos-user-float" } function M.tagged(window, tag) for _, value in ipairs(window.tags or {}) do if value == tag or value == tag .. "*" then return true end diff --git a/roles/desktop/files/workspace_model.lua b/desktop/hypr/workspace_model.lua similarity index 100% rename from roles/desktop/files/workspace_model.lua rename to desktop/hypr/workspace_model.lua diff --git a/roles/desktop/files/workspace_state.lua b/desktop/hypr/workspace_state.lua similarity index 97% rename from roles/desktop/files/workspace_state.lua rename to desktop/hypr/workspace_state.lua index d154a68c..69a17c1f 100644 --- a/roles/desktop/files/workspace_state.lua +++ b/desktop/hypr/workspace_state.lua @@ -61,8 +61,8 @@ local function shell_quote(value) return "'" .. value:gsub("'", "'\\''") .. "'" -- entirely in-process. A random per-directory temporary prefix prevents the -- predictable .tmp symlink problem. The 0700 owner-checked directory is the -- privacy boundary; no claim is made about a particular inherited file umask. -local directories = rawget(_G, "__cybexos_workspace_directories") or {} -_G.__cybexos_workspace_directories = directories +local directories = rawget(_G, "__nerdos_workspace_directories") or {} +_G.__nerdos_workspace_directories = directories local PREPARE = [[ import os, secrets, stat, sys path = sys.argv[1] diff --git a/roles/desktop/files/workspaces.lua b/desktop/hypr/workspaces.lua similarity index 95% rename from roles/desktop/files/workspaces.lua rename to desktop/hypr/workspaces.lua index c55d4d1e..f23229f4 100644 --- a/roles/desktop/files/workspaces.lua +++ b/desktop/hypr/workspaces.lua @@ -1,5 +1,5 @@ -- One compositor owner for workspace numbering, layout and minimized windows. --- Public/data contract and qualification: docs/desktop-workspace-architecture.md. +-- Personal configuration and runtime data contract: docs/customization.md. local data = require("workspace_state") local model = require("workspace_model") local floating = require("workspace_layouts") @@ -9,12 +9,27 @@ local config = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config") local state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state") local runtime = os.getenv("XDG_RUNTIME_DIR") local session = os.getenv("HYPRLAND_INSTANCE_SIGNATURE") or "" -M.path = config .. "/cybexos/workspaces.json" -M.assignments_path = state_home .. "/cybexos/workspace-assignments.json" -M.snapshot_path = runtime and runtime .. "/cybexos/workspaces.json" or nil -M.session_path = runtime and runtime .. "/cybexos/workspace-session.json" or nil -local MINIMIZED = "special:cybexos-minimized" -local MIN_TAG = "cybexos-minimized-" +-- Hyprland's documented --verify-config parses declarations without creating +-- the event loop, but still emits config.reloaded. Calling hl.timer from that +-- event crashes 0.56.2. Only timer scheduling is inapplicable in this mode; +-- module loading, configuration declarations and initial refresh still run. +local verify_config = false +do + local process = io.open("/proc/self/cmdline", "rb") + if process then + local arguments = process:read(65536) or "" + process:close() + for argument in arguments:gmatch("[^%z]+") do + if argument == "--verify-config" then verify_config = true; break end + end + end +end +M.path = config .. "/nerdos/workspaces.json" +M.assignments_path = state_home .. "/nerdos/workspace-assignments.json" +M.snapshot_path = runtime and runtime .. "/nerdos/workspaces.json" or nil +M.session_path = runtime and runtime .. "/nerdos/workspace-session.json" or nil +local MINIMIZED = "special:nerdos-minimized" +local MIN_TAG = "nerdos-minimized-" local valid_layout = { dwindle = true, scrolling = true, floating = true } local function id(value) return data.integer(value, 1, model.MAX_ID) end local function address(value) @@ -42,21 +57,21 @@ preferences = preferences or { v = 1, mode = "shared", defaultLayout = "dwindle" titlebarsEnabled = true, layouts = {} } local assignments, assignment_error = data.read(M.assignments_path, model.validate) assignments = assignments or { v = 1, assignments = data.array() } -local previous = rawget(_G, "__cybexos_workspaces") +local previous = rawget(_G, "__nerdos_workspaces") if previous and previous.retire then previous.retire() end local state = { subscriptions = {}, rules = {}, current = {}, identities = {}, minimized = {}, busy = false, pending = nil, retired = false, error = preference_error or assignment_error or "", capabilities = { scrolling = false, titlebars = false } } -_G.__cybexos_workspaces = state +_G.__nerdos_workspaces = state -- Plugin loads are declarations: Hyprland clears its configured plugin list -- on each config parse, then reloads if that list changed. Always declare an -- installed optional plugin, even when currently disabled or already loaded. -- Runtime refreshes only toggle rendering; they never load/unload a plugin. do - local file = io.open("/usr/lib64/cybexos/titlebars.so", "rb") + local file = io.open("/etc/nerdos/titlebars.so", "rb") if file then file:close() - local ok, err = pcall(hl.plugin.load, "/usr/lib64/cybexos/titlebars.so") + local ok, err = pcall(hl.plugin.load, "/etc/nerdos/titlebars.so") if not ok then state.error = "titlebar plugin unavailable: " .. tostring(err) end end end @@ -181,7 +196,7 @@ local function save_session() end local function minimized_origin(window) for _, tag in ipairs(window.tags or {}) do - local saved = tag:match("^cybexos%-minimized%-(%d+)$") + local saved = tag:match("^nerdos%-minimized%-(%d+)$") if saved and id(tonumber(saved)) then return tonumber(saved) end end end @@ -227,7 +242,7 @@ local function titlebars() -- This dynamic effect exists only after the plugin registers it. Exact -- known GTK application classes are a policy, not generic CSD detection. if plugin and not state.titlebar_csd_rule then - state.titlebar_csd_rule = hl.window_rule({ name = "cybexos-titlebars-known-csd", + state.titlebar_csd_rule = hl.window_rule({ name = "nerdos-titlebars-known-csd", match = { class = "^(org[.]gnome[.]Nautilus|org[.]gnome[.]TextEditor)$" }, ["hyprbars:no_bar"] = true }) elseif not plugin and state.titlebar_csd_rule then state.titlebar_csd_rule:set_enabled(false) @@ -243,7 +258,7 @@ local function titlebars() -- current layouts. Configure once even when hidden to verify usable APIs. local rendering = preferences.titlebarsEnabled and wanted if not state.titlebar_theme then - local theme_dir = os.getenv("CYBEXOS_THEME_STATE_DIR") or state_home .. "/cybexos/theme" + local theme_dir = os.getenv("NERDOS_THEME_STATE_DIR") or state_home .. "/nerdos/theme" state.titlebar_theme = data.read(theme_dir .. "/tokens.json", function(document) assert(data.object(document) and document.v == 1 and data.object(document.colors) and data.object(document.font)) for _, key in ipairs({ "surfaceRaised", "text" }) do @@ -255,7 +270,7 @@ local function titlebars() size = data.integer(document.font.size, 6, 96) and document.font.size or 14 } end) or {} end - local colors = rawget(_G, "__cybexos_titlebar_colors") or state.titlebar_theme + local colors = rawget(_G, "__nerdos_titlebar_colors") or state.titlebar_theme local background, foreground = colors.background or "rgb(292b31)", colors.foreground or "rgb(e4e4e7)" local signature = data.encode(colors) local theme_changed = signature ~= state.titlebars_signature @@ -348,13 +363,13 @@ local function write_snapshot() end end local function sync_policy() - _G.__cybexos_workspace_mode = preferences.mode - local shared = rawget(_G, "__fedora_workspace_policy") + _G.__nerdos_workspace_mode = preferences.mode + local shared = rawget(_G, "__nerdos_workspace_policy") if shared and shared.apply then shared.apply() end -- Replace only the registered vendor swipe, preserving Touchpad opt-outs -- and any user.lua gesture that already retired it. Native workspace swipes -- cannot traverse our explicit local ID list (which need not be contiguous). - local gestures = rawget(_G, "__cybexos_gestures") + local gestures = rawget(_G, "__nerdos_gestures") if gestures and gestures.remove and state.gesture_mode ~= preferences.mode and type(hl.gesture) == "function" then state.gesture_mode = preferences.mode if gestures.remove("workspace") then @@ -476,7 +491,7 @@ function M.refresh_theme() refresh() end local function schedule() - if state.busy or state.retired or state.pending then return end + if verify_config or state.busy or state.retired or state.pending then return end state.pending = hl.timer(function() state.pending = nil if not state.retired then refresh() end diff --git a/desktop/icons/hicolor/128x128/apps/nerdos.png b/desktop/icons/hicolor/128x128/apps/nerdos.png new file mode 100644 index 00000000..91ab5beb Binary files /dev/null and b/desktop/icons/hicolor/128x128/apps/nerdos.png differ diff --git a/desktop/icons/hicolor/16x16/apps/nerdos.png b/desktop/icons/hicolor/16x16/apps/nerdos.png new file mode 100644 index 00000000..bd72eefa Binary files /dev/null and b/desktop/icons/hicolor/16x16/apps/nerdos.png differ diff --git a/desktop/icons/hicolor/256x256/apps/nerdos.png b/desktop/icons/hicolor/256x256/apps/nerdos.png new file mode 100644 index 00000000..53549423 Binary files /dev/null and b/desktop/icons/hicolor/256x256/apps/nerdos.png differ diff --git a/desktop/icons/hicolor/32x32/apps/nerdos.png b/desktop/icons/hicolor/32x32/apps/nerdos.png new file mode 100644 index 00000000..b7479f7e Binary files /dev/null and b/desktop/icons/hicolor/32x32/apps/nerdos.png differ diff --git a/desktop/icons/hicolor/48x48/apps/nerdos.png b/desktop/icons/hicolor/48x48/apps/nerdos.png new file mode 100644 index 00000000..bee13b39 Binary files /dev/null and b/desktop/icons/hicolor/48x48/apps/nerdos.png differ diff --git a/desktop/icons/hicolor/512x512/apps/nerdos.png b/desktop/icons/hicolor/512x512/apps/nerdos.png new file mode 100644 index 00000000..aadabd5d Binary files /dev/null and b/desktop/icons/hicolor/512x512/apps/nerdos.png differ diff --git a/desktop/icons/hicolor/64x64/apps/nerdos.png b/desktop/icons/hicolor/64x64/apps/nerdos.png new file mode 100644 index 00000000..809ff747 Binary files /dev/null and b/desktop/icons/hicolor/64x64/apps/nerdos.png differ diff --git a/desktop/icons/hicolor/scalable/apps/nerdos.svg b/desktop/icons/hicolor/scalable/apps/nerdos.svg new file mode 100644 index 00000000..5001809a --- /dev/null +++ b/desktop/icons/hicolor/scalable/apps/nerdos.svg @@ -0,0 +1,6 @@ + + NerdOS app icon + + + + diff --git a/desktop/nerdos-software.desktop b/desktop/nerdos-software.desktop new file mode 100644 index 00000000..f8aac8ce --- /dev/null +++ b/desktop/nerdos-software.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Software +Comment=Install and remove native packages through reviewed NerdOS deployments +Exec=nerdos-software +Icon=nerdos +Type=Application +Categories=System;PackageManager; +Keywords=install;remove;packages;applications; diff --git a/desktop/nerdos.desktop b/desktop/nerdos.desktop new file mode 100644 index 00000000..1e4d6571 --- /dev/null +++ b/desktop/nerdos.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=NerdOS +Comment=Personalize your Opinionated System +Exec=nerdos-runtime ipc settings open appearance +Icon=nerdos +Terminal=false +Type=Application +Categories=Settings;System; +Keywords=NerdOS;desktop;settings; diff --git a/desktop/notification-images/CMakeLists.txt b/desktop/notification-images/CMakeLists.txt new file mode 100644 index 00000000..5035f054 --- /dev/null +++ b/desktop/notification-images/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.16) +project(NerdOSNotificationImages VERSION 1.0 LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Qml Quick) +include(CTest) +set(NOTIFICATION_IMAGES_QML_DIR "lib64/qt6/qml/NerdOS/Images" CACHE STRING "Installed QML module directory") +add_library(notification-image-fingerprint STATIC imagefingerprints.cpp imagefingerprints.h) +set_target_properties(notification-image-fingerprint PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_link_libraries(notification-image-fingerprint PUBLIC Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick) +add_library(nerdosnotificationimagesplugin MODULE plugin.cpp) +target_link_libraries(nerdosnotificationimagesplugin PRIVATE notification-image-fingerprint) +set_target_properties(nerdosnotificationimagesplugin PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml/NerdOS/Images") +configure_file(qmldir qml/NerdOS/Images/qmldir COPYONLY) +configure_file(plugins.qmltypes qml/NerdOS/Images/plugins.qmltypes COPYONLY) +install(TARGETS nerdosnotificationimagesplugin LIBRARY DESTINATION "${NOTIFICATION_IMAGES_QML_DIR}") +install(FILES qmldir plugins.qmltypes DESTINATION "${NOTIFICATION_IMAGES_QML_DIR}") +install(FILES LICENSE DESTINATION share/licenses/nerdos-notification-images) +if(BUILD_TESTING) + find_package(Qt6 REQUIRED COMPONENTS Test) + add_executable(notification-image-fingerprint-test tests.cpp) + target_link_libraries(notification-image-fingerprint-test PRIVATE notification-image-fingerprint Qt6::Test) + target_compile_definitions(notification-image-fingerprint-test PRIVATE TEST_QML_PATH="${CMAKE_BINARY_DIR}/qml") + add_dependencies(notification-image-fingerprint-test nerdosnotificationimagesplugin) + add_test(NAME notification-image-fingerprint COMMAND notification-image-fingerprint-test) + set_tests_properties(notification-image-fingerprint PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endif() diff --git a/roles/desktop/files/notification-images/LICENSE b/desktop/notification-images/LICENSE similarity index 94% rename from roles/desktop/files/notification-images/LICENSE rename to desktop/notification-images/LICENSE index 389ed546..0506beb2 100644 --- a/roles/desktop/files/notification-images/LICENSE +++ b/desktop/notification-images/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 DigitalPals and CybexOS contributors +Copyright (c) 2026 DigitalPals and NerdOS contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/roles/desktop/files/notification-images/build.py b/desktop/notification-images/build.py similarity index 90% rename from roles/desktop/files/notification-images/build.py rename to desktop/notification-images/build.py index 2ca8698c..f59e7b94 100644 --- a/roles/desktop/files/notification-images/build.py +++ b/desktop/notification-images/build.py @@ -12,11 +12,11 @@ SOURCES = ('CMakeLists.txt', 'LICENSE', 'build.py', 'imagefingerprints.cpp', 'imagefingerprints.h', 'plugin.cpp', 'plugins.qmltypes', 'qmldir', 'tests.cpp') -MODULE = 'usr/lib64/qt6/qml/CybexOS/Images' +MODULE = 'usr/lib64/qt6/qml/NerdOS/Images' ARTIFACTS = tuple(f'{MODULE}/{name}' for name in - ('libcybexosnotificationimagesplugin.so', 'qmldir', 'plugins.qmltypes')) + ( - 'usr/share/licenses/cybexos-notification-images/LICENSE',) -MANIFEST = 'usr/share/cybexos/notification-images/build.json' + ('libnerdosnotificationimagesplugin.so', 'qmldir', 'plugins.qmltypes')) + ( + 'usr/share/licenses/nerdos-notification-images/LICENSE',) +MANIFEST = 'usr/share/nerdos/notification-images/build.json' def sha256(path): @@ -39,7 +39,7 @@ def install(source, root): return previous except (OSError, ValueError, KeyError, TypeError): pass - with tempfile.TemporaryDirectory(prefix='cybexos-notification-images-') as temporary: + with tempfile.TemporaryDirectory(prefix='nerdos-notification-images-') as temporary: build = Path(temporary) / 'build' staged = Path(temporary) / 'root' subprocess.run(['cmake', '-S', str(source), '-B', str(build), diff --git a/roles/desktop/files/notification-images/imagefingerprints.cpp b/desktop/notification-images/imagefingerprints.cpp similarity index 97% rename from roles/desktop/files/notification-images/imagefingerprints.cpp rename to desktop/notification-images/imagefingerprints.cpp index 8d5fb536..9905dc63 100644 --- a/roles/desktop/files/notification-images/imagefingerprints.cpp +++ b/desktop/notification-images/imagefingerprints.cpp @@ -6,7 +6,7 @@ #include #include -namespace CybexOS { +namespace NerdOS { namespace { bool boundedDimensions(int width, int height) { return width > 0 && height > 0 && width <= ImageDimensionLimit @@ -27,7 +27,7 @@ QString imageFingerprint(const QImage &image, QString *error) { if (rgba.isNull()) return fail(QStringLiteral("canonicalization-failed")); if (rgba.sizeInBytes() > ImageByteLimit) return fail(QStringLiteral("image-bounds")); QCryptographicHash hash(QCryptographicHash::Sha256); - hash.addData(QByteArrayView("CybexOS.NotificationImage.RGBA.v1")); + hash.addData(QByteArrayView("NerdOS.NotificationImage.RGBA.v1")); const quint32 dimensions[] = {qToBigEndian(quint32(rgba.width())), qToBigEndian(quint32(rgba.height()))}; hash.addData(QByteArrayView(reinterpret_cast(dimensions), sizeof(dimensions))); for (int y = 0; y < rgba.height(); ++y) diff --git a/roles/desktop/files/notification-images/imagefingerprints.h b/desktop/notification-images/imagefingerprints.h similarity index 97% rename from roles/desktop/files/notification-images/imagefingerprints.h rename to desktop/notification-images/imagefingerprints.h index e9b97d3f..0f43ba3b 100644 --- a/roles/desktop/files/notification-images/imagefingerprints.h +++ b/desktop/notification-images/imagefingerprints.h @@ -5,7 +5,7 @@ #include #include -namespace CybexOS { +namespace NerdOS { constexpr qint64 ImageByteLimit = 16 * 1024 * 1024; constexpr int ImageDimensionLimit = 4096; // Hash dimensions and canonical RGBA pixels. Never retains the input image. diff --git a/desktop/notification-images/plugin.cpp b/desktop/notification-images/plugin.cpp new file mode 100644 index 00000000..2dc2a9cf --- /dev/null +++ b/desktop/notification-images/plugin.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +#include "imagefingerprints.h" +#include +#include + +class NotificationImagesPlugin final : public QQmlExtensionPlugin { + Q_OBJECT + Q_PLUGIN_METADATA(IID QQmlExtensionInterface_iid) +public: + void registerTypes(const char *uri) override { + qmlRegisterSingletonType(uri, 1, 0, "ImageFingerprints", + [](QQmlEngine *engine, QJSEngine *) -> QObject * { + return new NerdOS::ImageFingerprints(engine); + }); + } +}; +#include "plugin.moc" diff --git a/roles/desktop/files/notification-images/plugins.qmltypes b/desktop/notification-images/plugins.qmltypes similarity index 81% rename from roles/desktop/files/notification-images/plugins.qmltypes rename to desktop/notification-images/plugins.qmltypes index a8138371..9b2b358f 100644 --- a/roles/desktop/files/notification-images/plugins.qmltypes +++ b/desktop/notification-images/plugins.qmltypes @@ -1,9 +1,9 @@ import QtQuick.tooling 1.2 Module { Component { - name: "CybexOS::ImageFingerprints" + name: "NerdOS::ImageFingerprints" prototype: "QObject" - exports: ["CybexOS.Images/ImageFingerprints 1.0"] + exports: ["NerdOS.Images/ImageFingerprints 1.0"] isSingleton: true exportMetaObjectRevisions: [256] Property { name: "error"; type: "QString"; isReadonly: true; notify: "errorChanged" } diff --git a/desktop/notification-images/qmldir b/desktop/notification-images/qmldir new file mode 100644 index 00000000..10592ef7 --- /dev/null +++ b/desktop/notification-images/qmldir @@ -0,0 +1,4 @@ +module NerdOS.Images +plugin nerdosnotificationimagesplugin +classname NotificationImagesPlugin +typeinfo plugins.qmltypes diff --git a/roles/desktop/files/notification-images/tests.cpp b/desktop/notification-images/tests.cpp similarity index 82% rename from roles/desktop/files/notification-images/tests.cpp rename to desktop/notification-images/tests.cpp index bdc3d433..4eae5276 100644 --- a/roles/desktop/files/notification-images/tests.cpp +++ b/desktop/notification-images/tests.cpp @@ -41,7 +41,7 @@ private slots: provider->images.insert("7/8", colored()); provider->images.insert("7/9", colored(Qt::red)); engine.addImageProvider("qsimage", provider); - CybexOS::ImageFingerprints fingerprints(&engine); + NerdOS::ImageFingerprints fingerprints(&engine); const QString first = fingerprints.fingerprint("image://qsimage/1/2"); QVERIFY(QRegularExpression("^sha256:[0-9a-f]{64}$").match(first).hasMatch()); QCOMPARE(first, fingerprints.fingerprint("image://qsimage/7/8")); @@ -50,14 +50,14 @@ private slots: QVERIFY(fingerprints.error().isEmpty()); } void dimensionsArePartOfIdentity() { - QVERIFY(CybexOS::imageFingerprint(colored(Qt::red, 1, 2)) - != CybexOS::imageFingerprint(colored(Qt::red, 2, 1))); + QVERIFY(NerdOS::imageFingerprint(colored(Qt::red, 1, 2)) + != NerdOS::imageFingerprint(colored(Qt::red, 2, 1))); } void canonicalFormatsMatch() { const QImage image = colored(); for (auto format : {QImage::Format_ARGB32, QImage::Format_RGB32, QImage::Format_ARGB32_Premultiplied, QImage::Format_RGB888}) - QCOMPARE(CybexOS::imageFingerprint(image), CybexOS::imageFingerprint(image.convertToFormat(format))); + QCOMPARE(NerdOS::imageFingerprint(image), NerdOS::imageFingerprint(image.convertToFormat(format))); } void paddingIsExcluded() { QByteArray first(32, char(0x11)), second(40, char(0x77)); @@ -69,7 +69,7 @@ private slots: a.setPixelColor(x, y, QColor(1, 2, 3, 127)); b.setPixelColor(x, y, QColor(1, 2, 3, 127)); } - QCOMPARE(CybexOS::imageFingerprint(a), CybexOS::imageFingerprint(b)); + QCOMPARE(NerdOS::imageFingerprint(a), NerdOS::imageFingerprint(b)); QCOMPARE(first.at(8), char(0x11)); QCOMPARE(second.at(8), char(0x77)); } @@ -77,7 +77,7 @@ private slots: QQmlEngine engine; auto *provider = new Provider; engine.addImageProvider("qsimage", provider); - CybexOS::ImageFingerprints fingerprints(&engine); + NerdOS::ImageFingerprints fingerprints(&engine); const QStringList sources = {"", "/tmp/image", "file:///tmp/image", "https://example.org/x", "image://other/1/2", "image://qsimage/1", "image://qsimage/1/2?x", "image://qsimage/1/2#x", "image://qsimage/../2", "image://qsimage/1/%32", "image://QSIMAGE/1/2", @@ -90,7 +90,7 @@ private slots: } void unavailableAndNull() { QQmlEngine engine; - CybexOS::ImageFingerprints fingerprints(&engine); + NerdOS::ImageFingerprints fingerprints(&engine); QVERIFY(fingerprints.fingerprint("image://qsimage/1/2").isEmpty()); QCOMPARE(fingerprints.error(), "provider-unavailable"); engine.addImageProvider("qsimage", new Provider); @@ -103,15 +103,15 @@ private slots: } void dimensionAndStorageBounds() { QString error; - QVERIFY(CybexOS::imageFingerprint(colored(Qt::red, 4097, 1), &error).isEmpty()); + QVERIFY(NerdOS::imageFingerprint(colored(Qt::red, 4097, 1), &error).isEmpty()); QCOMPARE(error, "image-bounds"); - QVERIFY(CybexOS::imageFingerprint(colored(Qt::red, 2049, 2049), &error).isEmpty()); + QVERIFY(NerdOS::imageFingerprint(colored(Qt::red, 2049, 2049), &error).isEmpty()); QCOMPARE(error, "image-bounds"); - const int stride = CybexOS::ImageByteLimit / 2 + 4; + const int stride = NerdOS::ImageByteLimit / 2 + 4; QByteArray bytes(stride * 2, char(0)); QImage padded(reinterpret_cast(bytes.data()), 1, 2, stride, QImage::Format_RGBA8888); QVERIFY(!padded.isNull()); - QVERIFY(CybexOS::imageFingerprint(padded, &error).isEmpty()); + QVERIFY(NerdOS::imageFingerprint(padded, &error).isEmpty()); QCOMPARE(error, "image-bounds"); } void pixmapProvider() { @@ -119,8 +119,8 @@ private slots: auto *provider = new Provider(QQuickImageProvider::Pixmap); provider->images.insert("1/2", colored()); engine.addImageProvider("qsimage", provider); - CybexOS::ImageFingerprints fingerprints(&engine); - QCOMPARE(fingerprints.fingerprint("image://qsimage/1/2"), CybexOS::imageFingerprint(colored())); + NerdOS::ImageFingerprints fingerprints(&engine); + QCOMPARE(fingerprints.fingerprint("image://qsimage/1/2"), NerdOS::imageFingerprint(colored())); } void realQmlSingletonUsesItsEngineProvider() { QQmlEngine engine; @@ -129,10 +129,10 @@ private slots: provider->images.insert("1/2", colored()); engine.addImageProvider("qsimage", provider); QQmlComponent component(&engine); - component.setData("import QtQml\nimport CybexOS.Images 1.0\nQtObject { property string identity: ImageFingerprints.fingerprint(\"image://qsimage/1/2\") }", QUrl()); + component.setData("import QtQml\nimport NerdOS.Images 1.0\nQtObject { property string identity: ImageFingerprints.fingerprint(\"image://qsimage/1/2\") }", QUrl()); std::unique_ptr object(component.create()); QVERIFY2(object, qPrintable(component.errorString())); - QCOMPARE(object->property("identity").toString(), CybexOS::imageFingerprint(colored())); + QCOMPARE(object->property("identity").toString(), NerdOS::imageFingerprint(colored())); QCOMPARE(provider->calls, 1); } }; diff --git a/desktop/password/nerdos-password.py b/desktop/password/nerdos-password.py new file mode 100644 index 00000000..3c336518 --- /dev/null +++ b/desktop/password/nerdos-password.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Native password dialog; credentials reach the authorized worker over stdin.""" +import json +import os +import sys +from PySide6.QtCore import QProcess +from PySide6.QtWidgets import QApplication, QCheckBox, QDialog, QLabel, QLineEdit, QPushButton, QVBoxLayout + +app = QApplication(sys.argv) +app.setApplicationName('NerdOS Password') +class PasswordDialog(QDialog): + def closeEvent(self, event): + if process.state() != QProcess.NotRunning: + event.ignore() + return + super().closeEvent(event) + + def reject(self): + if process.state() == QProcess.NotRunning: + super().reject() + + +dialog = PasswordDialog() +dialog.setWindowTitle('Change your NerdOS password') +dialog.setMinimumWidth(440) +dialog.setStyleSheet('QDialog{background:#1a1b1e;color:#f3f2ec} QLabel,QCheckBox{color:#f3f2ec} QLineEdit{background:#242528;color:#f3f2ec;border:1px solid #525358;border-radius:7px;padding:12px} QPushButton{background:#c8ff1f;color:#202316;border:0;border-radius:7px;padding:12px;font-weight:bold}') +layout = QVBoxLayout(dialog) +layout.setContentsMargins(28, 28, 28, 28) +layout.setSpacing(16) +title = QLabel('One password. Your desktop and disk.') +title.setWordWrap(True) +layout.addWidget(title) +current = QLineEdit() +current.setPlaceholderText('Current password') +current.setEchoMode(QLineEdit.Password) +current.setMaxLength(1024) +layout.addWidget(current) +new = QLineEdit() +new.setPlaceholderText('New password · at least 8 characters') +new.setEchoMode(QLineEdit.Password) +new.setMaxLength(1024) +layout.addWidget(new) +show = QCheckBox('Show passwords') +show.toggled.connect(lambda checked: [field.setEchoMode(QLineEdit.Normal if checked else QLineEdit.Password) for field in (current, new)]) +layout.addWidget(show) +status = QLabel('Updates your login and the encrypted root disk together. Approve the system authorization prompt when asked.') +status.setWordWrap(True) +layout.addWidget(status) +button = QPushButton('Change password') +layout.addWidget(button) +process = QProcess(dialog) + + +def submit(): + if len(new.text()) < 8: + status.setText('Use at least 8 characters.') + return + if not current.text(): + status.setText('Enter your current password.') + return + button.setEnabled(False) + current.setEnabled(False) + new.setEnabled(False) + status.setText('Approve the system authorization prompt. Keep this window open until the password change finishes.') + payload = json.dumps({'current': current.text(), 'new': new.text()}).encode() + process.start('pkexec', [os.environ['NERDOS_PASSWORD_WORKER']]) + process.write(payload) + process.closeWriteChannel() + current.clear() + new.clear() + + +def finished(_code, _status): + try: + result = json.loads(bytes(process.readAllStandardOutput())) + status.setText(result.get('message') or result.get('error') or 'The password change did not complete.') + except (ValueError, UnicodeError): + status.setText('Authorization was cancelled or the password service could not start. No completed password change was reported.') + process.readAllStandardError() + button.setEnabled(True) + current.setEnabled(True) + new.setEnabled(True) + + +process.finished.connect(finished) +process.errorOccurred.connect(lambda _error: finished(127, QProcess.CrashExit)) +button.clicked.connect(submit) +new.returnPressed.connect(submit) +dialog.show() +sys.exit(app.exec()) diff --git a/roles/boot/files/bullet.png b/desktop/plymouth/bullet.png similarity index 100% rename from roles/boot/files/bullet.png rename to desktop/plymouth/bullet.png diff --git a/roles/boot/files/entry.png b/desktop/plymouth/entry.png similarity index 100% rename from roles/boot/files/entry.png rename to desktop/plymouth/entry.png diff --git a/desktop/plymouth/logo.png b/desktop/plymouth/logo.png new file mode 100644 index 00000000..9bf5a8de Binary files /dev/null and b/desktop/plymouth/logo.png differ diff --git a/desktop/plymouth/logo@2x.png b/desktop/plymouth/logo@2x.png new file mode 100644 index 00000000..e7ff9b53 Binary files /dev/null and b/desktop/plymouth/logo@2x.png differ diff --git a/desktop/plymouth/nerdos.plymouth b/desktop/plymouth/nerdos.plymouth new file mode 100644 index 00000000..77304e0e --- /dev/null +++ b/desktop/plymouth/nerdos.plymouth @@ -0,0 +1,9 @@ +[Plymouth Theme] +Name=NerdOS +Description=NerdOS splash screen. +ModuleName=script + +[script] +ImageDir=/usr/share/plymouth/themes/nerdos +ScriptFile=/usr/share/plymouth/themes/nerdos/nerdos.script +ConsoleLogBackgroundColor=0x0d0d0d diff --git a/desktop/plymouth/nerdos.script b/desktop/plymouth/nerdos.script new file mode 100644 index 00000000..7206eb5c --- /dev/null +++ b/desktop/plymouth/nerdos.script @@ -0,0 +1,285 @@ +# NerdOS Plymouth Theme Script + +Window.SetBackgroundTopColor(0.051, 0.051, 0.051); +Window.SetBackgroundBottomColor(0.051, 0.051, 0.051); + +logo.image = Image("logo.png"); +logo.sprite = Sprite(logo.image); +logo.sprite.SetX (Window.GetWidth() / 2 - logo.image.GetWidth() / 2); +logo.sprite.SetY (Window.GetHeight() / 2 - logo.image.GetHeight() / 2); +logo.sprite.SetOpacity (1); + +# Use these to adjust the progress bar timing +global.fake_progress_limit = 0.7; # Target percentage for fake progress (0.0 to 1.0) +global.fake_progress_duration = 15.0; # Duration in seconds to reach limit + +# Progress bar animation variables +global.fake_progress = 0.0; +global.real_progress = 0.0; +global.fake_progress_active = 0; # 0 / 1 boolean +global.animation_frame = 0; +global.fake_progress_start_time = 0; # Track when fake progress started +global.password_shown = 0; # Track if password dialog has been shown +global.max_progress = 0.0; # Track the maximum progress reached to prevent backwards movement +global.layout_width = 0; +global.layout_height = 0; + +fun refresh_callback () + { + update_layout(); + global.animation_frame++; + + # Animate fake progress to limit over time with easing + if (global.fake_progress_active == 1) + { + # Calculate elapsed time since start + elapsed_time = global.animation_frame / 50.0; # Convert frames to seconds (50 FPS) + + # Calculate linear progress ratio (0 to 1) based on time + time_ratio = elapsed_time / global.fake_progress_duration; + if (time_ratio > 1.0) + time_ratio = 1.0; + + # Apply easing curve: ease-out quadratic + # Formula: 1 - (1 - x)^2 + eased_ratio = 1 - ((1 - time_ratio) * (1 - time_ratio)); + + # Calculate fake progress based on eased ratio + global.fake_progress = eased_ratio * global.fake_progress_limit; + + # Update progress bar with fake progress + update_progress_bar(global.fake_progress); + } + } + +#----------------------------------------- Helper Functions -------------------------------- + +fun update_progress_bar(progress) + { + # Only update if progress is moving forward + if (progress > global.max_progress) + { + global.max_progress = progress; + width = Math.Int(progress_bar.original_image.GetWidth() * progress); + if (width < 1) width = 1; # Ensure minimum width of 1 pixel + + progress_bar.image = progress_bar.original_image.Scale(width, progress_bar.original_image.GetHeight()); + progress_bar.sprite.SetImage(progress_bar.image); + } + } + +fun show_progress_bar() + { + progress_box.sprite.SetOpacity(1); + progress_bar.sprite.SetOpacity(1); + } + +fun hide_progress_bar() + { + progress_box.sprite.SetOpacity(0); + progress_bar.sprite.SetOpacity(0); + } + +fun show_password_dialog() + { + entry.sprite.SetOpacity(1); + } + +fun hide_password_dialog() + { + entry.sprite.SetOpacity(0); + for (index = 0; bullet.sprites[index]; index++) + bullet.sprites[index].SetOpacity(0); + } + +fun start_fake_progress() + { + # Don't reset if we already have progress + if (global.max_progress == 0.0) + { + global.fake_progress = 0.0; + global.real_progress = 0.0; + update_progress_bar(0.0); + } + global.fake_progress_active = 1; + global.animation_frame = 0; + } + +fun stop_fake_progress() + { + global.fake_progress_active = 0; + } + +#----------------------------------------- Dialogue -------------------------------- + +entry.image = Image("entry.png"); +bullet.image = Image("bullet.png"); + +entry.sprite = Sprite(entry.image); +entry.x = Window.GetWidth()/2 - entry.image.GetWidth() / 2; +entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40; +entry.sprite.SetPosition(entry.x, entry.y, 10001); +entry.sprite.SetOpacity(0); + +# Bullet array +bullet.sprites = []; + +fun display_normal_callback () + { + update_layout(); + hide_password_dialog(); + + # Get current mode + mode = Plymouth.GetMode(); + + # Only show progress bar for boot and resume modes + if ((mode == "boot" || mode == "resume") && global.password_shown == 1) + { + show_progress_bar(); + start_fake_progress(); + } + } + +fun display_password_callback (prompt, bullets) + { + update_layout(); + global.password_shown = 1; # Mark that password dialog has been shown + + # Reset progress when password dialog appears + stop_fake_progress(); + hide_progress_bar(); + global.max_progress = 0.0; + global.fake_progress = 0.0; + global.real_progress = 0.0; + show_password_dialog(); + + # Clear all bullets first + for (index = 0; bullet.sprites[index]; index++) + bullet.sprites[index].SetOpacity(0); + + # Create and show bullets for current password (max 21) + max_bullets = 21; + bullets_to_show = bullets; + if (bullets_to_show > max_bullets) + bullets_to_show = max_bullets; + + for (index = 0; index < bullets_to_show; index++) + { + if (!bullet.sprites[index]) + { + # Scale bullet image to 7x7 pixels + scaled_bullet = bullet.image.Scale(7, 7); + bullet.sprites[index] = Sprite(scaled_bullet); + bullet.x = entry.x + 20 + index * (7 + 5); + bullet.y = entry.y + entry.image.GetHeight() / 2 - 3.5; + bullet.sprites[index].SetPosition(bullet.x, bullet.y, 10002); + } + bullet.sprites[index].SetOpacity(1); + } + } + +#----------------------------------------- Progress Bar -------------------------------- + +progress_box.image = Image("progress_box.png"); +progress_box.sprite = Sprite(progress_box.image); + +progress_box.x = Window.GetWidth() / 2 - progress_box.image.GetWidth() / 2; +progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2; +progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0); +progress_box.sprite.SetOpacity(0); + +progress_bar.original_image = Image("progress_bar.png"); +progress_bar.sprite = Sprite(); +progress_bar.image = progress_bar.original_image.Scale(1, progress_bar.original_image.GetHeight()); + +progress_bar.x = Window.GetWidth() / 2 - progress_bar.original_image.GetWidth() / 2; +progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2; +progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1); +progress_bar.sprite.SetOpacity(0); + +# Plymouth starts on the firmware framebuffer before xe exposes the internal +# panel and the Apple display's two tiles. The script plugin then changes its +# global canvas and centers every display inside it. Recenter the sprites when +# that geometry changes so the LUKS prompt cannot remain outside a display. +fun update_layout () + { + new_width = Window.GetWidth(); + new_height = Window.GetHeight(); + + if (new_width <= 0 || new_height <= 0) + return; + + if (new_width == global.layout_width && new_height == global.layout_height) + return; + + global.layout_width = new_width; + global.layout_height = new_height; + + logo.x = new_width / 2 - logo.image.GetWidth() / 2; + logo.y = new_height / 2 - logo.image.GetHeight() / 2; + logo.sprite.SetX(logo.x); + logo.sprite.SetY(logo.y); + + entry.x = new_width / 2 - entry.image.GetWidth() / 2; + entry.y = logo.y + logo.image.GetHeight() + 40; + entry.sprite.SetPosition(entry.x, entry.y, 10001); + + for (index = 0; bullet.sprites[index]; index++) + { + bullet.x = entry.x + 20 + index * (7 + 5); + bullet.y = entry.y + entry.image.GetHeight() / 2 - 3.5; + bullet.sprites[index].SetPosition(bullet.x, bullet.y, 10002); + } + + progress_box.x = new_width / 2 - progress_box.image.GetWidth() / 2; + progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2; + progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0); + + progress_bar.x = new_width / 2 - progress_bar.original_image.GetWidth() / 2; + progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2; + progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1); + } + +fun progress_callback (duration, progress) + { + global.real_progress = progress; + + # If real progress is above limit, stop fake progress and use real progress + if (progress > global.fake_progress_limit) + { + stop_fake_progress(); + update_progress_bar(progress); + } + } + +#----------------------------------------- Quit -------------------------------- + +fun quit_callback () +{ + logo.sprite.SetOpacity (1); +} + +#----------------------------------------- Message -------------------------------- + +message_sprite = Sprite(); +message_sprite.SetPosition(10, 10, 10000); + +fun display_message_callback (text) +{ + my_image = Image.Text(text, 1, 1, 1); + message_sprite.SetImage(my_image); +} + +fun hide_message_callback (text) +{ + message_sprite.SetOpacity(0); +} + +update_layout(); +Plymouth.SetRefreshFunction(refresh_callback); +Plymouth.SetDisplayNormalFunction(display_normal_callback); +Plymouth.SetDisplayPasswordFunction(display_password_callback); +Plymouth.SetBootProgressFunction(progress_callback); +Plymouth.SetQuitFunction(quit_callback); +Plymouth.SetDisplayMessageFunction (display_message_callback); +Plymouth.SetHideMessageFunction (hide_message_callback); diff --git a/roles/boot/files/progress_bar.png b/desktop/plymouth/progress_bar.png similarity index 100% rename from roles/boot/files/progress_bar.png rename to desktop/plymouth/progress_bar.png diff --git a/roles/boot/files/progress_box.png b/desktop/plymouth/progress_box.png similarity index 100% rename from roles/boot/files/progress_box.png rename to desktop/plymouth/progress_box.png diff --git a/roles/desktop/files/quickshell/.qmllint.ini b/desktop/quickshell/.qmllint.ini similarity index 100% rename from roles/desktop/files/quickshell/.qmllint.ini rename to desktop/quickshell/.qmllint.ini diff --git a/desktop/quickshell/Assistant/AssistantApproval.qml b/desktop/quickshell/Assistant/AssistantApproval.qml new file mode 100644 index 00000000..b1b5981a --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantApproval.qml @@ -0,0 +1,182 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" +import "../Common/AssistantHelpers.js" as AssistantHelpers + +// One thing the agent wants to do that the policy would not decide alone +// (desktop/assistant/nerdos_assistant/policy.py): a command, an edit outside +// the desktop's own preferences, or applying a staged system change. The +// agent waits until this card is answered. Applying a staged change shows +// the actual package changes; polkit then asks for the password. +Rectangle { + id: root + + required property var approval + readonly property string kind: approval.kind || "other" + readonly property bool applying: kind === "apply" + readonly property var closure: AssistantHelpers.closureChanges(approval.changes || "", 6) + readonly property string body: kind === "command" ? (approval.command || "") + : kind === "edit" ? (approval.diff || "") + : applying ? "" : (approval.command || approval.diff || "") + + implicitHeight: column.implicitHeight + Theme.scaled(20) + radius: Theme.scaled(12) + color: Theme.cardFill + border.width: 1 + border.color: applying ? Theme.hairline : Theme.amberBorder + + Column { + id: column + x: Theme.scaled(10) + y: Theme.scaled(10) + width: parent.width - Theme.scaled(20) + spacing: Theme.scaled(8) + + Row { + width: parent.width + spacing: Theme.scaled(8) + + Sym { + id: kindGlyph + anchors.verticalCenter: parent.verticalCenter + name: AssistantHelpers.APPROVAL_GLYPHS[root.kind] || "security" + size: Theme.iconMedium + color: root.applying ? Theme.accentText : Theme.amber + } + + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - kindGlyph.width - parent.spacing + text: root.approval.title || I18n.tr("Allow this?") + elide: Text.ElideRight + color: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + } + } + + Text { + width: parent.width + visible: text !== "" + text: root.kind === "edit" && root.approval.path + ? AssistantHelpers.displayPath(root.approval.path, Assistant.home) + : (root.approval.detail || "") + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 4 + elide: Text.ElideRight + color: Theme.textMid + font.family: root.kind === "edit" ? Theme.fontMono : Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + + // The command or the change, as the agent wrote it. + Rectangle { + width: parent.width + visible: root.body !== "" + height: visible ? Math.min(bodyText.implicitHeight, Theme.scaled(150)) + Theme.scaled(14) : 0 + radius: Theme.chipRadius + color: Theme.well + clip: true + + Flickable { + anchors.fill: parent + anchors.margins: Theme.scaled(7) + contentWidth: width + contentHeight: bodyText.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Text { + id: bodyText + width: parent.width + text: root.body + textFormat: Text.PlainText + wrapMode: Text.WrapAnywhere + color: Theme.textHi + font.family: Theme.fontMono + font.pixelSize: Theme.typography.metadata + } + } + } + + // A staged change: what the reviewed build adds, removes and updates. + Column { + width: parent.width + visible: root.applying + spacing: Theme.scaled(3) + + Repeater { + model: root.closure.rows + + delegate: Row { + id: changeRow + required property var modelData + width: column.width + spacing: Theme.scaled(6) + + Text { + text: changeRow.modelData.kind === "added" ? "+" + : changeRow.modelData.kind === "removed" ? "−" : "~" + color: changeRow.modelData.kind === "added" ? Theme.ok + : changeRow.modelData.kind === "removed" ? Theme.redText : Theme.textLow + font.family: Theme.fontMono + font.pixelSize: Theme.typography.metadata + } + + Text { + width: changeRow.width - Theme.scaled(14) + text: changeRow.modelData.name + " " + changeRow.modelData.change + elide: Text.ElideRight + color: Theme.textMid + font.family: Theme.fontMono + font.pixelSize: Theme.typography.metadata + } + } + } + + Text { + visible: root.closure.more > 0 || root.closure.rows.length === 0 + text: root.closure.rows.length === 0 ? I18n.tr("No package changes; configuration only.") + : I18n.format("and %1 more", [root.closure.more]) + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + + Text { + width: parent.width + text: root.approval.ready === false + ? I18n.tr("The updater does not hold this change as built and checked, so it cannot be applied. Deny it and ask again.") + : I18n.tr("Applying asks for the administrator password. A reboot may be needed afterwards; it never happens on its own.") + wrapMode: Text.WordWrap + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + } + + Flow { + width: parent.width + spacing: Theme.scaled(6) + layoutDirection: Qt.RightToLeft + + AssistantButton { + label: root.applying ? I18n.tr("Apply…") : I18n.tr("Allow") + primary: true + enabled: !root.applying || root.approval.ready !== false + onTriggered: Assistant.respond(root.approval.id, "allow") + } + + AssistantButton { + visible: !root.applying + label: I18n.tr("Always in this conversation") + onTriggered: Assistant.respond(root.approval.id, "always") + } + + AssistantButton { + label: root.applying ? I18n.tr("Not now") : I18n.tr("Deny") + onTriggered: Assistant.respond(root.approval.id, "deny") + } + } + } +} diff --git a/desktop/quickshell/Assistant/AssistantButton.qml b/desktop/quickshell/Assistant/AssistantButton.qml new file mode 100644 index 00000000..693186dd --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantButton.qml @@ -0,0 +1,91 @@ +import QtQuick +import "../Common" + +// The assistant box's one button: a glyph, a label, or both. `primary` is +// the action the card exists for (Send, Allow, Apply…); `danger` stops or +// refuses. A glyph-only button still names itself for screen readers. +Rectangle { + id: root + + property string glyph: "" + property string label: "" + property string accessibleName: label + property bool primary: false + property bool danger: false + property bool round: label === "" + property real controlHeight: Theme.scaled(30) + readonly property color ink: primary ? Theme.accentFg + : danger ? Theme.redText : Theme.textMid + + signal triggered() + + implicitHeight: controlHeight + implicitWidth: round ? controlHeight + : content.implicitWidth + Theme.scaled(label !== "" && glyph !== "" ? 20 : 22) + radius: round ? height / 2 : Theme.chipRadius + color: primary ? Theme.accent : danger ? Theme.redBgSoft : Theme.chip + opacity: enabled ? 1 : 0.45 + activeFocusOnTab: enabled && visible + border.width: activeFocus ? 1 : 0 + border.color: Theme.accentText + + Accessible.role: Accessible.Button + Accessible.name: root.accessibleName + Accessible.onPressAction: { + if (root.enabled) + root.triggered(); + } + + Keys.onPressed: event => { + if (root.enabled && (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space)) { + pressState.pulseCenter(); + root.triggered(); + event.accepted = true; + } + } + + StateLayer { + id: pressState + anchors.fill: parent + radius: parent.radius + hovered: mouse.containsMouse && root.enabled + pressed: mouse.pressed + focused: root.activeFocus + tint: root.ink + pressPoint: Qt.point(mouse.mouseX, mouse.mouseY) + } + + Row { + id: content + anchors.centerIn: parent + spacing: Theme.scaled(6) + + Sym { + anchors.verticalCenter: parent.verticalCenter + visible: root.glyph !== "" + name: root.glyph + size: Theme.iconMedium + color: root.ink + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: root.label !== "" + text: root.label + color: root.ink + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: root.primary ? Theme.weightSemibold : Theme.weightMedium + } + } + + MouseArea { + id: mouse + anchors.fill: parent + enabled: root.enabled + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.triggered() + } +} diff --git a/desktop/quickshell/Assistant/AssistantCharacter.qml b/desktop/quickshell/Assistant/AssistantCharacter.qml new file mode 100644 index 00000000..07d283df --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantCharacter.qml @@ -0,0 +1,272 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Shapes +import "../Common" + +// The assistant itself: the NerdOS glasses on a charcoal disc. As the mascot +// (Settings → Assistant → Character) a pair of eyes sits behind the lenses +// and shows what it is doing; as the mark the logo is drawn unchanged and +// only the ring around the disc speaks. The glasses come from the master +// symbol's own geometry (assets/brand/symbol/svg/nerdos-symbol-on-dark.svg) +// in the brand colours, which belong to the identity rather than the theme. +// +// Moods (AssistantHelpers.mood): idle, attentive, listening, thinking, +// alert, happy, sorry, asleep. +Item { + id: root + + property string mood: "idle" + property bool mascot: true + // 0..1, the microphone's level while listening. + property real level: 0 + property bool hovered: false + property bool unread: false + // Which side the box is on, so an attentive look goes to it. + property bool boxAbove: false + // Where the eyes should look, in -1..1 of their travel, while idle. + property real lookX: 0 + property real lookY: 0 + + readonly property color charcoal: "#1A1B1E" + readonly property color offWhite: "#F3F2EC" + readonly property color lime: "#C8FF1F" + + readonly property string framesPath: "M277.98 69.28 300.08 31C303.63 24.85 301.98 17.12 296.5 12.91C303.09 8.11 311.22 5.28 320 5.28H444C457.96 5.28 470.26 12.43 477.41 23.28H493C499.08 23.28 504 28.2 504 34.28V48.28C504 54.35 499.08 59.28 493 59.28H484V93.28C484 128.62 455.35 157.28 420 157.28H344C308.65 157.28 280 128.62 280 93.28V69.28H277.98ZM60 5.28H184C199.62 5.28 213.14 14.23 219.73 27.28H226.02L203.92 65.55C200.06 72.25 202.35 80.81 209.05 84.68L224 93.31C223.98 128.64 195.34 157.28 160 157.28H84C48.65 157.28 20 128.62 20 93.28V59.28H11C4.92 59.28 0 54.35 0 48.28V34.28C0 28.2 4.92 23.28 11 23.28H26.59C33.74 12.43 46.04 5.28 60 5.28ZM336 45.28C327.16 45.28 320 52.44 320 61.28V81.28C320 101.16 336.12 117.28 356 117.28H408C427.88 117.28 444 101.16 444 81.28V61.28C444 52.44 436.84 45.28 428 45.28ZM76 45.28C67.16 45.28 60 52.44 60 61.28V81.28C60 101.16 76.12 117.28 96 117.28H148C167.88 117.28 184 101.16 184 81.28V61.28C184 52.44 176.84 45.28 168 45.28Z" + readonly property string tapePath: "M257.55 0.67 290.45 19.67C292.85 21.05 293.67 24.11 292.28 26.5L253.28 94.05C251.9 96.44 248.85 97.26 246.45 95.88L213.55 76.88C211.15 75.5 210.33 72.44 211.72 70.05L250.72 2.5C252.1 0.11 255.15 -0.71 257.55 0.67Z" + + readonly property bool working: mood === "thinking" + readonly property bool ringShown: hovered || mood === "attentive" || mood === "listening" + || mood === "alert" || (mood === "happy" && !mascot) + + implicitWidth: Theme.scaled(64) + implicitHeight: implicitWidth + + // ---- the ring ------------------------------------------------------------ + Rectangle { + id: ring + anchors.centerIn: parent + width: parent.width + 8 + height: width + radius: width / 2 + color: "transparent" + border.width: 2 + border.color: root.mood === "alert" ? Theme.amber + : root.mood === "listening" ? root.lime : Qt.rgba(0.784, 1, 0.122, 0.55) + opacity: root.ringShown ? 1 : 0 + scale: root.mood === "listening" ? 1 + 0.22 * root.level : 1 + + Behavior on opacity { + NumberAnimation { duration: Theme.chipFadeDuration } + } + Behavior on scale { + enabled: !Theme.reducedMotion + NumberAnimation { duration: 80 } + } + } + + // The working arc runs around the disc while a turn is in progress. + Shape { + id: arc + anchors.centerIn: parent + width: parent.width + 10 + height: width + opacity: root.working ? 1 : 0 + visible: opacity > 0 + preferredRendererType: Shape.CurveRenderer + + Behavior on opacity { + NumberAnimation { duration: Theme.chipFadeDuration } + } + + ShapePath { + strokeColor: root.lime + strokeWidth: 3 + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: arc.width / 2 + centerY: arc.height / 2 + radiusX: arc.width / 2 - 2 + radiusY: radiusX + startAngle: -90 + sweepAngle: 100 + } + } + + RotationAnimator on rotation { + running: root.working && !Theme.reducedMotion + from: 0 + to: 360 + duration: 1100 + loops: Animation.Infinite + } + } + + // ---- the disc -------------------------------------------------------------- + Rectangle { + id: disc + anchors.fill: parent + radius: width / 2 + color: root.charcoal + border.width: 1 + border.color: Qt.rgba(0.953, 0.949, 0.925, root.hovered ? 0.28 : 0.14) + + Behavior on border.color { + ColorAnimation { duration: Theme.chipFadeDuration } + } + } + + // The glasses, in the symbol's own 504 × 157.28 units. + Item { + id: glasses + readonly property real unit: width / 504 + anchors.centerIn: parent + anchors.verticalCenterOffset: root.mascot ? -1 : 0 + width: root.width * 0.82 + height: width * 157.28 / 504 + opacity: root.mood === "asleep" ? 0.55 : 1 + + Behavior on opacity { + NumberAnimation { duration: Theme.chipFadeDuration } + } + + // Eyes go behind the frames, so the lens openings crop them. + Repeater { + model: root.mascot ? [122, 382] : [] + + delegate: Item { + id: eye + required property int modelData + readonly property real u: glasses.unit + // Travel inside the lens opening (124 × 72 units), in symbol + // units; the eye is 30 × 44, so a gap of dark lens always + // shows around it at the small sizes the character is drawn. + readonly property real dx: root.mood === "thinking" ? scanX + : root.mood === "attentive" ? 0 + : root.mood === "listening" ? 0 : root.lookX * 34 + readonly property real dy: root.mood === "attentive" ? (root.boxAbove ? -10 : 10) + : root.mood === "listening" ? -9 + : root.mood === "sorry" ? 10 : root.lookY * 9 + property real scanX: 0 + + x: (modelData - 15 + dx) * u + y: (81 - 22 + dy) * u + width: 30 * u + height: 44 * u + + Behavior on x { + enabled: !Theme.reducedMotion + NumberAnimation { duration: 180; easing.type: Easing.OutCubic } + } + Behavior on y { + enabled: !Theme.reducedMotion + NumberAnimation { duration: 180; easing.type: Easing.OutCubic } + } + + SequentialAnimation on scanX { + running: root.mood === "thinking" && !Theme.reducedMotion + loops: Animation.Infinite + NumberAnimation { to: 34; duration: 700; easing.type: Easing.InOutSine } + NumberAnimation { to: -34; duration: 900; easing.type: Easing.InOutSine } + NumberAnimation { to: 0; duration: 500; easing.type: Easing.InOutSine } + } + + // Open eyes: a soft upright pill that blinks. + Rectangle { + id: open + anchors.centerIn: parent + width: parent.width * (root.mood === "alert" || root.mood === "listening" ? 1.12 : 1) + height: parent.height * blink.amount + * (root.mood === "sorry" ? 0.55 : root.mood === "alert" ? 1.1 : 1) + radius: width / 2 + color: root.offWhite + visible: root.mood !== "happy" && root.mood !== "asleep" + } + + // Happy and asleep: a stroke instead of an eye. + Shape { + anchors.fill: parent + visible: root.mood === "happy" || root.mood === "asleep" + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: root.offWhite + strokeWidth: Math.max(1.5, 9 * eye.u) + fillColor: "transparent" + capStyle: ShapePath.RoundCap + startX: 0 + startY: root.mood === "happy" ? eye.height * 0.62 : eye.height * 0.55 + + PathQuad { + x: eye.width + y: root.mood === "happy" ? eye.height * 0.62 : eye.height * 0.55 + controlX: eye.width / 2 + controlY: root.mood === "happy" ? eye.height * 0.1 : eye.height * 0.6 + } + } + } + } + } + + Shape { + width: 504 + height: 157.28 + scale: glasses.unit + transformOrigin: Item.TopLeft + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: root.offWhite + strokeColor: "transparent" + strokeWidth: 0 + fillRule: ShapePath.OddEvenFill + PathSvg { path: root.framesPath } + } + + ShapePath { + fillColor: root.lime + strokeColor: "transparent" + strokeWidth: 0 + PathSvg { path: root.tapePath } + } + } + } + + // A reply finished while the box was tucked away or the character hidden. + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: -1 + width: Theme.scaled(13) + height: width + radius: width / 2 + color: root.mood === "alert" ? Theme.amber : root.lime + border.width: 2 + border.color: root.charcoal + visible: root.unread || root.mood === "alert" + } + + // Blinks every few seconds; never while a stroke stands in for the eyes. + QtObject { + id: blink + property real amount: 1 + } + + SequentialAnimation { + id: blinkOnce + NumberAnimation { target: blink; property: "amount"; to: 0.12; duration: 70 } + NumberAnimation { target: blink; property: "amount"; to: 1; duration: 110 } + } + + Timer { + interval: 3200 + Math.round(Math.random() * 2600) + repeat: true + running: root.mascot && root.visible && !Theme.reducedMotion + && ["idle", "attentive", "thinking", "listening"].indexOf(root.mood) !== -1 + onTriggered: { + blinkOnce.restart(); + interval = 3200 + Math.round(Math.random() * 2600); + } + } +} diff --git a/desktop/quickshell/Assistant/AssistantComposer.qml b/desktop/quickshell/Assistant/AssistantComposer.qml new file mode 100644 index 00000000..d2c897c2 --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantComposer.qml @@ -0,0 +1,515 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell.Services.Pipewire +import "../Common" +import "../Common/AssistantHelpers.js" as AssistantHelpers + +// Where a question is typed or spoken. Enter sends, Shift+Enter breaks the +// line and Esc closes (a recording is cancelled first). While listening, the +// field becomes a level meter; Voxtype transcribes once the key or button is +// released, and the words land here — sent straight away for push-to-talk. +AssistantSurface { + id: root + + readonly property bool listening: Assistant.listening + // The microphone's level, which the character's ring follows too. + readonly property real level: listening ? peaks.peak : 0 + readonly property bool transcribing: Assistant.transcribing + readonly property var suggestions: { + const list = [ + { text: I18n.tr("What can you do?"), screen: false }, + { text: I18n.tr("Make the menubar transparent"), screen: false }, + { text: I18n.tr("Create a CPU temperature widget"), screen: false } + ]; + if (Settings.assistantScreenContext) + list.push({ text: I18n.tr("Explain what is on my screen"), screen: true }); + return list; + } + + function focusInput() { + if (!root.listening && !Assistant.setupRequired) + input.forceActiveFocus(); + } + + AssistantSetup { + width: parent.width + visible: Assistant.connected && Assistant.setupRequired + } + + // Esc backs out one step at a time: the recording, then the box. + function backOut() { + if (Assistant.listening || Assistant.transcribing) + Assistant.cancelListening(); + else + Assistant.closeComposer(); + } + + Connections { + target: Assistant + + function onFocusComposer() { + Qt.callLater(root.focusInput); + } + + function onDraftChanged() { + if (input.text !== Assistant.draft) { + input.syncing = true; + input.text = Assistant.draft; + input.cursorPosition = input.text.length; + input.syncing = false; + } + } + } + + // ---- the conversation this continues ------------------------------------ + Item { + width: parent.width + height: conversationLine.implicitHeight + visible: Assistant.conversation !== null && !root.listening && !Assistant.setupRequired + + Text { + id: conversationLine + anchors.left: parent.left + anchors.right: newLink.left + anchors.rightMargin: Theme.scaled(8) + text: I18n.tr("Continuing the conversation") + elide: Text.ElideRight + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + + Text { + id: newLink + anchors.right: parent.right + text: I18n.tr("New conversation") + color: newMouse.containsMouse ? Theme.accentText : Theme.textMid + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + font.weight: Theme.weightMedium + Accessible.role: Accessible.Button + Accessible.name: text + + MouseArea { + id: newMouse + anchors.fill: parent + anchors.margins: -4 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Assistant.newConversation() + } + } + } + + // ---- the attached screenshot ------------------------------------------------ + Rectangle { + width: parent.width + height: visible ? Theme.scaled(46) : 0 + visible: Assistant.screenAttached && !Assistant.setupRequired + radius: Theme.chipRadius + color: Theme.chip + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.scaled(6) + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.scaled(10) + + Rectangle { + width: Theme.scaled(56) + height: Theme.scaled(34) + radius: 4 + color: Theme.well + clip: true + + Image { + anchors.fill: parent + source: Assistant.screenAttached ? "file://" + Assistant.screenPath : "" + sourceSize.width: Theme.scaled(112) + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + + Text { + text: I18n.format("Screen · %1", [Assistant.screenOutput]) + color: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + } + Text { + text: I18n.tr("Sent with this question only") + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + } + } + + AssistantButton { + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(6) + anchors.verticalCenter: parent.verticalCenter + glyph: "close" + accessibleName: I18n.tr("Remove the screenshot") + controlHeight: Theme.scaled(26) + color: "transparent" + onTriggered: Assistant.detachScreen() + } + } + + // ---- the field --------------------------------------------------------------- + Rectangle { + id: field + visible: !Assistant.setupRequired + width: parent.width + height: Math.max(Theme.scaled(44), inputArea.height + Theme.scaled(14)) + radius: Theme.scaled(12) + color: Theme.well + border.width: 1 + border.color: input.activeFocus || root.listening ? Theme.accent : Theme.hairline + + Behavior on border.color { + ColorAnimation { duration: Theme.chipFadeDuration } + } + + Item { + id: inputArea + anchors.left: parent.left + anchors.leftMargin: Theme.scaled(12) + anchors.right: buttons.left + anchors.rightMargin: Theme.scaled(6) + anchors.verticalCenter: parent.verticalCenter + height: root.listening || root.transcribing ? Theme.scaled(28) + : Math.min(Theme.scaled(110), Math.max(Theme.scaled(22), input.contentHeight)) + + Flickable { + id: inputFlick + anchors.fill: parent + visible: !root.listening && !root.transcribing + contentWidth: width + contentHeight: Math.max(height, input.contentHeight) + clip: true + boundsBehavior: Flickable.StopAtBounds + + TextEdit { + id: input + property bool syncing: false + + width: inputFlick.width + height: Math.max(inputFlick.height, contentHeight) + wrapMode: TextEdit.Wrap + selectByMouse: true + color: Theme.textHi + selectionColor: Theme.accentSoft + selectedTextColor: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.control + Accessible.name: I18n.tr("Ask the NerdOS Assistant") + Accessible.description: I18n.tr("Enter sends. Shift Enter inserts a newline.") + Component.onCompleted: text = Assistant.draft + + onTextChanged: { + if (!syncing) + Assistant.draft = text; + } + onActiveFocusChanged: { + if (activeFocus) + Assistant.focusRequested = true; + } + onCursorRectangleChanged: { + if (cursorRectangle.y + cursorRectangle.height + > inputFlick.contentY + inputFlick.height) + inputFlick.contentY = cursorRectangle.y + + cursorRectangle.height - inputFlick.height; + else if (cursorRectangle.y < inputFlick.contentY) + inputFlick.contentY = cursorRectangle.y; + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + root.backOut(); + event.accepted = true; + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (event.modifiers & Qt.ShiftModifier) + input.insert(input.cursorPosition, "\n"); + else + Assistant.send(); + event.accepted = true; + } + } + + Text { + visible: input.text === "" + width: parent.width + text: Assistant.connected ? I18n.tr("Ask NerdOS anything…") + : I18n.tr("The assistant service is not running.") + elide: Text.ElideRight + color: Theme.textFaint + font: input.font + } + } + } + + // Listening: the red dot, the level and the time so far. + Row { + anchors.fill: parent + visible: root.listening + spacing: Theme.scaled(8) + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Theme.scaled(8) + height: width + radius: width / 2 + color: Theme.red + + SequentialAnimation on opacity { + running: root.listening && !Theme.reducedMotion + loops: Animation.Infinite + NumberAnimation { to: 0.35; duration: 600 } + NumberAnimation { to: 1; duration: 600 } + } + } + + AssistantVisualizer { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - timer.width - Theme.scaled(24) + height: parent.height + level: root.level + running: root.listening + } + + Text { + id: timer + anchors.verticalCenter: parent.verticalCenter + text: AssistantHelpers.elapsedLabel(Assistant.now - Assistant.listenStartedAt) + color: Theme.textLow + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.metadata + font.features: Theme.tabularNumberFeatures + } + } + + Row { + anchors.verticalCenter: parent.verticalCenter + visible: root.transcribing + spacing: Theme.scaled(8) + + Sym { + id: transcribingSpinner + anchors.verticalCenter: parent.verticalCenter + name: "progress_activity" + size: Theme.iconMedium + color: Theme.accentText + + RotationAnimator on rotation { + running: root.transcribing && !Theme.reducedMotion + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Transcribing…") + color: Theme.textMid + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + } + } + } + + Row { + id: buttons + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(6) + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.scaled(7) + spacing: Theme.scaled(2) + + AssistantButton { + visible: Settings.assistantScreenContext && !root.listening && !root.transcribing + glyph: "monitor" + accessibleName: Assistant.screenAttached ? I18n.tr("Remove the screenshot") + : I18n.tr("Attach the screen") + color: Assistant.screenAttached ? Theme.chipHover : "transparent" + enabled: !Assistant.capturing + onTriggered: Assistant.toggleScreen() + } + + AssistantButton { + visible: Assistant.voiceAvailable && !root.transcribing + glyph: root.listening ? "stop" : "mic" + accessibleName: root.listening ? I18n.tr("Stop recording") + : I18n.tr("Talk instead of typing") + color: root.listening ? Theme.redBgSoft : "transparent" + danger: root.listening + onTriggered: Assistant.toggleMicrophone() + } + + AssistantButton { + visible: root.listening || root.transcribing + glyph: "close" + accessibleName: I18n.tr("Cancel the recording") + color: "transparent" + onTriggered: Assistant.cancelListening() + } + + AssistantButton { + visible: Assistant.draft.trim() !== "" && !root.listening && !root.transcribing + glyph: "send" + primary: true + accessibleName: I18n.tr("Send") + enabled: Assistant.connected && !Assistant.busy + onTriggered: Assistant.send() + } + } + } + + // ---- first questions ----------------------------------------------------------- + Flow { + width: parent.width + spacing: Theme.scaled(6) + visible: Assistant.draft === "" && Assistant.conversation === null + && !root.listening && !root.transcribing && Assistant.connected && !Assistant.setupRequired + + Repeater { + model: root.suggestions + + delegate: Rectangle { + id: chip + required property var modelData + width: chipText.implicitWidth + Theme.scaled(18) + height: Theme.scaled(26) + radius: height / 2 + color: chipMouse.containsMouse ? Theme.chipHover : Theme.chip + Accessible.role: Accessible.Button + Accessible.name: chip.modelData.text + + Text { + id: chipText + anchors.centerIn: parent + text: chip.modelData.text + color: Theme.textMid + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + + MouseArea { + id: chipMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (chip.modelData.screen) + Assistant.askAboutScreen(chip.modelData.text); + else + Assistant.send(chip.modelData.text); + } + } + } + } + } + + // The service starts with the session; this is for one that stopped. + AssistantButton { + visible: !Assistant.connected + label: I18n.tr("Start the assistant") + glyph: "play_arrow" + onTriggered: Assistant.fix("service") + } + + // ---- who answers, and how to talk -------------------------------------------------- + Item { + width: parent.width + height: Theme.scaled(22) + visible: !Assistant.setupRequired + + Rectangle { + id: agentChip + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: agentRow.implicitWidth + Theme.scaled(14) + height: Theme.scaled(22) + radius: height / 2 + color: agentMouse.containsMouse ? Theme.chipHover : Theme.chip + Accessible.role: Accessible.Button + Accessible.name: I18n.format("Answered by %1. Change the agent.", [Assistant.agentLabel]) + + Row { + id: agentRow + anchors.centerIn: parent + spacing: Theme.scaled(5) + + BrandIcon { + anchors.verticalCenter: parent.verticalCenter + width: Theme.iconSmall + height: Theme.iconSmall + name: AssistantHelpers.AGENT_ICONS[Assistant.agent] || "" + colorized: true + tint: Theme.textMid + normalizeTintSource: true + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Assistant.agentInstalled || !Assistant.connected ? Assistant.agentLabel + : I18n.format("%1 is not installed", [Assistant.agentLabel]) + color: Assistant.agentInstalled || !Assistant.connected ? Theme.textMid : Theme.redText + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + } + + MouseArea { + id: agentMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!AssistantHelpers.anyAgent(Assistant.agents)) + Settings.showPanel("assistant", Assistant.screenName); + else + Settings.set("assistantAgent", + AssistantHelpers.nextAgent(Assistant.agent, Assistant.agents)); + } + } + } + + Text { + anchors.left: agentChip.right + anchors.leftMargin: Theme.scaled(8) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignRight + elide: Text.ElideLeft + text: Assistant.notice !== "" ? Assistant.notice + : root.listening ? (Assistant.voiceLatched ? I18n.tr("Tap the microphone to send") + : I18n.tr("Release to send · Esc cancels")) + : root.transcribing ? "" + : Assistant.voiceAvailable ? I18n.tr("Hold Super+I to talk") + : I18n.tr("Enter sends · Esc closes") + color: Assistant.notice !== "" ? Theme.textMid : Theme.textFaint + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + } + + // The microphone's level, only while recording. + PwObjectTracker { + objects: root.listening && Pipewire.defaultAudioSource ? [Pipewire.defaultAudioSource] : [] + } + + PwNodePeakMonitor { + id: peaks + node: Pipewire.defaultAudioSource + enabled: root.listening + } +} diff --git a/desktop/quickshell/Assistant/AssistantReply.qml b/desktop/quickshell/Assistant/AssistantReply.qml new file mode 100644 index 00000000..67f96bb9 --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantReply.qml @@ -0,0 +1,357 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import "../Common" +import "../Common/AssistantHelpers.js" as AssistantHelpers +import "../Common/T3CodeHelpers.js" as T3CodeHelpers + +// The answer, under the question it answers. It stays until closed. What the +// turn left behind gets its own row: a staged system change to review and +// apply, a new widget to add to the bar, and Undo for preferences or files it +// changed. `room` is the height the box may use on this side of the +// character; a long reply scrolls inside it. +AssistantSurface { + id: root + + property real room: 400 + readonly property var turn: Assistant.turn + readonly property bool failed: turn !== null && turn.status === "failed" + readonly property var plugins: turn && turn.plugins ? turn.plugins : [] + readonly property bool canUndo: turn !== null && turn.undo !== undefined && turn.undo.length > 0 + // The build this turn staged, while the updater still holds it. + readonly property bool staged: turn !== null && !!turn.staged + && Updates.deployment.transaction === turn.staged + readonly property string deploymentState: staged ? (Updates.deployment.state || "") : "" + readonly property var closure: AssistantHelpers.closureChanges( + staged ? Updates.deployment.changes || "" : "", 5) + readonly property real chromeHeight: header.height + actions.height + + extras.height + spacing * 3 + padding * 2 + + // ---- the question ------------------------------------------------------------ + Item { + id: header + width: parent.width + height: Theme.scaled(24) + + BrandIcon { + id: agentMark + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: Theme.iconSmall + height: Theme.iconSmall + name: root.turn ? AssistantHelpers.AGENT_ICONS[root.turn.agent] || "" : "" + colorized: true + tint: Theme.textLow + normalizeTintSource: true + } + + Text { + anchors.left: agentMark.right + anchors.leftMargin: Theme.scaled(7) + anchors.right: duration.left + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + text: root.turn ? I18n.format("You · %1", [root.turn.prompt]) : "" + textFormat: Text.PlainText + elide: Text.ElideRight + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + + Text { + id: duration + anchors.right: close.left + anchors.rightMargin: Theme.scaled(4) + anchors.verticalCenter: parent.verticalCenter + visible: root.turn !== null && root.turn.finishedAt > 0 + text: root.turn ? AssistantHelpers.elapsedLabel(root.turn.finishedAt - root.turn.startedAt) : "" + color: Theme.textFaint + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.metadata + font.features: Theme.tabularNumberFeatures + } + + AssistantButton { + id: close + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + glyph: "close" + accessibleName: I18n.tr("Close the reply") + controlHeight: Theme.scaled(24) + color: "transparent" + onTriggered: Assistant.closeReply() + } + } + + // ---- the answer ---------------------------------------------------------------- + Flickable { + id: bodyFlick + width: parent.width + height: Math.max(Theme.scaled(24), Math.min(body.implicitHeight, + root.room - root.chromeHeight)) + contentWidth: width + contentHeight: body.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height + + TextEdit { + id: body + width: bodyFlick.width + readOnly: true + selectByMouse: true + textFormat: root.failed ? TextEdit.PlainText : TextEdit.MarkdownText + text: root.failed + ? (root.turn.error || I18n.tr("Something went wrong.")) + : T3CodeHelpers.styleMarkdownLinks(root.turn ? root.turn.text || "" : "", + Theme.accentText.toString()) + wrapMode: TextEdit.Wrap + color: root.failed ? Theme.redText : Theme.textHi + selectionColor: Theme.accentSoft + selectedTextColor: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.control + // The agent wrote these links; only web and mail ones open. + onLinkActivated: link => { + if (/^(https?|mailto):/i.test(link)) + Quickshell.execDetached(["xdg-open", link]); + } + + HoverHandler { + cursorShape: body.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor + } + } + } + + // ---- what the turn left behind --------------------------------------------------- + Column { + id: extras + width: parent.width + spacing: Theme.scaled(8) + + // A failure names its one fix. + AssistantButton { + readonly property var action: root.failed + ? AssistantHelpers.errorAction(root.turn.errorKind, root.turn.agent) : null + visible: action !== null + label: !action ? "" + : root.turn.errorKind === "auth" ? I18n.tr("Sign in") + : root.turn.errorKind === "missing" ? I18n.tr("Assistant settings") + : I18n.tr("Start the assistant") + primary: true + onTriggered: Assistant.fix(root.turn.errorKind) + } + + // A system change built and checked by the updater, waiting for review. + Rectangle { + width: parent.width + visible: root.staged + height: visible ? stagedColumn.implicitHeight + Theme.scaled(18) : 0 + radius: Theme.scaled(12) + color: Theme.cardFill + border.width: 1 + border.color: Theme.hairline + + Column { + id: stagedColumn + x: Theme.scaled(9) + y: Theme.scaled(9) + width: parent.width - Theme.scaled(18) + spacing: Theme.scaled(4) + + Text { + width: parent.width + text: root.deploymentState === "ready" ? I18n.tr("Staged change · built and checked") + : root.deploymentState === "done" || root.deploymentState === "awaiting-boot" + || root.deploymentState === "awaiting-login" ? I18n.tr("Applied") + : Updates.deployment.phase || "" + elide: Text.ElideRight + color: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + font.weight: Theme.weightSemibold + } + + Repeater { + model: root.deploymentState === "ready" ? root.closure.rows : [] + + delegate: Text { + required property var modelData + width: stagedColumn.width + text: (modelData.kind === "added" ? "+ " : modelData.kind === "removed" ? "− " : "~ ") + + modelData.name + " " + modelData.change + elide: Text.ElideRight + color: modelData.kind === "added" ? Theme.ok + : modelData.kind === "removed" ? Theme.redText : Theme.textMid + font.family: Theme.fontMono + font.pixelSize: Theme.typography.metadata + } + } + + Text { + width: parent.width + visible: root.deploymentState === "awaiting-boot" + text: Updates.rebootRecommendation + wrapMode: Text.WordWrap + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + + Row { + spacing: Theme.scaled(6) + visible: root.deploymentState === "ready" + topPadding: Theme.scaled(4) + + AssistantButton { + label: I18n.tr("Apply…") + primary: true + onTriggered: Updates.apply() + } + + AssistantButton { + label: I18n.tr("Details") + onTriggered: Popouts.openPanel("updates", undefined, undefined, Assistant.screenName) + } + } + } + } + + // Widgets the turn created. They stay off until added. + Repeater { + model: root.plugins + + delegate: Rectangle { + id: pluginRow + required property var modelData + width: extras.width + height: Theme.scaled(40) + radius: Theme.scaled(10) + color: Theme.cardFill + + Sym { + id: pluginGlyph + anchors.left: parent.left + anchors.leftMargin: Theme.scaled(10) + anchors.verticalCenter: parent.verticalCenter + name: "widgets" + size: Theme.iconMedium + color: Theme.accentText + } + + Column { + anchors.left: pluginGlyph.right + anchors.leftMargin: Theme.scaled(9) + anchors.right: addButton.left + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + + Text { + width: parent.width + text: pluginRow.modelData.name || pluginRow.modelData.id + elide: Text.ElideRight + color: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + } + Text { + width: parent.width + text: I18n.format("%1 · off until added", [pluginRow.modelData.id]) + elide: Text.ElideRight + color: Theme.textLow + font.family: Theme.fontMono + font.pixelSize: Theme.typography.metadata + } + } + + AssistantButton { + id: addButton + anchors.right: parent.right + anchors.rightMargin: Theme.scaled(6) + anchors.verticalCenter: parent.verticalCenter + label: I18n.tr("Add to bar") + primary: true + controlHeight: Theme.scaled(28) + onTriggered: Assistant.addWidget(pluginRow.modelData.id) + } + } + } + + Text { + width: parent.width + visible: Assistant.notice !== "" + text: Assistant.notice + wrapMode: Text.WordWrap + color: Theme.textMid + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + } + } + + // ---- actions ------------------------------------------------------------------- + Flow { + id: actions + width: parent.width + spacing: Theme.scaled(4) + + AssistantButton { + visible: root.canUndo + label: I18n.tr("Undo") + glyph: "undo" + onTriggered: Assistant.undo() + } + + AssistantButton { + visible: Assistant.speechAvailable && !root.failed && root.turn !== null && (root.turn.text || "") !== "" + label: Assistant.speaking ? I18n.tr("Stop") : I18n.tr("Speak") + glyph: Assistant.speaking ? "stop" : "volume_up" + onTriggered: { + if (Assistant.speaking) + Assistant.stopSpeaking(); + else + Assistant.speak(); + } + } + + AssistantButton { + label: I18n.tr("Reply") + glyph: "reply" + onTriggered: Assistant.reply() + } + + AssistantButton { + visible: !root.failed && root.turn !== null && (root.turn.text || "") !== "" + glyph: "content_copy" + accessibleName: I18n.tr("Copy the reply") + onTriggered: Assistant.copyReply() + } + + AssistantButton { + visible: root.turn !== null && root.turn.resumable === true + glyph: "terminal" + accessibleName: I18n.tr("Continue in a terminal") + onTriggered: Assistant.handoff() + } + } + + // Sentence by sentence while it is read aloud. + Rectangle { + width: parent.width + height: 2 + radius: 1 + visible: Assistant.speaking && Assistant.speechCount > 0 + color: Theme.hairline + + Rectangle { + width: parent.width * Math.min(1, (Assistant.speechIndex + 1) / Math.max(1, Assistant.speechCount)) + height: parent.height + radius: 1 + color: Theme.accent + + Behavior on width { + NumberAnimation { duration: Theme.chipFadeDuration } + } + } + } +} diff --git a/desktop/quickshell/Assistant/AssistantSetup.qml b/desktop/quickshell/Assistant/AssistantSetup.qml new file mode 100644 index 00000000..f85ebe43 --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantSetup.qml @@ -0,0 +1,98 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" +import "../Common/AssistantHelpers.js" as AssistantHelpers + +Column { + id: root + spacing: Theme.scaled(10) + + Text { + width: parent.width + text: I18n.tr("Let’s connect your assistant") + color: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.control + font.weight: Theme.weightSemibold + wrapMode: Text.Wrap + } + + Text { + width: parent.width + text: Assistant.signingIn !== "" + ? I18n.tr("Finish signing in in the terminal and browser. This box will update when you’re connected.") + : I18n.tr("Choose Claude Code or Codex and sign in to your account. Then you can ask your first question.") + color: Theme.textMid + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + wrapMode: Text.Wrap + } + + Repeater { + model: AssistantHelpers.AGENT_IDS + delegate: Column { + id: account + required property string modelData + readonly property var agent: Assistant.agents[modelData] || ({}) + width: root.width + spacing: Theme.scaled(4) + + AssistantButton { + label: account.agent.auth === "ready" + ? I18n.format("Use %1", [AssistantHelpers.AGENT_LABELS[account.modelData]]) + : I18n.format("Sign in to %1", [AssistantHelpers.AGENT_LABELS[account.modelData]]) + primary: Assistant.agent === account.modelData + enabled: account.agent.available === true && !Assistant.authChecking + && Assistant.signingIn === "" + onTriggered: { + if (account.agent.auth === "ready") + Settings.set("assistantAgent", account.modelData); + else + Assistant.signIn(account.modelData); + } + } + + Text { + width: parent.width + visible: account.agent.available !== true || account.agent.auth === "unknown" + text: account.agent.available !== true ? I18n.tr("Not installed. Add it in Software.") + : I18n.tr("Sign-in status could not be checked. Try again.") + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + wrapMode: Text.Wrap + } + } + } + + Flow { + width: parent.width + spacing: Theme.scaled(6) + + AssistantButton { + label: Assistant.authChecking ? I18n.tr("Checking sign-in…") : I18n.tr("Check sign-in") + enabled: !Assistant.authChecking + onTriggered: Assistant.refreshAuth() + } + AssistantButton { + visible: !AssistantHelpers.anyAgent(Assistant.agents) + label: I18n.tr("Open Software") + onTriggered: Software.show() + } + AssistantButton { + label: I18n.tr("Close") + onTriggered: Assistant.closeComposer() + } + } + + Text { + width: parent.width + visible: Assistant.notice !== "" || Assistant.draft.trim() !== "" + text: Assistant.notice || I18n.tr("Your question is kept here until you’re signed in.") + color: Theme.textLow + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.metadata + wrapMode: Text.Wrap + textFormat: Text.PlainText + } +} diff --git a/desktop/quickshell/Assistant/AssistantSurface.qml b/desktop/quickshell/Assistant/AssistantSurface.qml new file mode 100644 index 00000000..b8d0938e --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantSurface.qml @@ -0,0 +1,28 @@ +import QtQuick +import "../Common" + +// The card the composer, the working pill and the reply share. Opaque on +// purpose: the assistant's layer is not blurred (a full-output surface would +// pay for blurring every pixel), so glass here would only lower contrast +// over whatever window happens to be underneath. +Rectangle { + id: root + + default property alias content: column.data + property real padding: Theme.scaled(12) + property alias spacing: column.spacing + + implicitHeight: column.implicitHeight + padding * 2 + radius: Theme.surfaceRadius + color: Theme.popBg + border.width: 1 + border.color: Theme.popBorder + + Column { + id: column + x: root.padding + y: root.padding + width: root.width - root.padding * 2 + spacing: Theme.scaled(10) + } +} diff --git a/desktop/quickshell/Assistant/AssistantVisualizer.qml b/desktop/quickshell/Assistant/AssistantVisualizer.qml new file mode 100644 index 00000000..a7f119a2 --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantVisualizer.qml @@ -0,0 +1,68 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" +import "../Common/Format.js" as Format + +// Bars that follow the microphone while the assistant listens: the most +// recent level enters on the right and the history scrolls left, so a +// spoken phrase reads as a shape rather than one jittering meter. +Item { + id: root + + property real level: 0 + property bool running: false + property color color: Theme.accent + property int bars: 28 + property var history: [] + + implicitHeight: Theme.scaled(26) + + function sample() { + // Peaks are linear; a square root is closer to how loud it sounds. + const value = Math.sqrt(Format.clamp01(root.level)); + const next = root.history.slice(Math.max(0, root.history.length - root.bars + 1)); + next.push(value); + root.history = next; + } + + onRunningChanged: { + if (!running) + history = []; + } + + Timer { + interval: 45 + repeat: true + running: root.running && root.visible + onTriggered: root.sample() + } + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + spacing: Math.max(2, Math.round(root.width / root.bars * 0.35)) + + Repeater { + model: root.bars + + delegate: Rectangle { + required property int index + readonly property int offset: root.bars - 1 - index + readonly property real value: offset < root.history.length + ? root.history[root.history.length - 1 - offset] : 0 + + anchors.verticalCenter: parent.verticalCenter + width: Math.max(2, Math.round(root.width / root.bars * 0.65)) + height: Math.max(3, root.height * (0.12 + 0.88 * value)) + radius: width / 2 + color: root.color + opacity: 0.45 + 0.55 * value + + Behavior on height { + enabled: !Theme.reducedMotion + NumberAnimation { duration: 60 } + } + } + } + } +} diff --git a/desktop/quickshell/Assistant/AssistantWorking.qml b/desktop/quickshell/Assistant/AssistantWorking.qml new file mode 100644 index 00000000..e6bd37ef --- /dev/null +++ b/desktop/quickshell/Assistant/AssistantWorking.qml @@ -0,0 +1,100 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" +import "../Common/AssistantHelpers.js" as AssistantHelpers + +// The pill while the agent works: what it is doing right now, how long it +// has taken and Stop. Anything it needs a decision on stacks underneath; +// the turn does not continue until it is answered. +AssistantSurface { + id: root + + readonly property var turn: Assistant.turn + padding: Theme.scaled(8) + + Item { + width: parent.width + height: Theme.scaled(32) + + Item { + id: spinner + anchors.left: parent.left + anchors.leftMargin: Theme.scaled(4) + anchors.verticalCenter: parent.verticalCenter + width: Theme.iconMedium + height: Theme.iconMedium + + Sym { + anchors.fill: parent + visible: !Assistant.waiting + name: "progress_activity" + size: Theme.iconMedium + color: Theme.accentText + + RotationAnimator on rotation { + running: !Assistant.waiting && root.visible && !Theme.reducedMotion + from: 0 + to: 360 + duration: 900 + loops: Animation.Infinite + } + } + + // Its own glyph: a stopped spinner would leave it tilted. + Sym { + anchors.fill: parent + visible: Assistant.waiting + name: "priority_high" + size: Theme.iconMedium + color: Theme.amber + } + } + + AnimatedText { + anchors.left: spinner.right + anchors.leftMargin: Theme.scaled(10) + anchors.right: elapsed.left + anchors.rightMargin: Theme.scaled(8) + anchors.verticalCenter: parent.verticalCenter + text: Assistant.waiting ? I18n.tr("Needs your answer") + : root.turn && root.turn.step ? root.turn.step : I18n.tr("Thinking") + elide: Text.ElideRight + color: Theme.textHi + font.family: Theme.fontMenu + font.pixelSize: Theme.typography.secondary + } + + Text { + id: elapsed + anchors.right: stopButton.left + anchors.rightMargin: Theme.scaled(6) + anchors.verticalCenter: parent.verticalCenter + text: root.turn ? AssistantHelpers.elapsedLabel(Assistant.now - root.turn.startedAt) : "" + color: Theme.textLow + font.family: Theme.fontNumeric + font.pixelSize: Theme.typography.metadata + font.features: Theme.tabularNumberFeatures + } + + AssistantButton { + id: stopButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + label: I18n.tr("Stop") + glyph: "stop" + danger: true + controlHeight: Theme.scaled(28) + onTriggered: Assistant.stop() + } + } + + Repeater { + model: Assistant.approvals + + delegate: AssistantApproval { + required property var modelData + width: root.width - root.padding * 2 + approval: modelData + } + } +} diff --git a/desktop/quickshell/Assistant/qmldir b/desktop/quickshell/Assistant/qmldir new file mode 100644 index 00000000..30f4c394 --- /dev/null +++ b/desktop/quickshell/Assistant/qmldir @@ -0,0 +1,12 @@ +# Sibling-type resolution inside Assistant/ relies on this explicit index; a +# directory carrying a qmldir is no longer implicitly scanned, so every +# Assistant/*.qml type must be listed. qmldir.test.cjs enforces that. +AssistantApproval AssistantApproval.qml +AssistantButton AssistantButton.qml +AssistantCharacter AssistantCharacter.qml +AssistantComposer AssistantComposer.qml +AssistantReply AssistantReply.qml +AssistantSetup AssistantSetup.qml +AssistantSurface AssistantSurface.qml +AssistantVisualizer AssistantVisualizer.qml +AssistantWorking AssistantWorking.qml diff --git a/desktop/quickshell/AssistantWindow.qml b/desktop/quickshell/AssistantWindow.qml new file mode 100644 index 00000000..4bd468ed --- /dev/null +++ b/desktop/quickshell/AssistantWindow.qml @@ -0,0 +1,326 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Window +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import "Common" +import "Assistant" +import "Common/AssistantHelpers.js" as AssistantHelpers + +// The NerdOS Assistant on screen (docs/assistant.md): the character, and the +// box under it — the composer, the working pill or the reply. +// +// One overlay surface covering the output the character is on, taking input +// only on the character and the box. A layer surface belongs to one output, +// so a drag that ends on another display moves the character there and the +// surface follows. The layer is neither blurred nor animated by Hyprland +// (looknfeel.lua), which is what makes a full-output surface affordable. +// +// Hovering opens the composer without taking the keyboard, so typing in the +// window underneath carries on; a click or Super+I gives it the keyboard. +PanelWindow { + id: root + + readonly property real size: Theme.scaled(64) + // The bar's band at the top, kept clear by the default place and the box. + readonly property real topReserve: Settings.position === "top" + ? Theme.barHeight + Theme.barTopMargin : 0 + readonly property var resting: AssistantHelpers.placement( + { x: Settings.assistantX, y: Settings.assistantY }, + { width: root.width, height: root.height }, root.size, root.topReserve) + + property bool dragging: false + property real dragX: 0 + property real dragY: 0 + property point pressWindow: Qt.point(0, 0) + property point pressBubble: Qt.point(0, 0) + readonly property real bubbleX: dragging ? dragX : resting.x + readonly property real bubbleY: dragging ? dragY : resting.y + readonly property var dockPlace: AssistantHelpers.dockPlacement( + { x: root.bubbleX, y: root.bubbleY }, root.size, + { width: root.width, height: root.height }, Theme.scaled(360), root.topReserve) + + // One surface at a time; the working pill wins, then the reply. + readonly property bool showWorking: Assistant.busy && !Assistant.tucked + readonly property bool showReply: Assistant.replyOpen && !Assistant.composing && !Assistant.busy + readonly property bool showComposer: Assistant.composing && !Assistant.busy + readonly property bool dockShown: (showComposer || showWorking || showReply) + && !dragging && !Assistant.capturing + + // The keyboard goes to the box only when it was asked for. Exclusive + // first, so the compositor hands it over without a click; then OnDemand + // under a focus grab, so a click anywhere else takes it back (the + // launcher's arrangement, LauncherWindow.qml). + readonly property bool keyboardWanted: Assistant.focusRequested + && (showComposer || (showWorking && Assistant.waiting)) + readonly property bool keyboardActive: root.contentItem.Window.active + property bool grabReady: false + onKeyboardActiveChanged: { + if (keyboardActive && keyboardWanted) + grabReady = true; + } + onKeyboardWantedChanged: { + if (!keyboardWanted) + grabReady = false; + else if (keyboardActive) + grabReady = true; + } + + screen: Assistant.screen + // Not root.screen: the window writes that back as it maps, which would + // loop through this binding. + visible: Assistant.shown && Assistant.screen !== null + anchors { + top: true + bottom: true + left: true + right: true + } + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "qs-assistant" + WlrLayershell.keyboardFocus: !root.dockShown ? WlrKeyboardFocus.None + : root.keyboardWanted && !grab.active ? WlrKeyboardFocus.Exclusive + : WlrKeyboardFocus.OnDemand + + mask: Region { + item: character + + Region { + item: dockMask + } + } + + HyprlandFocusGrab { + id: grab + active: root.keyboardWanted && root.grabReady && root.visible + windows: [root] + onCleared: { + root.grabReady = false; + Assistant.focusRequested = false; + Assistant.hoverEnded(); + } + } + + // ---- hover --------------------------------------------------------------- + readonly property bool pointerInside: characterHover.hovered || dockHover.hovered + + // A pass on the way somewhere else does not open the box. + Timer { + id: hoverDwell + interval: 260 + onTriggered: { + if (characterHover.hovered && !Assistant.busy && !Assistant.replyOpen + && !Assistant.composing && !root.dragging) + Assistant.openComposer(false); + } + } + + Timer { + id: leaveGrace + interval: 700 + onTriggered: { + if (!root.pointerInside) + Assistant.hoverEnded(); + } + } + + onPointerInsideChanged: { + if (pointerInside) + leaveGrace.stop(); + else + leaveGrace.restart(); + } + + Item { + id: stage + anchors.fill: parent + // Hidden for the screenshot it is about to take. + opacity: Assistant.capturing ? 0 : 1 + + // ---- the character ------------------------------------------------------ + AssistantCharacter { + id: character + x: root.bubbleX + y: root.bubbleY + width: root.size + height: root.size + mood: Assistant.mood + mascot: Settings.assistantCharacter !== "mark" + level: composer.level + hovered: characterHover.hovered + unread: Assistant.unread + boxAbove: root.dockPlace.above + scale: dragArea.pressed && !root.dragging ? 0.94 : root.dragging ? 1.06 : 1 + rotation: root.dragging && !Theme.reducedMotion + ? AssistantHelpers.clamp((root.dragX - root.pressBubble.x) / 40, -8, 8) : 0 + Accessible.role: Accessible.Button + Accessible.name: I18n.tr("NerdOS Assistant") + Accessible.description: Assistant.busy ? I18n.tr("Working. Click to tuck the box away.") + : I18n.tr("Click to ask a question. Drag to move.") + + Behavior on x { + enabled: !root.dragging && !Theme.reducedMotion + NumberAnimation { duration: Theme.expandDuration; easing.type: Easing.OutCubic } + } + Behavior on y { + enabled: !root.dragging && !Theme.reducedMotion + NumberAnimation { duration: Theme.expandDuration; easing.type: Easing.OutCubic } + } + Behavior on scale { + enabled: !Theme.reducedMotion + NumberAnimation { + duration: Theme.pressDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.springCurve + } + } + + HoverHandler { + id: characterHover + onHoveredChanged: { + if (hovered) + hoverDwell.restart(); + else + hoverDwell.stop(); + } + onPointChanged: { + character.lookX = AssistantHelpers.clamp( + (point.position.x - character.width / 2) / (character.width / 2), -1, 1); + character.lookY = AssistantHelpers.clamp( + (point.position.y - character.height / 2) / (character.height / 2), -1, 1); + } + } + + } + + // Keep pointer coordinates on the stationary output, independent of + // the character's movement, press scale and playful drag rotation. + // Moving the MouseArea with the character feeds those transforms back + // into its local mouse coordinates and can reduce a drag to a wobble. + MouseArea { + id: dragArea + anchors.fill: parent + preventStealing: true + cursorShape: root.dragging ? Qt.ClosedHandCursor : Qt.PointingHandCursor + + onPressed: mouse => { + const local = character.mapFromItem(dragArea, mouse.x, mouse.y); + if (!character.contains(local)) { + mouse.accepted = false; + return; + } + root.pressWindow = Qt.point(mouse.x, mouse.y); + root.pressBubble = Qt.point(character.x, character.y); + root.lastPointer = root.pressWindow; + } + onPositionChanged: mouse => { + if (!pressed) + return; + // In window coordinates; the press keeps the pointer, so + // positions beyond this output still arrive here. + const dx = mouse.x - root.pressWindow.x; + const dy = mouse.y - root.pressWindow.y; + if (!root.dragging && !AssistantHelpers.dragged(dx, dy)) + return; + if (!root.dragging) { + root.dragX = root.pressBubble.x; + root.dragY = root.pressBubble.y; + root.dragging = true; + Assistant.dragging = true; + hoverDwell.stop(); + } + root.dragX = AssistantHelpers.clamp(root.pressBubble.x + dx, 0, root.width - root.size); + root.dragY = AssistantHelpers.clamp(root.pressBubble.y + dy, 0, root.height - root.size); + root.lastPointer = Qt.point(mouse.x, mouse.y); + } + onReleased: { + if (root.dragging) + root.finishDrag(); + else + Assistant.characterClicked(); + } + onCanceled: { + if (root.dragging) + root.finishDrag(); + } + } + + // ---- the box ---------------------------------------------------------- + Item { + id: dock + + readonly property Item current: root.showWorking ? working + : root.showReply ? replyCard : root.showComposer ? composer : null + + x: root.dockPlace.x + width: root.dockPlace.width + height: current ? current.height : 0 + y: root.dockPlace.above ? root.dockPlace.anchorY - height : root.dockPlace.anchorY + opacity: root.dockShown ? 1 : 0 + visible: opacity > 0.01 + + Behavior on opacity { + NumberAnimation { duration: Theme.popoutFadeInDuration } + } + + HoverHandler { + id: dockHover + } + + AssistantComposer { + id: composer + width: parent.width + visible: root.showComposer + } + + AssistantWorking { + id: working + width: parent.width + visible: root.showWorking + } + + AssistantReply { + id: replyCard + width: parent.width + visible: root.showReply + room: root.dockPlace.room + } + } + + // What the input mask admits of the box: nothing while it is hidden. + Item { + id: dockMask + x: dock.x + y: dock.y + width: root.dockShown ? dock.width : 0 + height: root.dockShown ? dock.height : 0 + } + } + + property point lastPointer: Qt.point(0, 0) + + // A drop on this output stays here; one past its edge lands on the + // display under the pointer, at the same spot under the pointer. + function finishDrag() { + const outputs = Quickshell.screens.map(s => ({ x: s.x, y: s.y, width: s.width, height: s.height })); + const globalX = root.screen.x + root.lastPointer.x; + const globalY = root.screen.y + root.lastPointer.y; + const index = AssistantHelpers.outputAt(outputs, globalX, globalY); + const target = index >= 0 ? Quickshell.screens[index] : root.screen; + if (target !== root.screen) { + const grabX = root.pressWindow.x - root.pressBubble.x; + const grabY = root.pressWindow.y - root.pressBubble.y; + Assistant.moveTo(target.name, + AssistantHelpers.clamp(globalX - target.x - grabX, 0, target.width - root.size), + AssistantHelpers.clamp(globalY - target.y - grabY, 0, target.height - root.size)); + } else { + Assistant.moveTo(root.screen.name, root.dragX, root.dragY); + } + root.dragging = false; + Assistant.dragging = false; + } +} diff --git a/roles/desktop/files/quickshell/Bar/Bar.qml b/desktop/quickshell/Bar/Bar.qml similarity index 99% rename from roles/desktop/files/quickshell/Bar/Bar.qml rename to desktop/quickshell/Bar/Bar.qml index e425df0e..09d5e4f9 100644 --- a/roles/desktop/files/quickshell/Bar/Bar.qml +++ b/desktop/quickshell/Bar/Bar.qml @@ -241,6 +241,7 @@ PanelWindow { case "updates": return Updates.total > 0 || Updates.error !== "" || Updates.runState !== "idle" || Updates.rebootRecommended; case "tray": return SystemTray.items.values.length > 0; + case "assistant": return Settings.assistantConfigured; default: return true; } } @@ -310,7 +311,7 @@ PanelWindow { // ---- rearranging widgets in place ----------------------------------- // Dragging a widget along the bar is the same edit as dragging its row in - // CybexOS Settings, and both commit through LayoutHelpers.moveWidget. Only + // NerdOS Settings, and both commit through LayoutHelpers.moveWidget. Only // the measurement differs: the settings list has one fixed row pitch to // divide by, while the bar has to ask its live slots where they actually // came to rest — every widget is a different width, and a group pill puts @@ -671,7 +672,8 @@ PanelWindow { indicators: "Modules/Indicators.qml", t3: "Modules/T3.qml", hermes: "Modules/Hermes.qml", gh: "Modules/GitHub.qml", updates: "Modules/Updates.qml", - tray: "Modules/Tray.qml", notifications: "Modules/Notifications.qml", + tray: "Modules/Tray.qml", assistant: "Modules/Assistant.qml", + notifications: "Modules/Notifications.qml", vol: "Modules/Volume.qml", wifi: "Modules/Wifi.qml", batt: "Modules/Battery.qml", bt: "Modules/Bluetooth.qml", control: "Modules/Control.qml" @@ -820,7 +822,7 @@ PanelWindow { // ---- the slab ------------------------------------------------------ // One continuous surface. In glass mode the compositor supplies the blur - // behind its tint (see roles/desktop/templates/looknfeel.lua.j2); solid mode uses + // behind its tint (see desktop/hypr/looknfeel.lua); solid mode uses // the same selected menubar color at full opacity. Item { id: slabLayer @@ -881,7 +883,7 @@ PanelWindow { fillColor: Theme.barSurface } - // Right-click anywhere on the slab opens CybexOS Settings. Module mouse + // Right-click anywhere on the slab opens NerdOS Settings. Module mouse // areas only accept the left button, so right-clicks fall through the // layout layer to this area. MouseArea { diff --git a/roles/desktop/files/quickshell/Bar/BarBrandIcon.qml b/desktop/quickshell/Bar/BarBrandIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarBrandIcon.qml rename to desktop/quickshell/Bar/BarBrandIcon.qml diff --git a/roles/desktop/files/quickshell/Bar/BarChip.qml b/desktop/quickshell/Bar/BarChip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarChip.qml rename to desktop/quickshell/Bar/BarChip.qml diff --git a/roles/desktop/files/quickshell/Bar/BarHover.qml b/desktop/quickshell/Bar/BarHover.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarHover.qml rename to desktop/quickshell/Bar/BarHover.qml diff --git a/roles/desktop/files/quickshell/Bar/BarIcon.qml b/desktop/quickshell/Bar/BarIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarIcon.qml rename to desktop/quickshell/Bar/BarIcon.qml diff --git a/roles/desktop/files/quickshell/Bar/BarPopoutWindow.qml b/desktop/quickshell/Bar/BarPopoutWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarPopoutWindow.qml rename to desktop/quickshell/Bar/BarPopoutWindow.qml diff --git a/roles/desktop/files/quickshell/Bar/BarTooltip.qml b/desktop/quickshell/Bar/BarTooltip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarTooltip.qml rename to desktop/quickshell/Bar/BarTooltip.qml diff --git a/roles/desktop/files/quickshell/Bar/Cluster.qml b/desktop/quickshell/Bar/Cluster.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Cluster.qml rename to desktop/quickshell/Bar/Cluster.qml diff --git a/roles/desktop/files/quickshell/Bar/Divider.qml b/desktop/quickshell/Bar/Divider.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Divider.qml rename to desktop/quickshell/Bar/Divider.qml diff --git a/roles/desktop/files/quickshell/Bar/HermesChip.qml b/desktop/quickshell/Bar/HermesChip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/HermesChip.qml rename to desktop/quickshell/Bar/HermesChip.qml diff --git a/roles/desktop/files/quickshell/Bar/ModuleSlot.qml b/desktop/quickshell/Bar/ModuleSlot.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/ModuleSlot.qml rename to desktop/quickshell/Bar/ModuleSlot.qml diff --git a/desktop/quickshell/Bar/Modules/Assistant.qml b/desktop/quickshell/Bar/Modules/Assistant.qml new file mode 100644 index 00000000..eca6bdae --- /dev/null +++ b/desktop/quickshell/Bar/Modules/Assistant.qml @@ -0,0 +1,34 @@ +import QtQuick +import ".." +import "../../Common" + +// Shows and hides the NerdOS Assistant on this bar's display. Lit while the +// character is out; a dot while it works or holds an unread reply, which is +// how a turn left running with the character hidden is still visible. +BarModule { + id: root + + moduleId: "assistant" + + BarIcon { + id: icon + host: root.host + isle: root.isle + anchorItem: root.groupAnchor ?? icon + glyph: "assistant" + glyphWidth: Theme.barIconSize + idleColor: Settings.assistantShown ? Theme.barAccent : Theme.barIcon + label: Assistant.waiting ? "!" : Assistant.busy ? "…" : Assistant.unread ? "•" : "" + labelColor: Assistant.waiting ? Theme.barAmber : Theme.barAccent + compact: root.compact + tooltip: Assistant.waiting ? I18n.tr("The assistant needs your answer") + : Assistant.busy ? I18n.tr("The assistant is working") + : Assistant.unread ? I18n.tr("The assistant has replied") + : Settings.assistantShown ? I18n.tr("Hide the assistant") + : I18n.tr("Show the assistant") + tooltipAlign: 1 + Accessible.role: Accessible.Button + Accessible.name: I18n.tr("NerdOS Assistant") + onClicked: Assistant.toggleShown(root.host ? root.host.outputName : "") + } +} diff --git a/roles/desktop/files/quickshell/Bar/Modules/BarModule.qml b/desktop/quickshell/Bar/Modules/BarModule.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/BarModule.qml rename to desktop/quickshell/Bar/Modules/BarModule.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Battery.qml b/desktop/quickshell/Bar/Modules/Battery.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Battery.qml rename to desktop/quickshell/Bar/Modules/Battery.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Bluetooth.qml b/desktop/quickshell/Bar/Modules/Bluetooth.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Bluetooth.qml rename to desktop/quickshell/Bar/Modules/Bluetooth.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Clock.qml b/desktop/quickshell/Bar/Modules/Clock.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Clock.qml rename to desktop/quickshell/Bar/Modules/Clock.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Control.qml b/desktop/quickshell/Bar/Modules/Control.qml similarity index 96% rename from roles/desktop/files/quickshell/Bar/Modules/Control.qml rename to desktop/quickshell/Bar/Modules/Control.qml index 0fd6b90e..cc21f9f9 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/Control.qml +++ b/desktop/quickshell/Bar/Modules/Control.qml @@ -21,7 +21,7 @@ BarModule { anchors.verticalCenter: parent.verticalCenter width: Theme.barIconSize height: Theme.barIconSize - name: "fedora" + name: "nerdos" highlighted: controlButton.held || controlButton.hovered } } diff --git a/roles/desktop/files/quickshell/Bar/Modules/Fusebox.qml b/desktop/quickshell/Bar/Modules/Fusebox.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Fusebox.qml rename to desktop/quickshell/Bar/Modules/Fusebox.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/GitHub.qml b/desktop/quickshell/Bar/Modules/GitHub.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/GitHub.qml rename to desktop/quickshell/Bar/Modules/GitHub.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Hermes.qml b/desktop/quickshell/Bar/Modules/Hermes.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Hermes.qml rename to desktop/quickshell/Bar/Modules/Hermes.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Indicators.qml b/desktop/quickshell/Bar/Modules/Indicators.qml similarity index 99% rename from roles/desktop/files/quickshell/Bar/Modules/Indicators.qml rename to desktop/quickshell/Bar/Modules/Indicators.qml index 972e4372..f6cc3a0b 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/Indicators.qml +++ b/desktop/quickshell/Bar/Modules/Indicators.qml @@ -14,7 +14,7 @@ BarModule { moduleId: "indicators" readonly property string panelName: "reminders" readonly property string ocrScript: - Quickshell.env("HOME") + "/.local/bin/screen-ocr" + "screen-ocr" spacing: 2 property bool disclosureLatched: false property var actionButtons: [] diff --git a/roles/desktop/files/quickshell/Bar/Modules/Media.qml b/desktop/quickshell/Bar/Modules/Media.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Media.qml rename to desktop/quickshell/Bar/Modules/Media.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/ModelUsage.qml b/desktop/quickshell/Bar/Modules/ModelUsage.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/ModelUsage.qml rename to desktop/quickshell/Bar/Modules/ModelUsage.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Notes.qml b/desktop/quickshell/Bar/Modules/Notes.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Notes.qml rename to desktop/quickshell/Bar/Modules/Notes.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Notifications.qml b/desktop/quickshell/Bar/Modules/Notifications.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Notifications.qml rename to desktop/quickshell/Bar/Modules/Notifications.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Remote.qml b/desktop/quickshell/Bar/Modules/Remote.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Remote.qml rename to desktop/quickshell/Bar/Modules/Remote.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/T3.qml b/desktop/quickshell/Bar/Modules/T3.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/T3.qml rename to desktop/quickshell/Bar/Modules/T3.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Tray.qml b/desktop/quickshell/Bar/Modules/Tray.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Tray.qml rename to desktop/quickshell/Bar/Modules/Tray.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Updates.qml b/desktop/quickshell/Bar/Modules/Updates.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Updates.qml rename to desktop/quickshell/Bar/Modules/Updates.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Volume.qml b/desktop/quickshell/Bar/Modules/Volume.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Volume.qml rename to desktop/quickshell/Bar/Modules/Volume.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Weather.qml b/desktop/quickshell/Bar/Modules/Weather.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Weather.qml rename to desktop/quickshell/Bar/Modules/Weather.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Wifi.qml b/desktop/quickshell/Bar/Modules/Wifi.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Wifi.qml rename to desktop/quickshell/Bar/Modules/Wifi.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Workspaces.qml b/desktop/quickshell/Bar/Modules/Workspaces.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Workspaces.qml rename to desktop/quickshell/Bar/Modules/Workspaces.qml diff --git a/desktop/quickshell/Bar/Modules/qmldir b/desktop/quickshell/Bar/Modules/qmldir new file mode 100644 index 00000000..5942945b --- /dev/null +++ b/desktop/quickshell/Bar/Modules/qmldir @@ -0,0 +1,25 @@ +# Sibling-type resolution inside Bar/Modules/ relies on this explicit index — +# a directory carrying a qmldir is no longer implicitly scanned, so every +# Bar/Modules/*.qml type must be listed. qmldir.test.cjs enforces that. +BarModule BarModule.qml +Assistant Assistant.qml +Battery Battery.qml +Bluetooth Bluetooth.qml +Clock Clock.qml +Control Control.qml +Fusebox Fusebox.qml +GitHub GitHub.qml +Hermes Hermes.qml +Indicators Indicators.qml +Media Media.qml +ModelUsage ModelUsage.qml +Notes Notes.qml +Notifications Notifications.qml +T3 T3.qml +Tray Tray.qml +Updates Updates.qml +Volume Volume.qml +Weather Weather.qml +Wifi Wifi.qml +Workspaces Workspaces.qml +Remote Remote.qml diff --git a/roles/desktop/files/quickshell/Bar/PointerCheck.qml b/desktop/quickshell/Bar/PointerCheck.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/PointerCheck.qml rename to desktop/quickshell/Bar/PointerCheck.qml diff --git a/roles/desktop/files/quickshell/Bar/PopoutHost.qml b/desktop/quickshell/Bar/PopoutHost.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/PopoutHost.qml rename to desktop/quickshell/Bar/PopoutHost.qml diff --git a/roles/desktop/files/quickshell/Bar/T3Chip.qml b/desktop/quickshell/Bar/T3Chip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/T3Chip.qml rename to desktop/quickshell/Bar/T3Chip.qml diff --git a/roles/desktop/files/quickshell/Bar/UserWidgets.qml b/desktop/quickshell/Bar/UserWidgets.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/UserWidgets.qml rename to desktop/quickshell/Bar/UserWidgets.qml diff --git a/roles/desktop/files/quickshell/Bar/Workspaces.qml b/desktop/quickshell/Bar/Workspaces.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Workspaces.qml rename to desktop/quickshell/Bar/Workspaces.qml diff --git a/roles/desktop/files/quickshell/Bar/qmldir b/desktop/quickshell/Bar/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Bar/qmldir rename to desktop/quickshell/Bar/qmldir diff --git a/roles/desktop/files/quickshell/Common/Activity.qml b/desktop/quickshell/Common/Activity.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Activity.qml rename to desktop/quickshell/Common/Activity.qml diff --git a/roles/desktop/files/quickshell/Common/AnimatedText.qml b/desktop/quickshell/Common/AnimatedText.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/AnimatedText.qml rename to desktop/quickshell/Common/AnimatedText.qml diff --git a/desktop/quickshell/Common/Assistant.qml b/desktop/quickshell/Common/Assistant.qml new file mode 100644 index 00000000..55ec2236 --- /dev/null +++ b/desktop/quickshell/Common/Assistant.qml @@ -0,0 +1,873 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import "AssistantHelpers.js" as AssistantHelpers + +// The shell's side of the NerdOS Assistant (docs/assistant.md). The +// nerdos-assistant user service owns the agent processes and every turn, so +// a reload of this shell never ends one; this singleton is its client. It +// owns what only the desktop can know: where the character stands, whether +// its box is open, push-to-talk through Voxtype, and the one screenshot a +// question may carry. +Singleton { + id: root + + // ---- availability ------------------------------------------------------ + readonly property bool available: Settings.assistantConfigured + readonly property string runtimeDir: + (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/nerdos" + readonly property string socketPath: runtimeDir + "/assistant.sock" + readonly property string scratchDir: runtimeDir + "/assistant" + readonly property string home: Quickshell.env("HOME") || "" + + // ---- service state ------------------------------------------------------- + readonly property bool connected: socket.connected && helloed + property bool helloed: false + property var agents: ({}) + property bool speechAvailable: false + property var turn: null + // Whether the service holds a conversation the next question continues. + property var conversation: null + // The `nerd agent` default, which "auto" follows. + property string preferredAgent: "" + + readonly property string agent: AssistantHelpers.resolveAgent( + Settings.assistantAgent, agents, preferredAgent) + readonly property string agentLabel: AssistantHelpers.AGENT_LABELS[agent] || agent + readonly property bool agentInstalled: !!(agents[agent] && agents[agent].available) + readonly property bool agentReady: agentInstalled && agents[agent].auth === "ready" + readonly property bool setupRequired: !agentReady + property bool authChecking: false + property string signingIn: "" + + readonly property bool busy: AssistantHelpers.active(turn) + readonly property bool waiting: turn !== null && turn.status === "waiting" + readonly property var approvals: turn && turn.approvals ? turn.approvals : [] + readonly property bool hasReply: AssistantHelpers.finished(turn) + + // ---- presence and placement -------------------------------------------- + // The character is out unless game mode wants the screen to itself. + readonly property bool shown: available && Settings.assistantShown && !GameMode.active + readonly property ShellScreen screen: Screens.byName(Settings.assistantScreen) ?? Screens.focused + readonly property string screenName: screen ? screen.name : "" + + // ---- box state ----------------------------------------------------------- + property bool composing: false + // The box was opened by the keyboard or a click, so it takes the keyboard. + property bool focusRequested: false + property string draft: "" + // Clicking the character while it works tucks the box away. + property bool tucked: false + property bool replyOpen: false + property bool unread: false + property bool celebrating: false + property string notice: "" + property bool dragging: false + readonly property bool failed: turn !== null && turn.status === "failed" && replyOpen + + readonly property string mood: AssistantHelpers.mood({ + connected: root.connected, listening: root.listening, waiting: root.waiting, + busy: root.busy, transcribing: root.transcribing, dragging: root.dragging, + speaking: root.speaking, celebrating: root.celebrating, failed: root.failed, + composing: root.composing + }) + + // ---- voice --------------------------------------------------------------- + // Voxtype runs on every installation; Dictation watches its state for us. + readonly property bool voiceAvailable: available && !Dictation.stateMissing + property bool listening: false + property bool transcribing: false + // A tap on the microphone latches it; a held key or press does not. + property bool voiceLatched: false + property bool sendAfterTranscript: false + property double listenStartedAt: 0 + readonly property string transcriptPath: scratchDir + "/voice.txt" + + // ---- screen context ------------------------------------------------------ + property bool capturing: false + // A suggestion about the screen waits for its screenshot. + property string pendingScreenQuestion: "" + property bool screenAttached: false + property string screenPath: "" + property string screenOutput: "" + + // ---- speech ---------------------------------------------------------------- + property bool speaking: false + property string speechId: "" + property int speechIndex: 0 + property int speechCount: 0 + + // ---- Super+I -------------------------------------------------------------- + property bool shortcutHolding: false + + signal focusComposer() + + // ---- presence ------------------------------------------------------------ + function show(outputName) { + if (!available) + return; + if (outputName && outputName !== Settings.assistantScreen && Screens.byName(outputName)) + moveTo(outputName, Settings.assistantX, Settings.assistantY); + else if (Settings.assistantScreen === "" && screen) + Settings.set("assistantScreen", screen.name); + Settings.set("assistantShown", true); + // Brought back for a reply that finished while it was away. + if (unread && hasReply && !busy) { + unread = false; + replyOpen = true; + } + } + + function hide() { + if (listening) + cancelListening(); + closeBox(); + Settings.set("assistantShown", false); + } + + function toggleShown(outputName) { + if (Settings.assistantShown) + hide(); + else + show(outputName); + } + + function moveTo(outputName, x, y) { + if (outputName !== Settings.assistantScreen) + Settings.set("assistantScreen", outputName); + if (x !== Settings.assistantX) + Settings.set("assistantX", Math.round(x)); + if (y !== Settings.assistantY) + Settings.set("assistantY", Math.round(y)); + } + + // ---- the box ------------------------------------------------------------- + function openComposer(withFocus) { + if (!available) + return; + if (!Settings.assistantShown) + show(Screens.focused ? Screens.focused.name : ""); + tucked = false; + if (busy) + return; + composing = true; + if (withFocus) { + focusRequested = true; + focusComposer(); + } + } + + function toggleComposer() { + if (composing && focusRequested && !listening) + closeComposer(); + else if (busy && Settings.assistantShown) + tucked = !tucked; + else + openComposer(true); + } + + function closeComposer() { + composing = false; + focusRequested = false; + notice = ""; + } + + // The empty box a hover opened closes again once the pointer leaves. + function hoverEnded() { + if (composing && !focusRequested && draft.trim() === "" && !listening + && !transcribing && !screenAttached && !capturing) + closeComposer(); + } + + function closeReply() { + replyOpen = false; + unread = false; + stopSpeaking(); + } + + function closeBox() { + closeComposer(); + closeReply(); + } + + function characterClicked() { + if (unread && hasReply) { + unread = false; + closeComposer(); + replyOpen = true; + return; + } + unread = false; + if (busy) { + tucked = !tucked; + return; + } + if (composing && focusRequested && draft.trim() === "") + closeComposer(); + else + openComposer(true); + } + + function reply() { + closeReply(); + openComposer(true); + } + + // ---- Super+I: tap to type, hold to talk ------------------------------------ + function shortcutPressed() { + if (!available) + return; + if (listening) { + stopListening(true); + return; + } + shortcutHolding = true; + holdTimer.restart(); + } + + function shortcutReleased() { + if (holdTimer.running) { + holdTimer.stop(); + shortcutHolding = false; + toggleComposer(); + return; + } + if (shortcutHolding && listening && !voiceLatched) + stopListening(true); + shortcutHolding = false; + } + + Timer { + id: holdTimer + interval: AssistantHelpers.HOLD_MS + onTriggered: { + if (root.voiceAvailable && !root.busy) { + root.openComposer(false); + root.startListening(false); + } else { + root.shortcutHolding = false; + root.toggleComposer(); + } + } + } + + // ---- asking ---------------------------------------------------------------- + function send(text) { + const prompt = String(text === undefined ? draft : text).trim(); + if (prompt === "" || busy) + return; + if (!connected) { + notice = I18n.tr("The assistant service is not running."); + return; + } + if (!agentReady) { + draft = prompt; + openComposer(true); + refreshAuth(); + return; + } + stopSpeaking(); + const params = { + text: prompt, + agent: root.agent, + model: AssistantHelpers.modelFor(root.agent, Settings.assistantClaudeModel, + Settings.assistantCodexModel), + screenshot: screenAttached ? screenPath : "", + context: { output: screenName, speak: Settings.assistantSpeak, language: I18n.language } + }; + // The service owns the screenshot from here and deletes it. + const shot = screenAttached ? screenPath : ""; + screenAttached = false; + screenPath = ""; + draft = ""; + notice = ""; + composing = false; + focusRequested = false; + replyOpen = false; + tucked = false; + unread = false; + request("turn.start", params, (result, error) => { + if (error) { + // Refused before the service took the screenshot: remove it + // here, so it does not outlive the question it was for. + if (shot !== "") + Quickshell.execDetached(["rm", "-f", "--", shot]); + root.draft = prompt; + root.notice = error.message || I18n.tr("The assistant could not start."); + root.composing = true; + if (error.code === -32004) + root.refreshAuth(); + return; + } + root.adopt(result.turn); + }); + } + + function stop() { + if (busy) + request("turn.stop", {}); + } + + function respond(approvalId, decision) { + request("approval.respond", { id: approvalId, decision: decision }); + } + + function undo() { + if (!turn || !turn.undo || turn.undo.length === 0) + return; + const id = turn.id; + request("turn.undo", { id: id }, (result, error) => { + if (error) { + root.notice = error.message; + return; + } + const restored = result.restored || []; + const skipped = result.skipped || []; + if (root.turn && root.turn.id === id) + root.turn = Object.assign({}, root.turn, { undo: [] }); + root.notice = skipped.length > 0 + ? I18n.format("Restored %1; %2 changed again since, so it was left alone.", + [restored.length, skipped.length]) + : I18n.tr("Undone."); + }); + } + + function newConversation() { + request("conversation.reset", {}, () => { + root.conversation = null; + }); + closeReply(); + notice = ""; + } + + // Continues the same Claude Code or Codex session in Kitty, for work that + // outgrows a box under a character. + function handoff() { + request("conversation.handoff", {}, (result, error) => { + if (error || !result || !Array.isArray(result.argv)) { + root.notice = error ? error.message : I18n.tr("Nothing to continue yet."); + return; + } + Quickshell.execDetached(["nerdos-apps", "terminal", "--app-id=nerdos-agent", + "--dir=" + (result.cwd || root.home), "--"].concat(result.argv)); + }); + } + + function copyReply() { + Quickshell.clipboardText = AssistantHelpers.copyText(turn); + notice = I18n.tr("Copied."); + } + + function addWidget(pluginId) { + Quickshell.execDetached(["nerd", "plugin", "enable", pluginId]); + notice = I18n.tr("Added to the bar."); + } + + function fix(kind) { + const action = AssistantHelpers.errorAction(kind, turn ? turn.agent : agent); + if (!action) + return; + if (action.auth) { + openComposer(true); + signIn(turn ? turn.agent : agent); + } else if (action.command) + Quickshell.execDetached(["nerdos-apps", "terminal", "--hold", "--"].concat(action.command)); + else if (action.unit) + Quickshell.execDetached(["systemctl", "--user", "start", action.unit]); + else if (action.page) + Settings.showPanel(action.page, screenName); + } + + // Sign-in stays with the agent. The service only supplies its fixed login + // command and checks its status; no credential is sent through this socket. + function signIn(agentId) { + if (!connected || authChecking || busy) + return; + Settings.set("assistantAgent", agentId); + request("auth.login", { agent: agentId }, (result, error) => { + if (error || !result || !Array.isArray(result.argv)) { + root.notice = error ? error.message : I18n.tr("Sign-in could not start."); + return; + } + root.signingIn = agentId; + root.focusRequested = false; + Quickshell.execDetached(["nerdos-apps", "terminal", "--hold", + "--dir=" + result.cwd, "--"].concat(result.argv)); + authPoll.restart(); + authDeadline.restart(); + }); + } + + function refreshAuth() { + if (!connected || authChecking) + return; + authChecking = true; + request("auth.status", {}, (result, error) => { + root.authChecking = false; + if (result) + root.adoptAgents(result.agents || {}); + else if (error) + root.notice = error.message; + }); + } + + function adoptAgents(next) { + agents = next; + if (signingIn !== "" && next[signingIn] && next[signingIn].auth === "ready") { + signingIn = ""; + authPoll.stop(); + authDeadline.stop(); + notice = I18n.tr("Signed in. Your question is ready to send."); + } + } + + Timer { + id: authPoll + interval: 3000 + repeat: true + onTriggered: root.refreshAuth() + } + + Timer { + id: authDeadline + interval: 300000 + onTriggered: { + authPoll.stop(); + root.signingIn = ""; + } + } + + // ---- voice ----------------------------------------------------------------- + function startListening(latched) { + if (!agentReady) { + openComposer(true); + refreshAuth(); + return; + } + if (!voiceAvailable || listening || transcribing || busy) + return; + listening = true; + voiceLatched = latched === true; + listenStartedAt = Date.now(); + notice = ""; + const options = Settings.modOpts.indicators; + // Removing the previous transcript first is what makes the next read + // this recording's words. + Quickshell.execDetached(["sh", "-c", + 'mkdir -p -m 0700 "$1" && rm -f -- "$2" && exec voxtype --model "$3" --language "$4" record start --file="$2"', + "sh", scratchDir, transcriptPath, options.dictationModel, + options.dictationPrimaryLanguage]); + Dictation.refresh(); + transcriptTimeout.stop(); + } + + function stopListening(sendWhenDone) { + if (!listening) + return; + listening = false; + voiceLatched = false; + transcribing = true; + sendAfterTranscript = sendWhenDone === true; + Quickshell.execDetached(["voxtype", "record", "stop"]); + Dictation.refresh(); + transcriptTimeout.restart(); + transcriptCheck.restart(); + } + + function cancelListening() { + if (!listening && !transcribing) + return; + listening = false; + transcribing = false; + voiceLatched = false; + transcriptTimeout.stop(); + Quickshell.execDetached(["voxtype", "record", "cancel"]); + } + + function toggleMicrophone() { + if (listening) + stopListening(false); + else + startListening(true); + } + + // Voxtype's state returns to idle once the transcript is on disk. + Connections { + target: Dictation + + function onStateChanged() { + if (root.listening && Dictation.state === "transcribing") { + // Voxtype ended the recording itself, at its length limit: + // the words still arrive, for the person to send. + root.listening = false; + root.voiceLatched = false; + root.transcribing = true; + root.sendAfterTranscript = false; + transcriptTimeout.restart(); + } else if (root.transcribing && Dictation.state === "idle") { + transcript.reload(); + } else if (root.listening && Dictation.state === "idle" + && Date.now() - root.listenStartedAt > 1500) { + // The daemon dropped the recording (a device error, or Voxtype + // restarting); there is nothing to wait for. + root.listening = false; + root.notice = I18n.tr("The microphone stopped. Try again."); + } + } + } + + // A release that came before Voxtype had started recording leaves its + // state idle throughout, so no change announces the end. + Timer { + id: transcriptCheck + interval: 1500 + onTriggered: { + if (root.transcribing && Dictation.state === "idle") + transcript.reload(); + } + } + + FileView { + id: transcript + path: root.transcriptPath + printErrors: false + onLoaded: root.transcribed(text().trim()) + onLoadFailed: root.transcribed("") + } + + function transcribed(words) { + if (!transcribing) + return; + transcribing = false; + transcriptTimeout.stop(); + Quickshell.execDetached(["rm", "-f", "--", transcriptPath]); + if (words === "") { + notice = I18n.tr("Nothing was heard."); + return; + } + draft = draft.trim() === "" ? words : draft.trim() + " " + words; + if (sendAfterTranscript) + send(); + else + openComposer(true); + } + + Timer { + id: transcriptTimeout + interval: 45000 + onTriggered: { + if (root.transcribing) { + root.transcribing = false; + root.notice = I18n.tr("Transcription took too long."); + } + } + } + + // ---- screen context ---------------------------------------------------------- + // One screenshot of the display the character is on, taken with the + // character hidden; it travels with the next question only. + function attachScreen() { + if (!Settings.assistantScreenContext || capturing || screenName === "" || !agentReady) + return; + capturing = true; + captureDelay.restart(); + } + + function askAboutScreen(question) { + if (screenAttached) { + send(question); + return; + } + pendingScreenQuestion = question; + attachScreen(); + } + + function detachScreen() { + if (screenPath !== "") + Quickshell.execDetached(["rm", "-f", "--", screenPath]); + screenAttached = false; + screenPath = ""; + } + + function toggleScreen() { + if (screenAttached) + detachScreen(); + else + attachScreen(); + } + + // Long enough for the compositor to present a frame without the + // character before grim reads the output. + Timer { + id: captureDelay + interval: 160 + onTriggered: { + const path = root.scratchDir + "/screen-" + Date.now() + ".png"; + capture.path = path; + capture.output = root.screenName; + capture.command = ["sh", "-c", 'mkdir -p -m 0700 "$1" && exec grim -o "$2" -l 6 "$3"', + "sh", root.scratchDir, root.screenName, path]; + capture.running = true; + captureTimeout.restart(); + } + } + + // The character stays hidden while capturing, so a capture that hangs + // must not hide it for good. + Timer { + id: captureTimeout + interval: 8000 + onTriggered: { + if (!root.capturing) + return; + capture.running = false; + root.capturing = false; + root.pendingScreenQuestion = ""; + root.notice = I18n.tr("The screen could not be captured."); + } + } + + Process { + id: capture + property string path: "" + property string output: "" + onExited: (code) => { + captureTimeout.stop(); + if (!root.capturing) + return; + root.capturing = false; + if (code === 0) { + if (root.screenPath !== "" && root.screenPath !== capture.path) + Quickshell.execDetached(["rm", "-f", "--", root.screenPath]); + root.screenPath = capture.path; + root.screenOutput = capture.output; + root.screenAttached = true; + if (root.pendingScreenQuestion !== "") + root.send(root.pendingScreenQuestion); + } else { + root.notice = I18n.tr("The screen could not be captured."); + } + root.pendingScreenQuestion = ""; + } + } + + // ---- speech -------------------------------------------------------------- + function speak() { + if (!turn || !turn.text || !connected) + return; + request("speech.say", { text: turn.text, language: I18n.language }, (result, error) => { + if (error || !result) { + root.notice = error ? error.message : ""; + return; + } + root.speechId = result.id; + root.speechIndex = 0; + root.speechCount = (result.sentences || []).length; + root.speaking = root.speechCount > 0; + }); + } + + function stopSpeaking() { + if (!speaking) + return; + speaking = false; + request("speech.stop", {}); + } + + // ---- service --------------------------------------------------------------- + property int nextId: 1 + property var pending: ({}) + + function request(method, params, callback) { + if (!socket.connected) { + if (callback) + callback(null, { code: -32000, message: I18n.tr("The assistant service is not running.") }); + return; + } + const id = nextId++; + if (callback) + pending[id] = { callback: callback, at: Date.now() }; + socket.write(JSON.stringify({ jsonrpc: "2.0", id: id, method: method, params: params || {} }) + "\n"); + socket.flush(); + } + + function receive(line) { + let message; + try { + message = JSON.parse(line); + } catch (e) { + console.warn("assistant: unreadable message from the service"); + return; + } + if (message.id !== undefined && message.method === undefined) { + const entry = pending[message.id]; + delete pending[message.id]; + if (entry) + entry.callback(message.result ?? null, message.error ?? null); + return; + } + const params = message.params || {}; + switch (message.method) { + case "agents.updated": + adoptAgents(params.agents || {}); + break; + case "turn.updated": + adopt(params.turn); + break; + case "turn.delta": + if (turn && params.id === turn.id) + turn = Object.assign({}, turn, { text: (turn.text || "") + (params.text || "") }); + break; + case "speech.progress": + if (params.id === speechId) + speechIndex = params.index; + break; + case "speech.done": + if (params.id === speechId) + speaking = false; + break; + } + } + + // Takes the service's snapshot of the current turn, and reacts to the + // moment it finishes. + function adopt(next) { + const previous = turn; + turn = next || null; + if (!next) + return; + const wasActive = previous !== null && previous.id === next.id && AssistantHelpers.active(previous); + if (next.status === "waiting") { + // A question nobody can see waits forever; bring the character out. + tucked = false; + if (!Settings.assistantShown) + show(Screens.focused ? Screens.focused.name : ""); + } + if (!wasActive || !AssistantHelpers.finished(next)) + return; + conversation = { agent: next.agent, resumable: next.resumable === true }; + if (next.status === "stopped") { + draft = next.prompt || ""; + notice = next.undo && next.undo.length > 0 + ? I18n.tr("Stopped. Undo reverts what it had changed.") + : I18n.tr("Stopped. Nothing was changed."); + replyOpen = (next.undo && next.undo.length > 0) || (next.text || "") !== ""; + composing = !replyOpen; + return; + } + // A box tucked away, or a character hidden, gets the dot instead: the + // reply opens when the character is next clicked. + unread = tucked || !Settings.assistantShown; + replyOpen = !unread; + tucked = false; + if (next.status === "completed") { + celebrating = true; + celebrateTimer.restart(); + if (Settings.assistantSpeak && !unread) + speak(); + } + } + + Timer { + id: celebrateTimer + interval: 2400 + onTriggered: root.celebrating = false + } + + function hello() { + request("hello", { client: "quickshell", protocol: 1 }, (result, error) => { + if (error || !result) { + console.warn("assistant: hello failed:", error ? error.message : "no result"); + return; + } + root.helloed = true; + root.adoptAgents(result.agents || {}); + root.speechAvailable = !!(result.speech && result.speech.available); + root.conversation = result.conversation || null; + // A reconnect adopts the service's view of the turn; nothing is + // replayed. + const previous = root.turn; + root.turn = result.turn || null; + if (root.turn && AssistantHelpers.finished(root.turn) + && (!previous || previous.id !== root.turn.id)) + root.replyOpen = false; + }); + } + + // Recheck on opening: the person may have signed in or out in a terminal. + onComposingChanged: { + if (composing && connected) + refreshAuth(); + } + + Socket { + id: socket + path: root.socketPath + parser: SplitParser { + onRead: line => root.receive(line) + } + onConnectionStateChanged: { + root.helloed = false; + if (connected) { + retry.interval = 1000; + root.hello(); + } else { + // Requests in flight will never be answered. + const waiting = root.pending; + root.pending = {}; + for (const id of Object.keys(waiting)) + waiting[id].callback(null, { code: -32000, + message: I18n.tr("The assistant service is not running.") }); + } + } + } + + // Reconnects with a gentle backoff; the service may start after the shell + // or restart under it. + Timer { + id: retry + interval: 1000 + repeat: true + running: root.available && !socket.connected + triggeredOnStart: true + onTriggered: { + socket.connected = true; + interval = Math.min(15000, interval * 2); + } + } + + // ---- the `nerd agent` default ---------------------------------------------- + FileView { + path: (Quickshell.env("XDG_CONFIG_HOME") || (root.home + "/.config")) + + "/nerdos/defaults/agent" + printErrors: false + watchChanges: true + onFileChanged: reload() + onLoaded: root.preferredAgent = text().trim() + onLoadFailed: root.preferredAgent = "" + } + + Binding { + target: Dictation + property: "assistantWatching" + value: root.available + } + + // The elapsed time in the worker pill. + property double now: Date.now() + Timer { + interval: 1000 + repeat: true + running: root.busy || root.listening + triggeredOnStart: true + onTriggered: root.now = Date.now() + } + + // A transient notice fades on its own. + Timer { + interval: 6000 + running: root.notice !== "" + onTriggered: root.notice = "" + } +} diff --git a/desktop/quickshell/Common/AssistantHelpers.js b/desktop/quickshell/Common/AssistantHelpers.js new file mode 100644 index 00000000..548a017b --- /dev/null +++ b/desktop/quickshell/Common/AssistantHelpers.js @@ -0,0 +1,243 @@ +// Pure rules for the NerdOS Assistant (docs/assistant.md): where the +// character stands, which side of it the box opens on, which agent answers, +// and how a turn snapshot from the service reads on screen. Shared by QML and +// Node tests, so no Qt APIs. + +// Space kept between the character, its box and the edges of the output. +var EDGE = 12; +var GAP = 10; +// The box opens below unless it would get less than this and above has more. +var MIN_BELOW = 300; +// A press that moves less than this is a click, not a drag. +var DRAG_THRESHOLD = 4; +// Super+I held this long is push-to-talk; a shorter press is a tap. +var HOLD_MS = 280; + +var AGENT_IDS = ["claude", "codex"]; +var AGENT_LABELS = { claude: "Claude Code", codex: "Codex" }; +// Brand marks bundled with the shell (BrandIcons.qml). +var AGENT_ICONS = { claude: "claude", codex: "openai" }; + +// The approval card's glyph for each kind of request. +var APPROVAL_GLYPHS = { command: "terminal", edit: "edit", apply: "deployed_code_update", other: "security" }; + +var ACTIVE_STATUSES = ["running", "waiting"]; +var FINISHED_STATUSES = ["completed", "stopped", "failed"]; + +function clamp(value, low, high) { + return Math.max(low, Math.min(high, value)); +} + +// Where the character stands on an output of width × height. A saved place +// of -1 is the default: the top-right corner, below the bar's reserve. A saved +// place from a larger output is pulled back inside this one. +function placement(saved, output, size, topReserve) { + var width = Math.max(size, output.width || 0); + var height = Math.max(size, output.height || 0); + var reserve = Math.max(0, topReserve || 0); + var x = typeof saved.x === "number" && saved.x >= 0 ? saved.x : width - size - EDGE * 2; + var y = typeof saved.y === "number" && saved.y >= 0 ? saved.y : reserve + EDGE * 2; + return { + x: Math.round(clamp(x, 0, width - size)), + y: Math.round(clamp(y, 0, height - size)) + }; +} + +// The box under (or over) the character. It flips above only when below is +// cramped and above is roomier; `room` is the height it may grow to, which +// the reply card scrolls within. +function dockPlacement(bubble, size, output, dockWidth, topReserve) { + var width = output.width || 0; + var height = output.height || 0; + var reserve = Math.max(0, topReserve || 0); + var w = Math.min(dockWidth, Math.max(0, width - EDGE * 2)); + var below = height - (bubble.y + size + GAP) - EDGE; + var aboveRoom = bubble.y - GAP - Math.max(EDGE, reserve); + var above = below < MIN_BELOW && aboveRoom > below; + var x = clamp(bubble.x + size / 2 - w / 2, EDGE, Math.max(EDGE, width - w - EDGE)); + return { + x: Math.round(x), + width: Math.round(w), + above: above, + room: Math.max(0, Math.round(above ? aboveRoom : below)), + // The edge the box grows from: its top when below, its bottom when above. + anchorY: Math.round(above ? bubble.y - GAP : bubble.y + size + GAP) + }; +} + +// The output under a point in global (compositor) coordinates, for a drag +// that ended on another display. Returns -1 when the point is on none. +function outputAt(outputs, x, y) { + for (var i = 0; i < outputs.length; i++) { + var o = outputs[i]; + if (x >= o.x && x < o.x + o.width && y >= o.y && y < o.y + o.height) + return i; + } + return -1; +} + +function dragged(dx, dy) { + return Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD; +} + +// "auto" follows the `nerd agent` default when it is one of ours and +// installed, then Claude Code, then Codex. An explicit choice that is not +// installed is still returned: the service reports it as missing, which +// tells the person what to install. +function resolveAgent(setting, agents, preferred) { + var installed = function(id) { + return !!(agents && agents[id] && agents[id].available); + }; + if (setting === "claude" || setting === "codex") + return setting; + var ready = function(id) { + return installed(id) && agents[id].auth === "ready"; + }; + if (AGENT_IDS.indexOf(preferred) !== -1 && ready(preferred)) + return preferred; + for (var j = 0; j < AGENT_IDS.length; j++) { + if (ready(AGENT_IDS[j])) + return AGENT_IDS[j]; + } + if (AGENT_IDS.indexOf(preferred) !== -1 && installed(preferred)) + return preferred; + for (var i = 0; i < AGENT_IDS.length; i++) { + if (installed(AGENT_IDS[i])) + return AGENT_IDS[i]; + } + return "claude"; +} + +// The agent chip cycles through the installed agents only. +function nextAgent(current, agents) { + var installed = AGENT_IDS.filter(function(id) { + return !!(agents && agents[id] && agents[id].available); + }); + if (installed.length === 0) + return current; + var index = installed.indexOf(current); + return installed[(index + 1) % installed.length]; +} + +function anyAgent(agents) { + return AGENT_IDS.some(function(id) { + return !!(agents && agents[id] && agents[id].available); + }); +} + +// The model sent with a turn; "default" leaves it to the agent's own config. +function modelFor(agent, claudeModel, codexModel) { + var value = agent === "codex" ? codexModel : claudeModel; + return value && value !== "default" ? value : ""; +} + +function active(turn) { + return !!turn && ACTIVE_STATUSES.indexOf(turn.status) !== -1; +} + +function finished(turn) { + return !!turn && FINISHED_STATUSES.indexOf(turn.status) !== -1; +} + +// What the character's face and ring show, in priority order. +function mood(state) { + if (!state.connected) + return "asleep"; + if (state.listening) + return "listening"; + if (state.waiting) + return "alert"; + if (state.busy || state.transcribing) + return "thinking"; + if (state.dragging || state.speaking || state.celebrating) + return "happy"; + if (state.failed) + return "sorry"; + if (state.composing) + return "attentive"; + return "idle"; +} + +// "8 s", "1 min 05 s", "12 min": the worker pill's elapsed time. +function elapsedLabel(ms) { + var seconds = Math.max(0, Math.floor((ms || 0) / 1000)); + if (seconds < 60) + return seconds + " s"; + var minutes = Math.floor(seconds / 60); + if (minutes >= 10) + return minutes + " min"; + var rest = seconds % 60; + return minutes + " min " + (rest < 10 ? "0" : "") + rest + " s"; +} + +// A ~/-relative path reads better in a narrow card than the full home path. +function displayPath(path, home) { + var value = String(path || ""); + if (home && (value === home || value.indexOf(home + "/") === 0)) + return "~" + value.slice(home.length); + return value; +} + +// Closure changes from `nix store diff-closures` as rows for the review card: +// "obsidian: ∅ → 1.9.12, +148.2 MiB" becomes {name, change, kind}. +function closureChanges(text, limit) { + var rows = []; + String(text || "").split("\n").forEach(function(line) { + var match = /^\s*([^:]+):\s*(.+?)\s*$/.exec(line); + if (!match) + return; + var change = match[2]; + var kind = /∅ →/.test(change) ? "added" : /→ ∅/.test(change) ? "removed" : "changed"; + rows.push({ name: match[1].trim(), change: change, kind: kind }); + }); + var max = limit || 8; + return { rows: rows.slice(0, max), more: Math.max(0, rows.length - max) }; +} + +// Turns a service failure into the one action that would fix it. +function errorAction(kind, agent) { + switch (kind) { + case "auth": return { label: "Sign in", auth: true }; + case "missing": return { label: "Settings", page: "assistant" }; + case "service": return { label: "Start the assistant", unit: "nerdos-assistant.service" }; + default: return null; + } +} + +// A finished turn's reply as it is copied: the text, without the trailing +// whitespace Markdown leaves behind. +function copyText(turn) { + return String(turn && turn.text || "").replace(/\s+$/, ""); +} + +var exported = { + EDGE: EDGE, + GAP: GAP, + MIN_BELOW: MIN_BELOW, + DRAG_THRESHOLD: DRAG_THRESHOLD, + HOLD_MS: HOLD_MS, + AGENT_IDS: AGENT_IDS, + AGENT_LABELS: AGENT_LABELS, + AGENT_ICONS: AGENT_ICONS, + APPROVAL_GLYPHS: APPROVAL_GLYPHS, + clamp: clamp, + placement: placement, + dockPlacement: dockPlacement, + outputAt: outputAt, + dragged: dragged, + resolveAgent: resolveAgent, + nextAgent: nextAgent, + anyAgent: anyAgent, + modelFor: modelFor, + active: active, + finished: finished, + mood: mood, + elapsedLabel: elapsedLabel, + displayPath: displayPath, + closureChanges: closureChanges, + errorAction: errorAction, + copyText: copyText +}; + +if (typeof module !== "undefined" && module.exports) + module.exports = exported; diff --git a/roles/desktop/files/quickshell/Common/Audio.qml b/desktop/quickshell/Common/Audio.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Audio.qml rename to desktop/quickshell/Common/Audio.qml diff --git a/roles/desktop/files/quickshell/Common/AudioHelpers.js b/desktop/quickshell/Common/AudioHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/AudioHelpers.js rename to desktop/quickshell/Common/AudioHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Backup.qml b/desktop/quickshell/Common/Backup.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/Backup.qml rename to desktop/quickshell/Common/Backup.qml index 6ea49360..078bda7a 100644 --- a/roles/desktop/files/quickshell/Common/Backup.qml +++ b/desktop/quickshell/Common/Backup.qml @@ -4,7 +4,7 @@ import Quickshell import "BackupHelpers.js" as BackupHelpers // Personal-file backups for Settings → Backup & recovery. The user helper -// cybexos-backup owns everything that matters: the configuration, the +// nerdos-backup owns everything that matters: the configuration, the // keyring password, every restic run, the state file and the notifications. // This reads its `status --json` while the page is on screen and starts // the few actions the page offers. Backups and restores run as transient diff --git a/roles/desktop/files/quickshell/Common/BackupHelpers.js b/desktop/quickshell/Common/BackupHelpers.js similarity index 95% rename from roles/desktop/files/quickshell/Common/BackupHelpers.js rename to desktop/quickshell/Common/BackupHelpers.js index d16ed651..37f27523 100644 --- a/roles/desktop/files/quickshell/Common/BackupHelpers.js +++ b/desktop/quickshell/Common/BackupHelpers.js @@ -1,15 +1,15 @@ // Pure helpers for personal-file backups (no Qt APIs, so Node tests load -// them). The user helper cybexos-backup owns the configuration, the +// them). The user helper nerdos-backup owns the configuration, the // keyring password and every restic run; Settings reads its `status --json` // and starts long runs as transient user units, so closing Settings or // restarting the shell never interrupts a backup or a restore. -var HELPER_NAME = "cybexos-backup"; -// The same shapes the helper accepts (assets/scripts/cybexos-backup). +var HELPER_NAME = "nerdos-backup"; +// The same shapes the helper accepts (assets/scripts/nerdos-backup). var HOST_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.@%-]{0,253}$/; var REMOTE_PATH_PATTERN = /^\/?[A-Za-z0-9_][A-Za-z0-9_. +\/-]{0,250}$/; var SNAPSHOT_PATTERN = /^(latest|[0-9a-f]{8,64})$/; -var DEFAULT_REMOTE_PATH = "cybexos-backup"; +var DEFAULT_REMOTE_PATH = "nerdos-backup"; function text(value) { return typeof value === "string" ? value : ""; @@ -30,7 +30,7 @@ function emptyStatus() { }; } -// A bounded, typed copy of `cybexos-backup status --json`. +// A bounded, typed copy of `nerdos-backup status --json`. function parseStatus(raw) { var data; try { @@ -195,7 +195,7 @@ function validRestorePath(path) { } function helperPath(home) { - return String(home || "") + "/.local/bin/" + HELPER_NAME; + return HELPER_NAME; } // `kind` is "local" (target: a folder) or "sftp" (target: an SSH alias or @@ -225,7 +225,7 @@ function setupCommand(helper, kind, target, path, existing) { // helper's lock refuses a second one. function transient(name, serial, description) { return ["systemd-run", "--user", "--quiet", "--collect", - "--unit=cybexos-backup-" + name + "-" + String(serial).replace(/[^0-9]/g, ""), + "--unit=nerdos-backup-" + name + "-" + String(serial).replace(/[^0-9]/g, ""), "--description=" + description, "--"]; } @@ -245,7 +245,7 @@ function restoreCommand(helper, snapshotId, path, serial) { } // The helper prints {"ok": false, "error": "..."} for --json requests and -// "cybexos-backup: ..." on stderr otherwise. +// "nerdos-backup: ..." on stderr otherwise. function errorText(code, body, stderr) { var data = null; try { @@ -258,7 +258,7 @@ function errorText(code, body, stderr) { var lines = String(stderr || "").split("\n").map(function(line) { return line.trim(); }) .filter(function(line) { return line !== ""; }); for (var i = lines.length - 1; i >= 0; i--) { - var match = /^cybexos-backup: (.+)$/.exec(lines[i]); + var match = /^nerdos-backup: (.+)$/.exec(lines[i]); if (match) return match[1]; } diff --git a/roles/desktop/files/quickshell/Common/BarGeometry.js b/desktop/quickshell/Common/BarGeometry.js similarity index 100% rename from roles/desktop/files/quickshell/Common/BarGeometry.js rename to desktop/quickshell/Common/BarGeometry.js diff --git a/roles/desktop/files/quickshell/Common/BarOverflow.qml b/desktop/quickshell/Common/BarOverflow.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BarOverflow.qml rename to desktop/quickshell/Common/BarOverflow.qml diff --git a/roles/desktop/files/quickshell/Common/Battery.qml b/desktop/quickshell/Common/Battery.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Battery.qml rename to desktop/quickshell/Common/Battery.qml diff --git a/roles/desktop/files/quickshell/Common/BatteryHealth.qml b/desktop/quickshell/Common/BatteryHealth.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BatteryHealth.qml rename to desktop/quickshell/Common/BatteryHealth.qml diff --git a/roles/desktop/files/quickshell/Common/BatteryViewHelpers.js b/desktop/quickshell/Common/BatteryViewHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/BatteryViewHelpers.js rename to desktop/quickshell/Common/BatteryViewHelpers.js diff --git a/roles/desktop/files/quickshell/Common/BluetoothHelpers.js b/desktop/quickshell/Common/BluetoothHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/BluetoothHelpers.js rename to desktop/quickshell/Common/BluetoothHelpers.js diff --git a/roles/desktop/files/quickshell/Common/BluetoothState.qml b/desktop/quickshell/Common/BluetoothState.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BluetoothState.qml rename to desktop/quickshell/Common/BluetoothState.qml diff --git a/roles/desktop/files/quickshell/Common/BrandIcon.qml b/desktop/quickshell/Common/BrandIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BrandIcon.qml rename to desktop/quickshell/Common/BrandIcon.qml diff --git a/roles/desktop/files/quickshell/Common/BrandIcons.qml b/desktop/quickshell/Common/BrandIcons.qml similarity index 94% rename from roles/desktop/files/quickshell/Common/BrandIcons.qml rename to desktop/quickshell/Common/BrandIcons.qml index eb470bc0..f366e898 100644 --- a/roles/desktop/files/quickshell/Common/BrandIcons.qml +++ b/desktop/quickshell/Common/BrandIcons.qml @@ -12,12 +12,12 @@ Singleton { id: root readonly property var names: [ - "claude", "fedora", "fusebox", "gemini", "github", "grok", "kimi", "openai", + "claude", "nerdos", "fusebox", "gemini", "github", "grok", "kimi", "openai", "slack", "t3", "tailscale", "whatsapp", "youtube" ] readonly property var files: ({ claude: "claude.svg", - fedora: "fedora.svg", + nerdos: "nerdos.svg", fusebox: "fusebox.svg", gemini: "gemini.svg", github: "github.svg", @@ -35,7 +35,7 @@ Singleton { // keeps thin outlines (notably OpenAI's) at their original weight. readonly property var highlightFiles: ({ claude: "claude-white.svg", - fedora: "fedora-white.svg", + nerdos: "nerdos-white.svg", fusebox: "fusebox-white.svg", gemini: "gemini-white.svg", github: "github-white.svg", @@ -51,7 +51,7 @@ Singleton { }) readonly property var labels: ({ claude: "Claude", - fedora: "Fedora", + nerdos: "NerdOS", fusebox: "Fusebox", gemini: "Gemini", github: "GitHub", diff --git a/roles/desktop/files/quickshell/Common/Calendar.qml b/desktop/quickshell/Common/Calendar.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Calendar.qml rename to desktop/quickshell/Common/Calendar.qml diff --git a/roles/desktop/files/quickshell/Common/CalendarHelpers.js b/desktop/quickshell/Common/CalendarHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/CalendarHelpers.js rename to desktop/quickshell/Common/CalendarHelpers.js diff --git a/roles/desktop/files/quickshell/Common/CaptureHelpers.js b/desktop/quickshell/Common/CaptureHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/CaptureHelpers.js rename to desktop/quickshell/Common/CaptureHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Claim.qml b/desktop/quickshell/Common/Claim.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Claim.qml rename to desktop/quickshell/Common/Claim.qml diff --git a/roles/desktop/files/quickshell/Common/CommandRequest.qml b/desktop/quickshell/Common/CommandRequest.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/CommandRequest.qml rename to desktop/quickshell/Common/CommandRequest.qml diff --git a/roles/desktop/files/quickshell/Common/CountdownHelpers.js b/desktop/quickshell/Common/CountdownHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/CountdownHelpers.js rename to desktop/quickshell/Common/CountdownHelpers.js diff --git a/desktop/quickshell/Common/DefaultApplications.qml b/desktop/quickshell/Common/DefaultApplications.qml new file mode 100644 index 00000000..3cbefb4d --- /dev/null +++ b/desktop/quickshell/Common/DefaultApplications.qml @@ -0,0 +1,16 @@ +pragma Singleton +import QtQuick +import Quickshell + +Singleton { + id: root + property var roles: ({}) + readonly property bool busy: client.busy + readonly property string error: client.error + function refresh() { client.run(["nerdos-apps", "status"]); } + function select(role, id) { client.run(["nerdos-apps", "set", role, id]); } + JsonCommand { + id: client + onCompleted: value => root.roles = value.roles || ({}) + } +} diff --git a/roles/desktop/files/quickshell/Common/DesktopWorkspaces.qml b/desktop/quickshell/Common/DesktopWorkspaces.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/DesktopWorkspaces.qml rename to desktop/quickshell/Common/DesktopWorkspaces.qml index 2b5ae8cc..08a848dd 100644 --- a/roles/desktop/files/quickshell/Common/DesktopWorkspaces.qml +++ b/desktop/quickshell/Common/DesktopWorkspaces.qml @@ -24,7 +24,7 @@ Singleton { readonly property bool titlebarsAvailable: ready && snapshot.capabilities.titlebars readonly property string error: commandError || (ready ? snapshot.error : "") readonly property string snapshotPath: Quickshell.env("XDG_RUNTIME_DIR") - ? Quickshell.env("XDG_RUNTIME_DIR") + "/cybexos/workspaces.json" : "" + ? Quickshell.env("XDG_RUNTIME_DIR") + "/nerdos/workspaces.json" : "" function monitor(name) { return WorkspaceHelpers.monitor(snapshot, name); } function workspace(id) { return WorkspaceHelpers.workspace(snapshot, id); } diff --git a/roles/desktop/files/quickshell/Common/Dictation.qml b/desktop/quickshell/Common/Dictation.qml similarity index 87% rename from roles/desktop/files/quickshell/Common/Dictation.qml rename to desktop/quickshell/Common/Dictation.qml index 2a7e9436..57e51557 100644 --- a/roles/desktop/files/quickshell/Common/Dictation.qml +++ b/desktop/quickshell/Common/Dictation.qml @@ -13,6 +13,11 @@ Singleton { // it there is no daemon, so nothing watches or waits for its state file // and the menubar offers no dictation. readonly property bool available: Settings.developerToolsConfigured + // Every installation runs the daemon, though; the assistant's + // push-to-talk uses it without the developer feature and asks for its + // state to be watched for that alone. + property bool assistantWatching: false + readonly property bool watching: available || assistantWatching readonly property string statePath: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/voxtype/state" property string state: "idle" @@ -52,7 +57,7 @@ Singleton { FileView { id: stateView - path: root.available ? root.statePath : "" + path: root.watching ? root.statePath : "" printErrors: false watchChanges: true onFileChanged: reload() @@ -90,7 +95,7 @@ Singleton { // while there is no file to watch, and not while the session is idle. Timer { interval: 3000 - running: root.available && root.stateMissing && !Activity.idle + running: root.watching && root.stateMissing && !Activity.idle repeat: true onTriggered: root.refresh() } @@ -99,7 +104,7 @@ Singleton { target: Activity function onResumed() { - if (root.available && root.stateMissing) + if (root.watching && root.stateMissing) root.refresh(); } } diff --git a/roles/desktop/files/quickshell/Common/DisplayHelpers.js b/desktop/quickshell/Common/DisplayHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/DisplayHelpers.js rename to desktop/quickshell/Common/DisplayHelpers.js diff --git a/roles/desktop/files/quickshell/Common/DisplaySettings.qml b/desktop/quickshell/Common/DisplaySettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/DisplaySettings.qml rename to desktop/quickshell/Common/DisplaySettings.qml diff --git a/roles/desktop/files/quickshell/Common/Dock.qml b/desktop/quickshell/Common/Dock.qml similarity index 91% rename from roles/desktop/files/quickshell/Common/Dock.qml rename to desktop/quickshell/Common/Dock.qml index c55122cb..bf4a3f09 100644 --- a/roles/desktop/files/quickshell/Common/Dock.qml +++ b/desktop/quickshell/Common/Dock.qml @@ -19,20 +19,21 @@ Singleton { // Focus history, most recent first, for "focus the app's last window". property var recent: [] - property string browserId: "" + readonly property string browserId: String((DefaultApplications.roles.browser || {}).effective || "").replace(/\.desktop$/, "") + readonly property string terminalId: String((DefaultApplications.roles.terminal || {}).effective || "").replace(/\.desktop$/, "") property var webApps: [] readonly property var desktopApps: DesktopEntries.applications.values // One local registry read on construction and when desktop entries change, // coalesced across installs/removals. No network or per-window subprocess. - onDesktopAppsChanged: webAppRefresh.restart() - Component.onCompleted: webAppRefresh.restart() + onDesktopAppsChanged: { webAppRefresh.restart(); DefaultApplications.refresh(); } + Component.onCompleted: { webAppRefresh.restart(); DefaultApplications.refresh(); } readonly property string activeAddress: Hyprland.activeToplevel ? Hyprland.activeToplevel.address : "" readonly property bool pinsExplicit: Settings.explicitOverrides.indexOf("dockPinned") !== -1 readonly property var pins: DockHelpers.effectivePins(Settings.dockPinned, pinsExplicit, - DockHelpers.suggestedPins(browserId)) + DockHelpers.suggestedPins(browserId, terminalId)) // Desktop entries load after the shell starts, and an app installed later // appears then too; reading the list makes both re-resolve. @@ -202,21 +203,12 @@ Singleton { } Process { id: webAppList - command: [Quickshell.env("HOME") + "/.local/bin/cybexos-webapp", "list", "--json"] + command: ["nerdos-webapp", "list", "--json"] stdout: StdioCollector { onStreamFinished: root.webApps = ProviderHelpers.parseWebApps(text) } } - // The default browser joins the suggested pins. mimeapps.list is the - // file the session's xdg-open reads; a missing or unreadable one simply - // leaves Files and the terminal. - FileView { - path: Quickshell.env("HOME") + "/.config/mimeapps.list" - printErrors: false - watchChanges: true - onFileChanged: reload() - onLoaded: root.browserId = DockHelpers.defaultBrowserId(text()) - onLoadFailed: root.browserId = "" - } + // GIO and xdg-terminal-exec resolve personal and system defaults together. + Timer { interval: 30000; repeat: true; running: true; onTriggered: DefaultApplications.refresh() } } diff --git a/roles/desktop/files/quickshell/Common/DockHelpers.js b/desktop/quickshell/Common/DockHelpers.js similarity index 98% rename from roles/desktop/files/quickshell/Common/DockHelpers.js rename to desktop/quickshell/Common/DockHelpers.js index de5655f8..aaf01ccf 100644 --- a/roles/desktop/files/quickshell/Common/DockHelpers.js +++ b/desktop/quickshell/Common/DockHelpers.js @@ -50,7 +50,7 @@ function webAppEntryId(appId, apps) { && /^[A-Za-z0-9_.-]+$/.test(match[1].slice(name.length + 1)); }); // URLs differing only by query/port (or sanitized path) are ambiguous. - return matches.length === 1 ? "cybexos-webapp-" + matches[0].id : ""; + return matches.length === 1 ? "nerdos-webapp-" + matches[0].id : ""; } function validId(id) { @@ -219,8 +219,8 @@ function defaultBrowserId(text) { return found; } -function suggestedPins(browserId) { - return uniqueIds([browserId].concat(SUGGESTED_PINS)); +function suggestedPins(browserId, terminalId) { + return uniqueIds([browserId, "org.gnome.Nautilus", terminalId === undefined ? "kitty" : terminalId]); } // Where the dock sits and what it reserves. The layer surface is anchored to diff --git a/roles/desktop/files/quickshell/Common/EthernetHelpers.js b/desktop/quickshell/Common/EthernetHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/EthernetHelpers.js rename to desktop/quickshell/Common/EthernetHelpers.js diff --git a/roles/desktop/files/quickshell/Common/EthernetState.qml b/desktop/quickshell/Common/EthernetState.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/EthernetState.qml rename to desktop/quickshell/Common/EthernetState.qml diff --git a/roles/desktop/files/quickshell/Common/ExternalUrl.js b/desktop/quickshell/Common/ExternalUrl.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ExternalUrl.js rename to desktop/quickshell/Common/ExternalUrl.js diff --git a/roles/desktop/files/quickshell/Common/Format.js b/desktop/quickshell/Common/Format.js similarity index 100% rename from roles/desktop/files/quickshell/Common/Format.js rename to desktop/quickshell/Common/Format.js diff --git a/roles/desktop/files/quickshell/Common/Fusebox.qml b/desktop/quickshell/Common/Fusebox.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/Fusebox.qml rename to desktop/quickshell/Common/Fusebox.qml index f677fb56..7c895be8 100644 --- a/roles/desktop/files/quickshell/Common/Fusebox.qml +++ b/desktop/quickshell/Common/Fusebox.qml @@ -597,7 +597,7 @@ Singleton { FileView { id: stateFile - path: Quickshell.env("HOME") + "/.local/state/cybexos/shell/fusebox.json" + path: Quickshell.env("HOME") + "/.local/state/nerdos/shell/fusebox.json" printErrors: false blockLoading: true // qmllint disable unqualified diff --git a/roles/desktop/files/quickshell/Common/FuseboxHelpers.js b/desktop/quickshell/Common/FuseboxHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/FuseboxHelpers.js rename to desktop/quickshell/Common/FuseboxHelpers.js diff --git a/roles/desktop/files/quickshell/Common/GameMode.qml b/desktop/quickshell/Common/GameMode.qml similarity index 83% rename from roles/desktop/files/quickshell/Common/GameMode.qml rename to desktop/quickshell/Common/GameMode.qml index 390a4c78..f4879366 100644 --- a/roles/desktop/files/quickshell/Common/GameMode.qml +++ b/desktop/quickshell/Common/GameMode.qml @@ -4,17 +4,17 @@ import Quickshell import Quickshell.Io import "GameModeState.js" as GameModeState -// Game mode, read from the state file ~/.local/bin/game-mode keeps in -// $XDG_RUNTIME_DIR/cybexos. The helper owns the compositor effects and the +// Game mode, read from the state file game-mode keeps in +// $XDG_RUNTIME_DIR/nerdos. The helper owns the compositor effects and the // idle inhibitor and is bound to Super+Shift+G, so game mode can start without // the shell; watching its state keeps the indicator honest either way. Singleton { id: root readonly property string stateDir: - (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/cybexos" + (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/nerdos" readonly property string helper: - Quickshell.env("HOME") + "/.local/bin/game-mode" + "game-mode" readonly property string instance: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") || "" diff --git a/roles/desktop/files/quickshell/Common/GameModeState.js b/desktop/quickshell/Common/GameModeState.js similarity index 91% rename from roles/desktop/files/quickshell/Common/GameModeState.js rename to desktop/quickshell/Common/GameModeState.js index 22f885a4..15d1627f 100644 --- a/roles/desktop/files/quickshell/Common/GameModeState.js +++ b/desktop/quickshell/Common/GameModeState.js @@ -1,5 +1,5 @@ // Game mode's runtime state as the game-mode helper writes it to -// $XDG_RUNTIME_DIR/cybexos/game-mode.json. The file names the compositor +// $XDG_RUNTIME_DIR/nerdos/game-mode.json. The file names the compositor // instance it belongs to: lingering keeps the runtime directory across // logins, and a file from an earlier login must never read as on. diff --git a/roles/desktop/files/quickshell/Common/GitHub.qml b/desktop/quickshell/Common/GitHub.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/GitHub.qml rename to desktop/quickshell/Common/GitHub.qml index 67aeffa0..830d77af 100644 --- a/roles/desktop/files/quickshell/Common/GitHub.qml +++ b/desktop/quickshell/Common/GitHub.qml @@ -215,7 +215,7 @@ Singleton { FileView { id: stateFile - path: Quickshell.env("HOME") + "/.local/state/cybexos/shell/github.json" + path: Quickshell.env("HOME") + "/.local/state/nerdos/shell/github.json" printErrors: false blockLoading: true // qmllint disable unqualified diff --git a/roles/desktop/files/quickshell/Common/GitHubHelpers.js b/desktop/quickshell/Common/GitHubHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/GitHubHelpers.js rename to desktop/quickshell/Common/GitHubHelpers.js diff --git a/roles/desktop/files/quickshell/Common/GitHubQueue.js b/desktop/quickshell/Common/GitHubQueue.js similarity index 100% rename from roles/desktop/files/quickshell/Common/GitHubQueue.js rename to desktop/quickshell/Common/GitHubQueue.js diff --git a/roles/desktop/files/quickshell/Common/HSlider.qml b/desktop/quickshell/Common/HSlider.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HSlider.qml rename to desktop/quickshell/Common/HSlider.qml diff --git a/roles/desktop/files/quickshell/Common/Hermes.qml b/desktop/quickshell/Common/Hermes.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/Hermes.qml rename to desktop/quickshell/Common/Hermes.qml index 91a62c71..6987dd05 100644 --- a/roles/desktop/files/quickshell/Common/Hermes.qml +++ b/desktop/quickshell/Common/Hermes.qml @@ -146,8 +146,8 @@ Singleton { function configureModel() { const home = Quickshell.env("HOME"); Quickshell.execDetached([ - "kitty", "--title", "Hermes model setup", - home + "/.local/bin/hermes", "model" + "nerdos-apps", "terminal", "--title=Hermes model setup", "--", + "hermes", "model" ]); } diff --git a/roles/desktop/files/quickshell/Common/HermesConnection.qml b/desktop/quickshell/Common/HermesConnection.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesConnection.qml rename to desktop/quickshell/Common/HermesConnection.qml diff --git a/roles/desktop/files/quickshell/Common/HermesConversations.qml b/desktop/quickshell/Common/HermesConversations.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesConversations.qml rename to desktop/quickshell/Common/HermesConversations.qml diff --git a/roles/desktop/files/quickshell/Common/HermesHelpers.js b/desktop/quickshell/Common/HermesHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesHelpers.js rename to desktop/quickshell/Common/HermesHelpers.js diff --git a/roles/desktop/files/quickshell/Common/HermesRpc.qml b/desktop/quickshell/Common/HermesRpc.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesRpc.qml rename to desktop/quickshell/Common/HermesRpc.qml diff --git a/roles/desktop/files/quickshell/Common/HermesSocket.qml b/desktop/quickshell/Common/HermesSocket.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesSocket.qml rename to desktop/quickshell/Common/HermesSocket.qml diff --git a/roles/desktop/files/quickshell/Common/HermesTheme.qml b/desktop/quickshell/Common/HermesTheme.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesTheme.qml rename to desktop/quickshell/Common/HermesTheme.qml diff --git a/roles/desktop/files/quickshell/Common/HugCorner.qml b/desktop/quickshell/Common/HugCorner.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HugCorner.qml rename to desktop/quickshell/Common/HugCorner.qml diff --git a/roles/desktop/files/quickshell/Common/I18n.qml b/desktop/quickshell/Common/I18n.qml similarity index 86% rename from roles/desktop/files/quickshell/Common/I18n.qml rename to desktop/quickshell/Common/I18n.qml index 3c2849f2..22ac4420 100644 --- a/roles/desktop/files/quickshell/Common/I18n.qml +++ b/desktop/quickshell/Common/I18n.qml @@ -8,13 +8,13 @@ import "i18n/nl.js" as Nl // The shell's own text in the session's language. Settings → Region sets // the system language, which applies at the next login to every // application; the shell follows the same rule rather than offering a -// language choice of its own. CYBEXOS_LANG overrides it for tests and +// language choice of its own. NERDOS_LANG overrides it for tests and // screenshots. Untranslated text stays English. Singleton { id: root readonly property string language: I18nHelpers.languageFor([ - Quickshell.env("CYBEXOS_LANG"), Quickshell.env("LC_ALL"), + Quickshell.env("NERDOS_LANG"), Quickshell.env("LC_ALL"), Quickshell.env("LC_MESSAGES"), Quickshell.env("LANG")]) readonly property var catalog: language === "nl" ? Nl.MESSAGES : ({}) diff --git a/roles/desktop/files/quickshell/Common/I18nHelpers.js b/desktop/quickshell/Common/I18nHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/I18nHelpers.js rename to desktop/quickshell/Common/I18nHelpers.js diff --git a/desktop/quickshell/Common/JsonCommand.qml b/desktop/quickshell/Common/JsonCommand.qml new file mode 100644 index 00000000..8d27a7c3 --- /dev/null +++ b/desktop/quickshell/Common/JsonCommand.qml @@ -0,0 +1,40 @@ +import QtQuick +import Quickshell + +// A finite JSON helper request. Missing executables and rejected requests also +// settle, without forwarding arbitrary diagnostics into the desktop journal. +Scope { + id: root + readonly property bool busy: process.running + property var result: ({}) + property string error: "" + signal completed(var value) + signal failed(string message) + + function run(args) { + if (process.running) return false; + error = ""; + process.command = args; + process.running = true; + return true; + } + + CommandRequest { + id: process + timeoutMs: 650000 + onCompleted: (code, body, diagnostics) => { + let value = null; + try { value = JSON.parse(body); } catch (_) {} + if (code !== 0 || !value) { + let failure = null; + try { failure = JSON.parse(diagnostics); } catch (_) {} + root.error = (failure && failure.error) || (value && value.error) + || (code === -1 ? "The helper could not be started." : "The request failed. Retry or inspect its private log."); + root.failed(root.error); + return; + } + root.result = value; + root.completed(value); + } + } +} diff --git a/roles/desktop/files/quickshell/Common/KeybindHelpers.js b/desktop/quickshell/Common/KeybindHelpers.js similarity index 99% rename from roles/desktop/files/quickshell/Common/KeybindHelpers.js rename to desktop/quickshell/Common/KeybindHelpers.js index e1ff3e0d..5b967585 100644 --- a/roles/desktop/files/quickshell/Common/KeybindHelpers.js +++ b/desktop/quickshell/Common/KeybindHelpers.js @@ -4,7 +4,7 @@ // // A binding appears only when it carries a description. Descriptions use the // convention "Group: Label", split at the first ": ". The vendor groups come -// first in GROUP_ORDER; groups introduced by ~/.config/cybexos/hypr/user.lua +// first in GROUP_ORDER; groups introduced by ~/.config/nerdos/hypr/user.lua // follow in the order Hyprland lists them, and a description with no group // lands in "Other", last. diff --git a/roles/desktop/files/quickshell/Common/Launcher.qml b/desktop/quickshell/Common/Launcher.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Launcher.qml rename to desktop/quickshell/Common/Launcher.qml index 3bb0a40d..6f5cd715 100644 --- a/roles/desktop/files/quickshell/Common/Launcher.qml +++ b/desktop/quickshell/Common/Launcher.qml @@ -31,7 +31,7 @@ Singleton { show(targetScreen); } - // `cybexos-runtime ipc launcher open clipboard` (Super+Ctrl+V). Opening + // `nerdos-runtime ipc launcher open clipboard` (Super+Ctrl+V). Opening // never toggles closed: the key always shows the requested tab. function openProvider(providerId, targetScreen) { openRequest = ProviderHelpers.openTarget(providerId); diff --git a/roles/desktop/files/quickshell/Common/LauncherNavigation.js b/desktop/quickshell/Common/LauncherNavigation.js similarity index 100% rename from roles/desktop/files/quickshell/Common/LauncherNavigation.js rename to desktop/quickshell/Common/LauncherNavigation.js diff --git a/roles/desktop/files/quickshell/Common/LauncherProviders.js b/desktop/quickshell/Common/LauncherProviders.js similarity index 93% rename from roles/desktop/files/quickshell/Common/LauncherProviders.js rename to desktop/quickshell/Common/LauncherProviders.js index deea1cf0..0ea24303 100644 --- a/roles/desktop/files/quickshell/Common/LauncherProviders.js +++ b/desktop/quickshell/Common/LauncherProviders.js @@ -32,14 +32,13 @@ var VERBS = [ { id: "system", label: "System" } ]; -// Commands start from the user's ~/.local/bin, where both installation paths -// put the CybexOS helpers (the ISO links them from /usr/share/cybexos/bin); -// the shell service's PATH need not include it. "{argument}" is replaced by +// Commands resolve packaged helpers through the managed desktop's PATH. +// "{argument}" is replaced by // the whole typed argument as one argv element, never spliced into a word. // // An entry is one of: // command: argv run detached once the launcher has closed -// settingsPage: a CybexOS Settings page id (settingsKey scrolls to a row) +// settingsPage: a NerdOS Settings page id (settingsKey scrolls to a row) // neither: a built-in handled by id in LauncherProviders.qml // `argument` asks for one text value first; `choices` picks one item from a // list the shell loads (installed web apps); `requires` hides an entry while @@ -49,6 +48,7 @@ var VERBS = [ // Factory reset is deliberately absent: it stays behind Settings' own // confirmation and is never one mistyped Enter away. var BUILTIN_ACTIONS = [ + { id: "software", verb: "install", name: "Open Software…", subtitle: "Search and review native package changes", keywords: ["install", "remove", "packages", "applications"], glyph: "apps", command: ["nerdos-software"] }, { id: "webapp-install", verb: "install", @@ -63,7 +63,7 @@ var BUILTIN_ACTIONS = [ hint: "Type a web address, then press Enter", submit: "Install web app from" }, - command: ["~/.local/bin/cybexos-webapp", "install", "--notify", + command: ["nerdos-webapp", "install", "--notify", "--url", "{argument}"] }, { @@ -80,7 +80,7 @@ var BUILTIN_ACTIONS = [ hint: "Type an https or SSH Git address, then press Enter", submit: "Install theme from" }, - command: ["~/.local/bin/cybex", "theme", "install", "{argument}"] + command: ["nerd", "theme", "install", "{argument}"] }, { id: "webapp-remove", @@ -90,8 +90,10 @@ var BUILTIN_ACTIONS = [ keywords: ["webapp", "uninstall", "delete", "pwa", "web"], glyph: "trash", choices: "webapps", - command: ["~/.local/bin/cybexos-webapp", "remove", "--notify", "{argument}"] + command: ["nerdos-webapp", "remove", "--notify", "{argument}"] }, + { id: "default-applications", verb: "setup", name: "Default applications", subtitle: "Choose your browser, terminal, editor and viewers", keywords: ["browser", "terminal", "editor", "defaults"], glyph: "apps", settingsPage: "applications" }, + { id: "storage-cleanup", verb: "setup", name: "Storage & cleanup", subtitle: "Review disk space and retained recovery points", keywords: ["disk", "space", "cleanup", "garbage", "retention"], glyph: "archive", settingsPage: "storage" }, { id: "security-fingerprint", verb: "setup", @@ -127,7 +129,7 @@ var BUILTIN_ACTIONS = [ keywords: ["ai", "default", "provider", "model", "agent", "code"], glyph: "robot", requires: "developerTools", - command: ["~/.local/bin/cybex", "agent", "--window", "--pick"] + command: ["nerd", "agent", "--window", "--pick"] }, { id: "game-mode", @@ -136,7 +138,7 @@ var BUILTIN_ACTIONS = [ subtitle: "Turn off effects and notifications while playing", keywords: ["gaming", "games", "steam", "performance", "fullscreen"], glyph: "device-gamepad-2", - command: ["~/.local/bin/game-mode", "toggle"] + command: ["game-mode", "toggle"] }, { id: "dnd-toggle", @@ -150,10 +152,19 @@ var BUILTIN_ACTIONS = [ id: "updates-check", verb: "update", name: "Check for updates", - subtitle: "Look for CybexOS, package and firmware updates", - keywords: ["upgrade", "dnf", "flatpak", "firmware", "update"], + subtitle: "Look for NerdOS and NixOS package updates", + keywords: ["upgrade", "nix", "update"], glyph: "refresh", - command: ["~/.local/bin/cybexos-runtime", "ipc", "updates", "check"] + command: ["nerdos-runtime", "ipc", "updates", "check"] + }, + { + id: "firmware-update", + verb: "update", + name: "Review device firmware…", + subtitle: "Check signed device firmware and confirm installation separately", + keywords: ["firmware", "bios", "uefi", "device", "update"], + glyph: "refresh", + command: ["nerdos-apps", "terminal", "--hold", "--", "nerdos-firmware-update", "interactive"] }, { id: "updates-open", @@ -162,7 +173,7 @@ var BUILTIN_ACTIONS = [ subtitle: "Show pending updates and their progress", keywords: ["upgrade", "install", "update", "pending"], glyph: "circle-arrow-up", - command: ["~/.local/bin/cybexos-runtime", "ipc", "updates", "open"] + command: ["nerdos-runtime", "ipc", "updates", "open"] }, { id: "screenshot-region", @@ -172,7 +183,7 @@ var BUILTIN_ACTIONS = [ keywords: ["screenshot", "capture", "screen", "print"], glyph: "camera", settle: true, - command: ["~/.local/bin/screenshot", "region"] + command: ["screenshot", "region"] }, { id: "screenshot-fullscreen", @@ -182,7 +193,7 @@ var BUILTIN_ACTIONS = [ keywords: ["screenshot", "capture", "screen", "print", "monitor"], glyph: "device-desktop", settle: true, - command: ["~/.local/bin/screenshot", "fullscreen"] + command: ["screenshot", "fullscreen"] }, { id: "screenshot-scroll", @@ -192,7 +203,7 @@ var BUILTIN_ACTIONS = [ keywords: ["screenshot", "capture", "scroll", "long", "page"], glyph: "arrow-down", settle: true, - command: ["~/.local/bin/screenshot", "scroll"] + command: ["screenshot", "scroll"] }, { id: "screen-record", @@ -202,7 +213,7 @@ var BUILTIN_ACTIONS = [ keywords: ["record", "recording", "video", "screencast", "capture"], glyph: "video", settle: true, - command: ["~/.local/bin/screen-record"] + command: ["screen-record"] }, { id: "screen-record-webcam", @@ -212,7 +223,7 @@ var BUILTIN_ACTIONS = [ keywords: ["record", "recording", "video", "camera", "webcam", "capture"], glyph: "video", settle: true, - command: ["~/.local/bin/screen-record", "--webcam"] + command: ["screen-record", "--webcam"] }, { id: "color-picker", @@ -222,7 +233,7 @@ var BUILTIN_ACTIONS = [ keywords: ["color", "colour", "picker", "eyedropper", "hex"], glyph: "droplet-half-2", settle: true, - command: ["~/.local/bin/color-picker"] + command: ["color-picker"] }, { id: "screen-ocr", @@ -232,12 +243,12 @@ var BUILTIN_ACTIONS = [ keywords: ["ocr", "text", "qr", "scan", "capture"], glyph: "qrcode", settle: true, - command: ["~/.local/bin/screen-ocr"] + command: ["screen-ocr"] }, { id: "settings", verb: "system", - name: "Open CybexOS Settings", + name: "Open NerdOS Settings", subtitle: "Appearance, bar, widgets and system", keywords: ["preferences", "configuration", "shell"], glyph: "settings" @@ -258,7 +269,7 @@ var BUILTIN_ACTIONS = [ subtitle: "Start a backup with the saved settings", keywords: ["backup", "copy", "save"], glyph: "archive", - command: ["~/.local/bin/cybex", "backup", "now"] + command: ["nerd", "backup", "now"] }, { id: "overview", @@ -267,7 +278,7 @@ var BUILTIN_ACTIONS = [ subtitle: "See every window and workspace", keywords: ["windows", "workspaces", "expose", "mission"], glyph: "layout-grid", - command: ["~/.local/bin/cybexos-runtime", "ipc", "overview", "open"] + command: ["nerdos-runtime", "ipc", "overview", "open"] }, { id: "ai-agent-launch", @@ -277,7 +288,7 @@ var BUILTIN_ACTIONS = [ keywords: ["ai", "code", "assistant", "terminal", "agent"], glyph: "robot", requires: "developerTools", - command: ["~/.local/bin/cybex", "agent", "--window"] + command: ["nerd", "agent", "--window"] }, { id: "wallpaper-shuffle", @@ -307,10 +318,10 @@ var BUILTIN_ACTIONS = [ id: "report", verb: "system", name: "Report a problem", - subtitle: "Collect diagnostics to share with CybexOS", + subtitle: "Collect diagnostics to share with NerdOS", keywords: ["bug", "issue", "feedback", "diagnostics", "support"], glyph: "message-circle-exclamation", - command: ["~/.local/bin/cybex", "report"] + command: ["nerd", "report"] }, { id: "recovery", @@ -874,7 +885,7 @@ function argumentEmptyText(action, value) { return checked.error !== "" ? checked.error : String(spec.hint || ""); } -// `cybexos-webapp list --json`, read defensively: it is the shell's view of +// `nerdos-webapp list --json`, read defensively: it is the shell's view of // files the user (or anything running as the user) can edit. function parseWebApps(text) { var value; @@ -949,7 +960,7 @@ function expandCommand(command, home, argument) { }); } -// What `cybexos-runtime ipc launcher open ` shows: a tab opens as that +// What `nerdos-runtime ipc launcher open ` shows: a tab opens as that // tab; a prefix provider opens Apps with its prefix typed, the way a user // would reach it. Anything else opens the plain launcher. function openTarget(providerId) { diff --git a/roles/desktop/files/quickshell/Common/LauncherProviders.qml b/desktop/quickshell/Common/LauncherProviders.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/LauncherProviders.qml rename to desktop/quickshell/Common/LauncherProviders.qml index 5a458451..9b47a712 100644 --- a/roles/desktop/files/quickshell/Common/LauncherProviders.qml +++ b/desktop/quickshell/Common/LauncherProviders.qml @@ -67,7 +67,7 @@ Singleton { readonly property string home: Quickshell.env("HOME") readonly property string dataHome: Quickshell.env("XDG_DATA_HOME") || (home + "/.local/share") readonly property string clipboardTool: Quickshell.shellDir + "/scripts/clipboard-tool.py" - readonly property string pinDir: dataHome + "/cybexos/clipboard/pins" + readonly property string pinDir: dataHome + "/nerdos/clipboard/pins" property var clipboardPins: [] property var clipboardPreviews: ({}) property string clipboardPreviewKey: "" @@ -408,7 +408,7 @@ Singleton { // own outcome. A fixed command runs under a fixed wrapper whose // only job is to say so when it fails. root.pendingCommand = argument !== undefined ? argv - : ["sh", "-c", '"$@" || notify-send --app-name=CybexOS -- "$0 failed"' + : ["sh", "-c", '"$@" || notify-send --app-name=NerdOS -- "$0 failed"' + ' "The command exited with an error"', action.name].concat(argv); // Started once the launcher has left the screen, so a capture // never contains it and a new window takes focus from no one. @@ -757,7 +757,7 @@ Singleton { } root.pastePending = false; if (code !== 0) - Quickshell.execDetached(["notify-send", "--app-name=CybexOS", + Quickshell.execDetached(["notify-send", "--app-name=NerdOS", "Clipboard", "The entry could not be copied"]); } } @@ -777,7 +777,7 @@ Singleton { id: webAppListProc property bool exitSeen: false property int lastExit: 0 - command: [root.home + "/.local/bin/cybexos-webapp", "list", "--json"] + command: ["nerdos-webapp", "list", "--json"] stdout: StdioCollector { onStreamFinished: root.webApps = ProviderHelpers.parseWebApps(text) } @@ -812,7 +812,7 @@ Singleton { FileView { id: pinsView - path: root.dataHome + "/cybexos/clipboard/pins.json" + path: root.dataHome + "/nerdos/clipboard/pins.json" printErrors: false watchChanges: true onFileChanged: reload() @@ -873,12 +873,12 @@ Singleton { ClipboardWatcher { watch: "wl-paste --type text --watch " - + Quickshell.env("HOME") + "/.local/bin/clipboard-history-store" + + "clipboard-history-store" } ClipboardWatcher { watch: "wl-paste --type image --watch " - + Quickshell.env("HOME") + "/.local/bin/clipboard-history-store" + + "clipboard-history-store" } Connections { @@ -897,7 +897,7 @@ Singleton { FileView { id: emojiFile - path: root.emojiRequested ? "/usr/share/unicode/emoji/emoji-test.txt" : "" + path: root.emojiRequested ? "/etc/nerdos/emoji-test.txt" : "" printErrors: false onLoaded: { root.emojiEntries = ProviderHelpers.parseEmojiData(text()); diff --git a/roles/desktop/files/quickshell/Common/LayoutHelpers.js b/desktop/quickshell/Common/LayoutHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/LayoutHelpers.js rename to desktop/quickshell/Common/LayoutHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Location/LocationSearch.qml b/desktop/quickshell/Common/Location/LocationSearch.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Location/LocationSearch.qml rename to desktop/quickshell/Common/Location/LocationSearch.qml diff --git a/roles/desktop/files/quickshell/Common/Location/qmldir b/desktop/quickshell/Common/Location/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Common/Location/qmldir rename to desktop/quickshell/Common/Location/qmldir diff --git a/roles/desktop/files/quickshell/Common/LocationHelpers.js b/desktop/quickshell/Common/LocationHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/LocationHelpers.js rename to desktop/quickshell/Common/LocationHelpers.js diff --git a/desktop/quickshell/Common/Maintenance.qml b/desktop/quickshell/Common/Maintenance.qml new file mode 100644 index 00000000..31cb8c95 --- /dev/null +++ b/desktop/quickshell/Common/Maintenance.qml @@ -0,0 +1,45 @@ +pragma Singleton +import QtQuick +import Quickshell + +Singleton { + id: root + property bool watched: false + property var snapshot: ({policy:{automatic:true, keepConfirmed:5, keepDays:30}, space:{}, deployments:[]}) + property string requestId: "" + property bool pending: false + property string requestError: "" + readonly property bool busy: requester.busy || pending || ["checking", "cleaning"].includes(snapshot.state) + readonly property bool ready: !busy && snapshot.state === "ready" && !!snapshot.reviewToken + readonly property string error: requestError || requester.error || poll.error || snapshot.error || "" + function refresh() { poll.run(["nerd", "maintenance", "status"]); } + function request(args) { requestError = ""; requester.run(["nerd", "maintenance"].concat(args)); } + function preview() { request(["preview"]); } + function clean() { if (ready) request(["clean", snapshot.reviewId, snapshot.reviewToken]); } + function pin(id, enabled) { request(["pin", id, enabled ? "true" : "false"]); } + function changePolicy(value, previous) { requestError = ""; requester.run(["nerd", "maintenance", "policy", JSON.stringify({previous:previous, policy:value})]); } + function bytes(value) { + const gib = Number(value || 0) / 1073741824; + return gib >= 1 ? gib.toFixed(1) + " GiB" : (Number(value || 0) / 1048576).toFixed(1) + " MiB"; + } + onWatchedChanged: { if (watched) refresh(); } + JsonCommand { id: poll; onCompleted: value => root.snapshot = value } + JsonCommand { + id: requester + onCompleted: value => { + root.requestId = value.requestId || ""; + root.pending = root.requestId !== ""; + root.refresh(); + } + } + JsonCommand { + id: requestStatus + onFailed: message => { root.pending = false; root.requestError = message; } + onCompleted: value => { + root.requestError = value.error || ""; + if (["done", "failed"].includes(value.state)) { root.pending = false; root.refresh(); } + } + } + Timer { interval: 1500; repeat: true; running: root.pending; onTriggered: requestStatus.run(["nerd", "maintenance", "request", root.requestId]) } + Timer { interval: 1500; repeat: true; running: root.watched || root.pending; onTriggered: root.refresh() } +} diff --git a/roles/desktop/files/quickshell/Common/Media.qml b/desktop/quickshell/Common/Media.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Media.qml rename to desktop/quickshell/Common/Media.qml diff --git a/roles/desktop/files/quickshell/Common/ModelUsageHub.qml b/desktop/quickshell/Common/ModelUsageHub.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/ModelUsageHub.qml rename to desktop/quickshell/Common/ModelUsageHub.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkDetails.qml b/desktop/quickshell/Common/NetworkDetails.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkDetails.qml rename to desktop/quickshell/Common/NetworkDetails.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkDetailsHelpers.js b/desktop/quickshell/Common/NetworkDetailsHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkDetailsHelpers.js rename to desktop/quickshell/Common/NetworkDetailsHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NetworkHelpers.js b/desktop/quickshell/Common/NetworkHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkHelpers.js rename to desktop/quickshell/Common/NetworkHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml b/desktop/quickshell/Common/NetworkOverlayState.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkOverlayState.qml rename to desktop/quickshell/Common/NetworkOverlayState.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkStatus.qml b/desktop/quickshell/Common/NetworkStatus.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkStatus.qml rename to desktop/quickshell/Common/NetworkStatus.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkStatusHelpers.js b/desktop/quickshell/Common/NetworkStatusHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkStatusHelpers.js rename to desktop/quickshell/Common/NetworkStatusHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Notes.qml b/desktop/quickshell/Common/Notes.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/Notes.qml rename to desktop/quickshell/Common/Notes.qml index c251c366..851ab5ad 100644 --- a/roles/desktop/files/quickshell/Common/Notes.qml +++ b/desktop/quickshell/Common/Notes.qml @@ -12,7 +12,7 @@ Singleton { id: root readonly property string filePath: - Quickshell.env("HOME") + "/.local/state/cybexos/shell/notes.json" + Quickshell.env("HOME") + "/.local/state/nerdos/shell/notes.json" property var records: [] readonly property int count: records.length diff --git a/roles/desktop/files/quickshell/Common/NotesHelpers.js b/desktop/quickshell/Common/NotesHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotesHelpers.js rename to desktop/quickshell/Common/NotesHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NotifActions.qml b/desktop/quickshell/Common/NotifActions.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifActions.qml rename to desktop/quickshell/Common/NotifActions.qml diff --git a/roles/desktop/files/quickshell/Common/NotifButton.qml b/desktop/quickshell/Common/NotifButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifButton.qml rename to desktop/quickshell/Common/NotifButton.qml diff --git a/roles/desktop/files/quickshell/Common/NotifCard.qml b/desktop/quickshell/Common/NotifCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifCard.qml rename to desktop/quickshell/Common/NotifCard.qml diff --git a/roles/desktop/files/quickshell/Common/NotifHelpers.js b/desktop/quickshell/Common/NotifHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifHelpers.js rename to desktop/quickshell/Common/NotifHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NotifHolds.js b/desktop/quickshell/Common/NotifHolds.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifHolds.js rename to desktop/quickshell/Common/NotifHolds.js diff --git a/roles/desktop/files/quickshell/Common/NotifIcon.qml b/desktop/quickshell/Common/NotifIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifIcon.qml rename to desktop/quickshell/Common/NotifIcon.qml diff --git a/roles/desktop/files/quickshell/Common/NotifImageFingerprint.qml b/desktop/quickshell/Common/NotifImageFingerprint.qml similarity index 91% rename from roles/desktop/files/quickshell/Common/NotifImageFingerprint.qml rename to desktop/quickshell/Common/NotifImageFingerprint.qml index 4b528c06..c99aa1d8 100644 --- a/roles/desktop/files/quickshell/Common/NotifImageFingerprint.qml +++ b/desktop/quickshell/Common/NotifImageFingerprint.qml @@ -1,6 +1,6 @@ // Fixed-URL optional wrapper: source-only previews may lack the native module. import QtQml -import CybexOS.Images 1.0 +import NerdOS.Images 1.0 QtObject { readonly property string error: ImageFingerprints.error diff --git a/roles/desktop/files/quickshell/Common/NotifPolicy.js b/desktop/quickshell/Common/NotifPolicy.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifPolicy.js rename to desktop/quickshell/Common/NotifPolicy.js diff --git a/roles/desktop/files/quickshell/Common/NotifSmart.js b/desktop/quickshell/Common/NotifSmart.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifSmart.js rename to desktop/quickshell/Common/NotifSmart.js diff --git a/roles/desktop/files/quickshell/Common/NotifSnoozes.qml b/desktop/quickshell/Common/NotifSnoozes.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifSnoozes.qml rename to desktop/quickshell/Common/NotifSnoozes.qml diff --git a/roles/desktop/files/quickshell/Common/Notifs.qml b/desktop/quickshell/Common/Notifs.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Notifs.qml rename to desktop/quickshell/Common/Notifs.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyApplications.qml b/desktop/quickshell/Common/OmarchyApplications.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyApplications.qml rename to desktop/quickshell/Common/OmarchyApplications.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyBarApi.qml b/desktop/quickshell/Common/OmarchyBarApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyBarApi.qml rename to desktop/quickshell/Common/OmarchyBarApi.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyEntryHost.qml b/desktop/quickshell/Common/OmarchyEntryHost.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyEntryHost.qml rename to desktop/quickshell/Common/OmarchyEntryHost.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyPluginApi.qml b/desktop/quickshell/Common/OmarchyPluginApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyPluginApi.qml rename to desktop/quickshell/Common/OmarchyPluginApi.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyPlugins.qml b/desktop/quickshell/Common/OmarchyPlugins.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/OmarchyPlugins.qml rename to desktop/quickshell/Common/OmarchyPlugins.qml index 034e25b9..8cb4eef1 100644 --- a/roles/desktop/files/quickshell/Common/OmarchyPlugins.qml +++ b/desktop/quickshell/Common/OmarchyPlugins.qml @@ -31,7 +31,7 @@ Singleton { const config = JSON.parse(JSON.stringify(UserPlugins.barConfig)); const layout = { left: [], center: [], right: [] }; for (const item of UserPlugins.enabledWidgets.filter(entry => entry.format === "omarchy" && !entry.error)) - layout[item.section || "right"].push(Object.assign({}, item.settings, { id: item.id, __cybexInstance: item.instanceName || "" })); + layout[item.section || "right"].push(Object.assign({}, item.settings, { id: item.id, __nerdosInstance: item.instanceName || "" })); return Object.assign({ position: Settings.position, transparent: false }, config, { layout: layout }); } @@ -412,7 +412,7 @@ Singleton { try { const selector = JSON.parse(selectorJson || "{}"); const value = JSON.parse(valueJson); - if (!selector || typeof selector !== "object" || Array.isArray(selector) || ["id", "__cybexInstance"].includes(key)) return "invalid selector or key"; + if (!selector || typeof selector !== "object" || Array.isArray(selector) || ["id", "__nerdosInstance"].includes(key)) return "invalid selector or key"; const layout = JSON.parse(JSON.stringify(barConfig.layout)); const section = selector.fromSection || selector.section; const index = selector.fromIndex !== undefined ? selector.fromIndex : selector.index; @@ -507,7 +507,10 @@ Singleton { function hide(id: string): void { root.hide(id); } function toggle(id: string, payloadJson: string): void { root.toggle(id, payloadJson); } function call(id: string, method: string, arg: string): string { return root.call(id, method, arg); } - function listPlugins(): string { return JSON.stringify({ plugins: UserPlugins.plugins, errors: root.errors }); } + function listPlugins(): string { + return JSON.stringify({ plugins: UserPlugins.plugins, errors: root.errors, + discoveryError: UserPlugins.discoveryError, discovering: UserPlugins.discoveryBusy }); + } function rescanPlugins(): string { UserPlugins.refresh(); return "ok"; } function setPluginEnabled(id: string, enabled: bool): string { if (!UserPlugins.plugins.some(item => item.id === id)) return "unknown"; diff --git a/roles/desktop/files/quickshell/Common/OmarchyServiceApi.qml b/desktop/quickshell/Common/OmarchyServiceApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyServiceApi.qml rename to desktop/quickshell/Common/OmarchyServiceApi.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyTheme.js b/desktop/quickshell/Common/OmarchyTheme.js similarity index 95% rename from roles/desktop/files/quickshell/Common/OmarchyTheme.js rename to desktop/quickshell/Common/OmarchyTheme.js index 2cb6061b..b524f6f2 100644 --- a/roles/desktop/files/quickshell/Common/OmarchyTheme.js +++ b/desktop/quickshell/Common/OmarchyTheme.js @@ -1,4 +1,4 @@ -// Pure translation of Cybex preferences into Omarchy's flat shell tokens. +// Pure translation of NerdOS preferences into Omarchy's flat shell tokens. function values(settings, palette, metrics, session) { var inherited = settings.pluginBorderMode === "inherit"; var border = inherited ? palette.surfaceBorder : settings.pluginBorderMode === "custom" ? settings.pluginBorderColor diff --git a/roles/desktop/files/quickshell/Common/OnlineWallpapers.qml b/desktop/quickshell/Common/OnlineWallpapers.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OnlineWallpapers.qml rename to desktop/quickshell/Common/OnlineWallpapers.qml diff --git a/roles/desktop/files/quickshell/Common/Osd.qml b/desktop/quickshell/Common/Osd.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Osd.qml rename to desktop/quickshell/Common/Osd.qml diff --git a/roles/desktop/files/quickshell/Common/Overview.qml b/desktop/quickshell/Common/Overview.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Overview.qml rename to desktop/quickshell/Common/Overview.qml diff --git a/roles/desktop/files/quickshell/Common/OverviewHelpers.js b/desktop/quickshell/Common/OverviewHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/OverviewHelpers.js rename to desktop/quickshell/Common/OverviewHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Palette.qml b/desktop/quickshell/Common/Palette.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/Palette.qml rename to desktop/quickshell/Common/Palette.qml index 1e5c5cd8..9729fbbc 100644 --- a/roles/desktop/files/quickshell/Common/Palette.qml +++ b/desktop/quickshell/Common/Palette.qml @@ -14,7 +14,7 @@ Singleton { id: root readonly property string cachePath: - Quickshell.env("HOME") + "/.local/state/cybexos/shell/wallpaper-palette.json" + Quickshell.env("HOME") + "/.local/state/nerdos/shell/wallpaper-palette.json" readonly property string wallpaperIdentity: Wallpaper.currentIdentity property var variants: null readonly property var active: PaletteHelpers.activeVariant(variants, diff --git a/roles/desktop/files/quickshell/Common/PaletteHelpers.js b/desktop/quickshell/Common/PaletteHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/PaletteHelpers.js rename to desktop/quickshell/Common/PaletteHelpers.js diff --git a/roles/desktop/files/quickshell/Common/PanelRegistryData.js b/desktop/quickshell/Common/PanelRegistryData.js similarity index 96% rename from roles/desktop/files/quickshell/Common/PanelRegistryData.js rename to desktop/quickshell/Common/PanelRegistryData.js index a4c8bfb7..11e42306 100644 --- a/roles/desktop/files/quickshell/Common/PanelRegistryData.js +++ b/desktop/quickshell/Common/PanelRegistryData.js @@ -12,7 +12,7 @@ // // Fields: // name the popout name, as passed to Popouts.openPanel() and -// `cybexos-runtime ipc popouts toggle ` (the shell runs +// `nerdos-runtime ipc popouts toggle ` (the shell runs // by path, so a bare `qs ipc call` finds no config) // island which bar section it hangs under when no caller says otherwise // source the view, relative to the shell root (the popout host makes it @@ -84,10 +84,11 @@ var PANELS = [ // Bar modules that own no panel of their own. `ws` has no detail view, // `tray` opens each item's own menu, and `modelusage` hosts the vendored -// Model Usage panel, which opens its own plugin-style popup. Listed so the +// Model Usage panel, which opens its own plugin-style popup. `assistant` +// shows and hides the assistant's own overlay. Listed so the // test can insist the module id and panel spaces account for each other // completely rather than silently tolerating a typo'd moduleId. -var PANEL_LESS_MODULES = ["ws", "tray", "modelusage"]; +var PANEL_LESS_MODULES = ["ws", "tray", "modelusage", "assistant"]; var SETTINGS = "settings"; var NOTES = "notes"; diff --git a/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml b/desktop/quickshell/Common/Persistence/SettingsDocument.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml rename to desktop/quickshell/Common/Persistence/SettingsDocument.qml diff --git a/roles/desktop/files/quickshell/Common/Persistence/qmldir b/desktop/quickshell/Common/Persistence/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Common/Persistence/qmldir rename to desktop/quickshell/Common/Persistence/qmldir diff --git a/roles/desktop/files/quickshell/Common/PluginSchema.js b/desktop/quickshell/Common/PluginSchema.js similarity index 100% rename from roles/desktop/files/quickshell/Common/PluginSchema.js rename to desktop/quickshell/Common/PluginSchema.js diff --git a/roles/desktop/files/quickshell/Common/PolkitHelpers.js b/desktop/quickshell/Common/PolkitHelpers.js similarity index 96% rename from roles/desktop/files/quickshell/Common/PolkitHelpers.js rename to desktop/quickshell/Common/PolkitHelpers.js index 6ca03553..b902073c 100644 --- a/roles/desktop/files/quickshell/Common/PolkitHelpers.js +++ b/desktop/quickshell/Common/PolkitHelpers.js @@ -20,7 +20,7 @@ function identityOptions(identities) { // PAM announces a fingerprint or security-key step with a message and no // input request: pam_fprintd's "Place your finger on the fingerprint reader" // (or "Swipe…", "Failed to match fingerprint"), and pam_u2f's cue, "Touch your -// security key" in the CybexOS stacks or its stock "Please touch the FIDO +// security key" in the NerdOS stacks or its stock "Please touch the FIDO // authenticator.". Returns "fingerprint", "securityKey" or "". function authenticationCue(message) { const text = String(message || "").toLowerCase(); diff --git a/roles/desktop/files/quickshell/Common/Popouts.qml b/desktop/quickshell/Common/Popouts.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Popouts.qml rename to desktop/quickshell/Common/Popouts.qml diff --git a/roles/desktop/files/quickshell/Common/ProcHelpers.js b/desktop/quickshell/Common/ProcHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ProcHelpers.js rename to desktop/quickshell/Common/ProcHelpers.js diff --git a/roles/desktop/files/quickshell/Common/ProcessList.qml b/desktop/quickshell/Common/ProcessList.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/ProcessList.qml rename to desktop/quickshell/Common/ProcessList.qml diff --git a/roles/desktop/files/quickshell/Common/ProcessListHelpers.js b/desktop/quickshell/Common/ProcessListHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ProcessListHelpers.js rename to desktop/quickshell/Common/ProcessListHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Recorder.qml b/desktop/quickshell/Common/Recorder.qml similarity index 97% rename from roles/desktop/files/quickshell/Common/Recorder.qml rename to desktop/quickshell/Common/Recorder.qml index fb60f078..4a01881d 100644 --- a/roles/desktop/files/quickshell/Common/Recorder.qml +++ b/desktop/quickshell/Common/Recorder.qml @@ -3,14 +3,14 @@ import QtQuick import Quickshell import Quickshell.Io -// Screen-recording state, read from the state files ~/.local/bin/screen-record +// Screen-recording state, read from the state files screen-record // keeps in $XDG_RUNTIME_DIR. // // The shell deliberately does not drive wf-recorder itself: the script already // owns region selection, the freeze overlay, the output path and the saved // notification, and it is bound to a compositor key, so a recording can start // without the shell involved at all. Watching its state is what keeps the bar -// chip honest either way. A scrolling screenshot (~/.local/bin/screenshot +// chip honest either way. A scrolling screenshot (screenshot // scroll) publishes its capture in the same directory and lights the same // chip, and a --webcam recording its area for WebcamOverlay. Singleton { @@ -19,9 +19,9 @@ Singleton { readonly property string stateDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/screen-record" readonly property string script: - Quickshell.env("HOME") + "/.local/bin/screen-record" + "screen-record" readonly property string screenshotScript: - Quickshell.env("HOME") + "/.local/bin/screenshot" + "screenshot" property bool active: false property int recorderPid: 0 diff --git a/desktop/quickshell/Common/Recovery.qml b/desktop/quickshell/Common/Recovery.qml new file mode 100644 index 00000000..da4ef583 --- /dev/null +++ b/desktop/quickshell/Common/Recovery.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +import Quickshell +Singleton { + readonly property bool recoveryBoot: false +} diff --git a/roles/desktop/files/quickshell/Common/Reminders.qml b/desktop/quickshell/Common/Reminders.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Reminders.qml rename to desktop/quickshell/Common/Reminders.qml index c59e99a1..92fcf4c0 100644 --- a/roles/desktop/files/quickshell/Common/Reminders.qml +++ b/desktop/quickshell/Common/Reminders.qml @@ -11,7 +11,7 @@ Singleton { id: root readonly property string helper: - Quickshell.env("HOME") + "/.local/bin/quickshell-reminder" + "quickshell-reminder" property var records: [] property bool loading: false property bool refreshPending: false diff --git a/roles/desktop/files/quickshell/Common/RemoteServer.qml b/desktop/quickshell/Common/RemoteServer.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/RemoteServer.qml rename to desktop/quickshell/Common/RemoteServer.qml diff --git a/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js b/desktop/quickshell/Common/RemoteServerHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/RemoteServerHelpers.js rename to desktop/quickshell/Common/RemoteServerHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Revealer.qml b/desktop/quickshell/Common/Revealer.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Revealer.qml rename to desktop/quickshell/Common/Revealer.qml diff --git a/roles/desktop/files/quickshell/Common/Screens.qml b/desktop/quickshell/Common/Screens.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Screens.qml rename to desktop/quickshell/Common/Screens.qml diff --git a/roles/desktop/files/quickshell/Common/ScrollChrome.qml b/desktop/quickshell/Common/ScrollChrome.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/ScrollChrome.qml rename to desktop/quickshell/Common/ScrollChrome.qml diff --git a/roles/desktop/files/quickshell/Common/SecurityHelpers.js b/desktop/quickshell/Common/SecurityHelpers.js similarity index 94% rename from roles/desktop/files/quickshell/Common/SecurityHelpers.js rename to desktop/quickshell/Common/SecurityHelpers.js index 1ea7c39a..9fef7d6f 100644 --- a/roles/desktop/files/quickshell/Common/SecurityHelpers.js +++ b/desktop/quickshell/Common/SecurityHelpers.js @@ -1,4 +1,4 @@ -// Settings > Security copy and enrolment parsing (docs/security.md). Kept free +// Settings > Security copy and enrolment parsing (docs/hardware-security.md). Kept free // of Qt APIs so tests/quickshell/security-settings.test.cjs can run it under // Node. The helper scripts report fprintd's own result names; every word a // person reads is chosen here. @@ -35,10 +35,8 @@ function readerLine(state) { function keyLine(state) { if (state === "missing") return "Security keys need the pam-u2f package."; - if (state === "authselect") - return "Security keys are managed with authselect on this computer."; if (state === "inactive") - return "Security keys are not set up yet. Run cybex update, then try again."; + return "Security keys are not enabled in the NixOS authentication policy. Check your personal modules, then preview and apply the change."; return ""; } diff --git a/roles/desktop/files/quickshell/Common/Session.qml b/desktop/quickshell/Common/Session.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Session.qml rename to desktop/quickshell/Common/Session.qml index d5e1ec04..73da401b 100644 --- a/roles/desktop/files/quickshell/Common/Session.qml +++ b/desktop/quickshell/Common/Session.qml @@ -61,7 +61,7 @@ Singleton { actionError = ""; actionOutput = ""; actionInFlight = action; - sessionAction.command = ["/usr/local/libexec/cybexos-session-action", action]; + sessionAction.command = ["nerdos-session-action", action]; sessionAction.running = true; return true; } diff --git a/roles/desktop/files/quickshell/Common/Settings.qml b/desktop/quickshell/Common/Settings.qml similarity index 95% rename from roles/desktop/files/quickshell/Common/Settings.qml rename to desktop/quickshell/Common/Settings.qml index 268d1695..1cd8f8a6 100644 --- a/roles/desktop/files/quickshell/Common/Settings.qml +++ b/desktop/quickshell/Common/Settings.qml @@ -21,25 +21,20 @@ Singleton { id: root readonly property string filePath: - Quickshell.env("HOME") + "/.config/cybexos/shell.json" + Quickshell.env("HOME") + "/.config/nerdos/shell.json" readonly property bool connectedWidgetsConfigured: - Quickshell.env("CYBEXOS_CONNECTED_WIDGETS") === "1" + Quickshell.env("NERDOS_CONNECTED_WIDGETS") === "1" + // The installer's assistant feature (docs/assistant.md). Unset, as in a + // source-tree run, it counts as on: the default. + readonly property bool assistantConfigured: + Quickshell.env("NERDOS_ASSISTANT") !== "0" // The installer's developer tooling feature (quickshell.service sets // it). Unset, as in a source-tree run, it counts as on: the default. readonly property bool developerToolsConfigured: - Quickshell.env("CYBEXOS_DEVELOPER_TOOLS") !== "0" + Quickshell.env("NERDOS_DEVELOPER_TOOLS") !== "0" readonly property var defaults: { const value = SettingsHelpers.defaults(); - if (connectedWidgetsConfigured) { - for (const column of ["left", "center", "right"]) - value.mods[column] = value.mods[column].map(entry => ({ - id: entry.id, - on: SettingsHelpers.CONNECTED_WIDGET_IDS.indexOf(entry.id) !== -1 - ? true : entry.on, - detail: entry.detail - })); - } return value; } readonly property var fontChoices: SettingsHelpers.FONT_CHOICES @@ -126,6 +121,16 @@ Singleton { property bool dock: defaults.dock property bool dockAutoHide: defaults.dockAutoHide property var dockPinned: defaults.dockPinned + property string assistantAgent: defaults.assistantAgent + property string assistantClaudeModel: defaults.assistantClaudeModel + property string assistantCodexModel: defaults.assistantCodexModel + property bool assistantSpeak: defaults.assistantSpeak + property bool assistantScreenContext: defaults.assistantScreenContext + property string assistantCharacter: defaults.assistantCharacter + property bool assistantShown: defaults.assistantShown + property string assistantScreen: defaults.assistantScreen + property int assistantX: defaults.assistantX + property int assistantY: defaults.assistantY // ---- Runtime state (not persisted) ----------------------------------- property bool panelOpen: false @@ -198,6 +203,8 @@ Singleton { description: "Per-display workspaces, layouts and floating windows" }, { id: "notifications", group: "Personalize", label: I18n.tr("Notifications"), glyph: "notifications", description: "Pop-ups, quiet hours and the on-screen display" }, + { id: "assistant", group: "Personalize", label: I18n.tr("Assistant"), glyph: "assistant", + description: "Agent, voice, screen context and character" }, { id: "displays", group: "Devices", label: I18n.tr("Displays"), glyph: "monitor", description: "Arrangement, resolution, scale and night light", system: true }, { id: "sound", group: "Devices", label: I18n.tr("Sound"), glyph: "volume_up", @@ -210,6 +217,10 @@ Singleton { description: "Layouts and layout switching", system: true }, { id: "power", group: "System", label: I18n.tr("Power"), glyph: "power", description: "Screen off, lock, suspend and stay awake" }, + { id: "applications", group: "System", label: I18n.tr("Applications"), glyph: "apps", + description: "Default applications and native software" }, + { id: "storage", group: "System", label: I18n.tr("Storage & cleanup"), glyph: "archive", + description: "Disk space, retained deployments and automatic cleanup" }, { id: "region", group: "System", label: I18n.tr("Region & formats"), glyph: "language", description: "Timezone, language, clock and temperature formats" }, { id: "accounts", group: "System", label: I18n.tr("Online accounts"), glyph: "account_circle", @@ -287,6 +298,11 @@ Singleton { power: ["idleLockMins", "idleScreenOffMins", "idleSuspendMins", "idleSuspendBatteryOnly", "idleInhibitMode", "idleInhibitUntilMs"], region: ["clock24", "unit"], + // Where the character stands and whether it is out are runtime + // state, reset with the page as idle inhibition is with Power. + assistant: ["assistantAgent", "assistantClaudeModel", "assistantCodexModel", + "assistantSpeak", "assistantScreenContext", "assistantCharacter", + "assistantShown", "assistantScreen", "assistantX", "assistantY"], backup: [], about: [] }) @@ -648,7 +664,7 @@ Singleton { // `mv` has to start and finish first. The dependency rule above stands. function notifyCorruption(message) { Notifs.send({ - appName: "CybexOS Settings", + appName: "NerdOS Settings", appIcon: "preferences-system", urgency: NotificationUrgency.Critical, summary: I18n.tr("Settings file problem"), @@ -759,7 +775,7 @@ Singleton { // unreadable file closes every write, and Retry re-reads it. function protectNewerFile(version) { loadError = true; - loadErrorText = filePath + " was saved by a newer CybexOS (settings version " + loadErrorText = filePath + " was saved by a newer NerdOS (settings version " + version + "). Changes apply but are not saved, so its newer settings are kept."; saveTimer.stop(); savePending = false; @@ -1101,7 +1117,7 @@ Singleton { } } - // Power saver takes the compositor's polish too: cybexos_power_saver() in + // Power saver takes the compositor's polish too: nerdos_power_saver() in // looknfeel.lua uses one blur pass and turns animations off, and // holds that across a config reload. It is sent on startup as well, which // also lifts a saver left behind by a shell that exited meanwhile. @@ -1112,7 +1128,7 @@ Singleton { return; dispatchedPowerSaver = Activity.powerSaver; powerSaverProc.command = ["hyprctl", "eval", - "cybexos_power_saver(" + (dispatchedPowerSaver ? "true" : "false") + ")"]; + "nerdos_power_saver(" + (dispatchedPowerSaver ? "true" : "false") + ")"]; powerSaverProc.running = true; } diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/desktop/quickshell/Common/SettingsHelpers.js similarity index 94% rename from roles/desktop/files/quickshell/Common/SettingsHelpers.js rename to desktop/quickshell/Common/SettingsHelpers.js index 8e3ec42a..550bc668 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/desktop/quickshell/Common/SettingsHelpers.js @@ -1,13 +1,13 @@ // Pure settings-schema helpers shared by QML and Node tests. // Keep this file free of Qt APIs so persistence stays deterministic. -var VERSION = 29; +var VERSION = 30; var BAR_STYLES = ["hug", "floating", "attached"]; var PALETTE_MODES = ["wallpaper", "fixed"]; // The edge drawer's tabs, in default strip order. Overview is the drawer's -// front door — the Fedora button and the session footer live there — so it +// front door — the NerdOS button and the session footer live there — so it // can be reordered but never disabled. var DRAWER_TAB_IDS = ["overview", "sound", "network", "bluetooth", "power", "notifications"]; @@ -34,21 +34,23 @@ var IDLE_SUSPEND_MINS = [0, 15, 30, 60, 120]; // old combined centre pill; schema 11 deliberately introduces the distinct // `notifications` id rather than reviving that historical key. `idle` became // a Control Panel toggle. Schema 24 restores `control` as a configurable -// widget, with the Fedora button's existing rightmost placement by default. +// widget, with the NerdOS button's existing rightmost placement by default. // Schema 25 retired `usage`, the first built-in model usage widget, and its // drawer tab. Schema 26 builds Model Usage in as `modelusage`, vendored from // the digitalpals.model-usage Omarchy plugin (see ModelUsage/README.md). // Schema 28 adds `fusebox`, the Fusebox server widget, beside Model Usage. +// Schema 30 adds `assistant`, the button that shows and hides the NerdOS +// Assistant, before the notification and status widgets. var MODULE_IDS = [ "ws", "media", "indicators", "clock", "weather", "notes", "modelusage", "fusebox", "updates", "gh", - "t3", "hermes", "remote", "tray", + "t3", "hermes", "remote", "tray", "assistant", "notifications", "vol", "wifi", "bt", "batt", "control" ]; var RETIRED_MODULE_IDS = ["bell", "idle", "usage"]; // Widgets for services the user signs in to. They start on only when the -// install's connected-service feature is selected (CYBEXOS_CONNECTED_WIDGETS). +// install's connected-service feature is selected (NERDOS_CONNECTED_WIDGETS). var CONNECTED_WIDGET_IDS = ["modelusage", "fusebox", "gh", "t3", "hermes"]; // Model Usage's quota providers, and the ones it can estimate costs for from @@ -110,6 +112,7 @@ var BAR_COLOR_PRESETS = { var MODULE_GROUPS = { ws: "solo", media: "solo", indicators: "solo", clock: "time", weather: "time", notes: "solo", modelusage: "solo", updates: "solo", gh: "chip", t3: "chip", hermes: "chip", tray: "solo", + assistant: "solo", notifications: "status", vol: "status", wifi: "status", bt: "status", batt: "status", control: "solo" }; @@ -197,6 +200,15 @@ var NOTE_CODEX_EFFORTS = NOTE_CODEX_EFFORT_CHOICES.map( var NOTE_CLAUDE_EFFORTS = NOTE_CLAUDE_EFFORT_CHOICES.map( function(choice) { return choice.value; }); +// The NerdOS Assistant (docs/assistant.md). "auto" follows the `nerd agent` +// default when it is one of the two, else whichever is installed. The model +// lists reuse the reviewed Notes catalogs; "default" leaves the choice to the +// agent's own configuration. +var ASSISTANT_AGENTS = ["auto", "claude", "codex"]; +var ASSISTANT_CHARACTERS = ["mascot", "mark"]; +var ASSISTANT_CLAUDE_MODELS = ["default"].concat(NOTE_CLAUDE_MODELS); +var ASSISTANT_CODEX_MODELS = ["default"].concat(NOTE_CODEX_MODELS); + // Group kinds that rest on a visible pill fill rather than transparent // furniture. Cluster.qml reads this so the decision lives beside the map. var FILLED_GROUP_KINDS = ["time", "status"]; @@ -222,10 +234,10 @@ function defaultMods() { center: [mod("indicators", true), mod("clock", true), mod("weather", false), mod("notes", true)], right: [ - mod("modelusage", false), mod("fusebox", false), mod("updates", true), mod("gh", false), + mod("modelusage", true), mod("fusebox", false), mod("updates", true), mod("gh", false), mod("t3", false), - mod("hermes", false), mod("remote", true), - mod("tray", false), mod("notifications", true), mod("vol", true), + mod("hermes", false), mod("remote", false), + mod("tray", false), mod("assistant", true), mod("notifications", true), mod("vol", true), mod("wifi", true), mod("bt", true), mod("batt", true), mod("control", true) ] }; @@ -455,7 +467,21 @@ function defaults() { // and the terminal until the person pins or unpins something. dock: false, dockAutoHide: false, - dockPinned: [] + dockPinned: [], + assistantAgent: "auto", + assistantClaudeModel: "default", + assistantCodexModel: "default", + assistantSpeak: false, + // Off until enabled: the screen button attaches a screenshot only + // to the question it was pressed for. + assistantScreenContext: false, + assistantCharacter: "mascot", + // Where the character was left. Runtime state, persisted so a shell + // reload or a new session finds it there; -1 is the default corner. + assistantShown: false, + assistantScreen: "", + assistantX: -1, + assistantY: -1 }; } @@ -1155,7 +1181,7 @@ function migrateMods(raw, sourceVersion, context) { var migrated = { left: [], center: [], right: [] }; ["left", "center", "right"].forEach(function(col) { var list = Array.isArray(raw[col]) ? raw[col] : []; - // Before schema 24 the old control entry was ignored and the Fedora + // Before schema 24 the old control entry was ignored and the NerdOS // button always lived on the right. Replace stale historical entries // with that effective placement; later user moves/removals persist. if (typeof sourceVersion !== "number" || sourceVersion < 24) @@ -1233,7 +1259,7 @@ function migrateMods(raw, sourceVersion, context) { if (hermesIndex === -1) continue; migrated[t3Col].splice(hermesIndex + 1, 0, { - id: "hermes", on: true, detail: "auto" + id: "hermes", on: false, detail: "auto" }); break; } @@ -1265,7 +1291,7 @@ function migrateMods(raw, sourceVersion, context) { // Schema 26 builds Model Usage in where the plugin it replaces stood: the // leading edge of the right column. Like the other connected-service - // widgets it starts on only when that install feature is selected. + // widgets its fresh visibility is independent of installed service support. var modelUsagePresent = ["left", "center", "right"].some(function(col) { return migrated[col].some(function(entry) { return entry && entry.id === "modelusage"; @@ -1275,7 +1301,7 @@ function migrateMods(raw, sourceVersion, context) { && !modelUsagePresent) { migrated.right.unshift({ id: "modelusage", - on: !!(context && context.connectedWidgets), + on: true, detail: "auto" }); } @@ -1290,7 +1316,7 @@ function migrateMods(raw, sourceVersion, context) { }); if ((typeof sourceVersion !== "number" || sourceVersion < 28) && !fuseboxPresent) { - var fusebox = { id: "fusebox", on: !!(context && context.connectedWidgets), detail: "auto" }; + var fusebox = { id: "fusebox", on: false, detail: "auto" }; var placed = ["left", "center", "right"].some(function(col) { var usageIndex = migrated[col].findIndex(function(entry) { return entry && entry.id === "modelusage"; @@ -1303,6 +1329,28 @@ function migrateMods(raw, sourceVersion, context) { if (!placed) migrated.right.unshift(fusebox); } + + // Schema 30 adds the NerdOS Assistant's button immediately before the + // notification and status widgets still on the right, where schema 11 + // put Notifications, so an older layout gains it without anything else + // moving. Off-feature installs hide it through the bar's auto-rule. + var assistantPresent = ["left", "center", "right"].some(function(col) { + return migrated[col].some(function(entry) { + return entry && entry.id === "assistant"; + }); + }); + if ((typeof sourceVersion !== "number" || sourceVersion < 30) + && !assistantPresent) { + var trailingIds = ["notifications", "vol", "wifi", "bt", "batt"]; + var assistantIndex = migrated.right.findIndex(function(entry) { + return entry && trailingIds.indexOf(entry.id) !== -1; + }); + if (assistantIndex === -1) + assistantIndex = migrated.right.length; + migrated.right.splice(assistantIndex, 0, { + id: "assistant", on: true, detail: "auto" + }); + } return normalizeMods(migrated); } @@ -1334,17 +1382,9 @@ function migratePaletteMode(parsed, defaultsValue) { } // `context.connectedWidgets` is the install's connected-service feature; it -// decides whether a widget that schema migration adds starts on. +// supplies feature availability without changing saved widget visibility. function merge(raw, context) { var d = defaults(); - if (context && context.connectedWidgets) { - ["left", "center", "right"].forEach(function(column) { - d.mods[column].forEach(function(entry) { - if (CONNECTED_WIDGET_IDS.indexOf(entry.id) !== -1) - entry.on = true; - }); - }); - } if (!raw || typeof raw !== "object") return d; var parsed = raw; @@ -1446,7 +1486,20 @@ function merge(raw, context) { modOpts: migrateModOpts(parsed.modOpts, parsed.v, parsed), dock: boolIn(parsed.dock, d.dock), dockAutoHide: boolIn(parsed.dockAutoHide, d.dockAutoHide), - dockPinned: desktopIdsIn(parsed.dockPinned, d.dockPinned) + dockPinned: desktopIdsIn(parsed.dockPinned, d.dockPinned), + assistantAgent: enumIn(parsed.assistantAgent, ASSISTANT_AGENTS, d.assistantAgent), + assistantClaudeModel: enumIn(parsed.assistantClaudeModel, ASSISTANT_CLAUDE_MODELS, + d.assistantClaudeModel), + assistantCodexModel: enumIn(parsed.assistantCodexModel, ASSISTANT_CODEX_MODELS, + d.assistantCodexModel), + assistantSpeak: boolIn(parsed.assistantSpeak, d.assistantSpeak), + assistantScreenContext: boolIn(parsed.assistantScreenContext, d.assistantScreenContext), + assistantCharacter: enumIn(parsed.assistantCharacter, ASSISTANT_CHARACTERS, + d.assistantCharacter), + assistantShown: boolIn(parsed.assistantShown, d.assistantShown), + assistantScreen: textIn(parsed.assistantScreen, 64, d.assistantScreen), + assistantX: intIn(parsed.assistantX, -1, 32768, 1, d.assistantX), + assistantY: intIn(parsed.assistantY, -1, 32768, 1, d.assistantY) }; } @@ -1721,6 +1774,10 @@ var exported = { NOTE_CLAUDE_MODEL_CHOICES: NOTE_CLAUDE_MODEL_CHOICES, NOTE_CODEX_EFFORT_CHOICES: NOTE_CODEX_EFFORT_CHOICES, NOTE_CLAUDE_EFFORT_CHOICES: NOTE_CLAUDE_EFFORT_CHOICES, + ASSISTANT_AGENTS: ASSISTANT_AGENTS, + ASSISTANT_CHARACTERS: ASSISTANT_CHARACTERS, + ASSISTANT_CLAUDE_MODELS: ASSISTANT_CLAUDE_MODELS, + ASSISTANT_CODEX_MODELS: ASSISTANT_CODEX_MODELS, moduleGroup: moduleGroup, FILLED_GROUP_KINDS: FILLED_GROUP_KINDS, groupFilled: groupFilled, diff --git a/roles/desktop/files/quickshell/Common/SettingsSearchData.js b/desktop/quickshell/Common/SettingsSearchData.js similarity index 87% rename from roles/desktop/files/quickshell/Common/SettingsSearchData.js rename to desktop/quickshell/Common/SettingsSearchData.js index ca6c29a1..a81c57bb 100644 --- a/roles/desktop/files/quickshell/Common/SettingsSearchData.js +++ b/desktop/quickshell/Common/SettingsSearchData.js @@ -10,9 +10,24 @@ // against SettingsHelpers' schema under Node. var ROWS = [ + { page: "applications", pageLabel: "Applications", group: "Default applications", label: "Web browser", key: "", anchor: "applications-browser", terms: "default browser firefox brave links web http" }, + { page: "applications", pageLabel: "Applications", group: "Default applications", label: "Terminal", key: "", anchor: "applications-terminal", terms: "default terminal kitty ghostty foot alacritty" }, + { page: "applications", pageLabel: "Applications", group: "Default applications", label: "Text editor", key: "", anchor: "applications-editor", terms: "default editor text nvim code" }, + { page: "applications", pageLabel: "Applications", group: "Default applications", label: "PDF viewer", key: "", anchor: "applications-pdf", terms: "default pdf documents" }, + { page: "applications", pageLabel: "Applications", group: "Default applications", label: "Image viewer", key: "", anchor: "applications-image", terms: "default image photo png jpeg" }, + { page: "applications", pageLabel: "Applications", group: "Default applications", label: "Video player", key: "", anchor: "applications-video", terms: "default video media mp4" }, + { page: "applications", pageLabel: "Applications", group: "Software", label: "Open Software", key: "", terms: "install remove packages applications software" }, + { page: "storage", pageLabel: "Storage & cleanup", group: "Storage", label: "Review cleanup", key: "", terms: "free disk space garbage collection nix store" }, + { page: "storage", pageLabel: "Storage & cleanup", group: "Retention", label: "Automatic weekly cleanup", key: "", anchor: "storage-automatic", terms: "automatic cleanup retention recovery generations pin" }, { page: "workspaces", pageLabel: "Workspaces", group: "Workspaces", label: "Workspace mode", key: "", terms: "shared per-monitor display local numbers hotplug external" }, { page: "workspaces", pageLabel: "Workspaces", group: "Workspace layout", label: "Default workspace layout", key: "", terms: "dwindle tiled scrolling floating desktop" }, { page: "workspaces", pageLabel: "Workspaces", group: "Floating windows", label: "Floating titlebars", key: "", terms: "decoration close maximize minimize restore dock" }, + { page: "assistant", pageLabel: "Assistant", group: "Agent", label: "Answered by", key: "assistantAgent", terms: "ai assistant clippy helper claude codex agent chat ask question" }, + { page: "assistant", pageLabel: "Assistant", group: "Agent", label: "Claude Code model", key: "assistantClaudeModel", terms: "ai assistant fable sonnet opus model" }, + { page: "assistant", pageLabel: "Assistant", group: "Agent", label: "Codex model", key: "assistantCodexModel", terms: "ai assistant openai gpt luna terra sol model" }, + { page: "assistant", pageLabel: "Assistant", group: "Voice and screen", label: "Speak replies", key: "assistantSpeak", terms: "ai assistant voice read aloud speech tts text to speech" }, + { page: "assistant", pageLabel: "Assistant", group: "Voice and screen", label: "Screen context", key: "assistantScreenContext", terms: "ai assistant screenshot screen capture privacy" }, + { page: "assistant", pageLabel: "Assistant", group: "Character", label: "Look", key: "assistantCharacter", terms: "ai assistant mascot character logo eyes glasses" }, { page: "notifications", pageLabel: "Notifications", group: "Notification history", label: "Remember notification history", key: "notifHistoryPersist", terms: "persist private retention restart" }, { page: "notifications", pageLabel: "Notifications", group: "Source snoozes", label: "Source snoozes", key: "", terms: "snooze mute application website unsnooze" }, { page: "notifications", pageLabel: "Notifications", group: "Grouped popups", label: "Burst limit", key: "notifBurstLimit", terms: "group stack duplicates repeated count" }, @@ -100,9 +115,9 @@ var ROWS = [ { page: "bar", pageLabel: "Bar", group: "Weather", label: "Weather location", key: "", widget: "weather", terms: "clock city place search country region coordinates latitude longitude forecast" }, { page: "bar", pageLabel: "Bar", group: "Fusebox", label: "Fusebox server", key: "", widget: "fusebox", terms: "ai proxy cliproxy subscriptions accounts management key url faults load requests sessions quota used remaining left meters pace breaker priority clients budgets connected addresses notifications privacy email" }, { page: "bar", pageLabel: "Bar", group: "Model Usage", label: "Model Usage", key: "", widget: "modelusage", terms: "ai claude codex kimi quota limits reset percentage providers cliproxy proxy accounts credentials cost tokens t3 refresh warning critical" }, - { page: "bar", pageLabel: "Bar", group: "Updates", label: "Download updates automatically", key: "", widget: "updates", terms: "background download updates automatic schedule timer dnf flatpak packages metered power check every interval notify" }, + { page: "bar", pageLabel: "Bar", group: "Updates", label: "Download updates automatically", key: "", widget: "updates", terms: "background download updates automatic schedule timer nix packages metered power check every interval notify" }, - { page: "bar", pageLabel: "Bar", group: "Control Center", label: "Control Center widget", key: "", widget: "control", terms: "fedora button move drag reorder show hide drawer" }, + { page: "bar", pageLabel: "Bar", group: "Control Center", label: "Control Center widget", key: "", widget: "control", terms: "nerdos button move drag reorder show hide drawer" }, // Control Center (also searchable by its former Drawer name) { page: "bar", pageLabel: "Bar", widget: "control", group: "Tabs", label: "Tab order", key: "", terms: "drawer reorder overview sound network bluetooth power notifications usage" }, @@ -134,7 +149,7 @@ var ROWS = [ { page: "power", pageLabel: "Power", group: "Stay awake", label: "Bar indicator", key: "", terms: "stay awake indicator sign-in widget options" }, { page: "notifications", pageLabel: "Notifications", group: "On-screen display", label: "Placement", key: "osd", terms: "osd volume brightness popup overlay" }, - // Backup & recovery: backups belong to the cybexos-backup helper, so the + // Backup & recovery: backups belong to the nerdos-backup helper, so the // rows navigate rather than naming shell.json keys. { page: "backup", pageLabel: "Backup & recovery", group: "Backups", label: "Back up personal files", key: "", terms: "backup restic encrypted external drive usb disk ssh sftp server nas home folder copy set up" }, { page: "backup", pageLabel: "Backup & recovery", group: "Backups", label: "Automatic backups", key: "", terms: "daily schedule timer reminder battery metered" }, diff --git a/roles/desktop/files/quickshell/Common/ShellHealth.qml b/desktop/quickshell/Common/ShellHealth.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/ShellHealth.qml rename to desktop/quickshell/Common/ShellHealth.qml index 807c433c..4dbdc6b1 100644 --- a/roles/desktop/files/quickshell/Common/ShellHealth.qml +++ b/desktop/quickshell/Common/ShellHealth.qml @@ -100,7 +100,7 @@ Singleton { FileView { id: deploymentFile - path: root.stateHome + "/cybexos/quickshell-health.json" + path: root.stateHome + "/nerdos/quickshell-health.json" printErrors: false watchChanges: true onFileChanged: reload() @@ -138,7 +138,7 @@ Singleton { property string body: "" property bool exitSeen: false property int lastExit: -1 - command: ["/usr/bin/python3", root.helper] + command: ["python3", root.helper] stdout: StdioCollector { onStreamFinished: probe.body = text } stderr: StdioCollector {} onStarted: { diff --git a/roles/desktop/files/quickshell/Common/ShellMetrics.js b/desktop/quickshell/Common/ShellMetrics.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ShellMetrics.js rename to desktop/quickshell/Common/ShellMetrics.js diff --git a/roles/desktop/files/quickshell/Common/SocketContract.qml b/desktop/quickshell/Common/SocketContract.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/SocketContract.qml rename to desktop/quickshell/Common/SocketContract.qml diff --git a/desktop/quickshell/Common/Software.qml b/desktop/quickshell/Common/Software.qml new file mode 100644 index 00000000..2e034206 --- /dev/null +++ b/desktop/quickshell/Common/Software.qml @@ -0,0 +1,68 @@ +pragma Singleton +import QtQuick +import Quickshell + +Singleton { + id: root + property bool open: false + property string query: "" + property bool installedTab: false + property var inventory: ({desired: [], packages: []}) + property var draft: [] + property var results: [] + property int total: 0 + property string requestId: "" + property string requestError: "" + property bool requestPending: false + property bool conflict: false + readonly property bool canStage: (dirty || inventory.configurationChanged === true) && !conflict && !busy && !Updates.runActive && !Updates.rebootRecommended + readonly property bool busy: catalogue.busy || loader.busy || requester.busy || requestPending + readonly property bool dirty: JSON.stringify(draft.slice().sort()) !== JSON.stringify((inventory.desired || []).slice().sort()) + readonly property string error: requestError || (conflict ? "Package choices changed elsewhere. Discard selections and refresh before building." : "") || requester.error || loader.error || catalogue.error + readonly property bool ownDeployment: Updates.deployment.changeKind === "software" + && (requestId === "" || Updates.deployment.requestId === requestId) + + signal present() + function show() { if (open) present(); else open = true; refresh(); } + function close() { open = false; } + function refresh() { + loader.run(["nerd", "software", "list"]); + searchDelay.restart(); + } + function discard() { conflict = false; draft = (inventory.desired || []).slice(); refresh(); } + function toggle(attribute) { + draft = draft.includes(attribute) ? draft.filter(p => p !== attribute) : draft.concat([attribute]); + } + function stage() { + if (!canStage) return; + requestError = ""; + requester.run(["nerd", "software", "stage", JSON.stringify({packages: draft, previous: inventory.desired || []})]); + } + onQueryChanged: searchDelay.restart() + Timer { id: searchDelay; interval: 250; onTriggered: { if (!catalogue.run(["nerd", "software", "search", root.query])) restart(); } } + JsonCommand { + id: loader + onCompleted: value => { + const edited = root.dirty; + if (edited && JSON.stringify((root.inventory.desired || []).slice().sort()) !== JSON.stringify((value.desired || []).slice().sort())) root.conflict = true; + root.inventory = value; + if (!edited) root.draft = (value.desired || []).slice(); + } + } + JsonCommand { + id: catalogue + onCompleted: value => { + root.results = value.results || []; root.total = value.total || 0; + } + } + JsonCommand { id: requester; onCompleted: value => { root.requestId = value.requestId || ""; root.requestPending = root.requestId !== ""; } } + JsonCommand { + id: requestStatus + onFailed: message => { root.requestPending = false; root.requestError = message; } + onCompleted: value => { + root.requestError = value.error || ""; + if (["done", "failed"].includes(value.state)) { root.requestPending = false; root.refresh(); } + } + } + Timer { interval: 1500; repeat: true; running: root.requestPending; onTriggered: requestStatus.run(["nerd", "software", "request", root.requestId]) } +} diff --git a/roles/desktop/files/quickshell/Common/Startup.qml b/desktop/quickshell/Common/Startup.qml similarity index 96% rename from roles/desktop/files/quickshell/Common/Startup.qml rename to desktop/quickshell/Common/Startup.qml index 1edbfcc9..e4b2c8a5 100644 --- a/roles/desktop/files/quickshell/Common/Startup.qml +++ b/desktop/quickshell/Common/Startup.qml @@ -18,8 +18,8 @@ Singleton { Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") || Quickshell.env("XDG_SESSION_ID") || "runtime" readonly property string markerPath: runtimeDir !== "" - ? runtimeDir + "/cybexos-quickshell-started" - : "/tmp/cybexos-quickshell-" + ? runtimeDir + "/nerdos-quickshell-started" + : "/tmp/nerdos-quickshell-" + (Quickshell.env("USER") || "user") + "-started" property bool ready: false property bool firstStart: false diff --git a/roles/desktop/files/quickshell/Common/StateLayer.qml b/desktop/quickshell/Common/StateLayer.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/StateLayer.qml rename to desktop/quickshell/Common/StateLayer.qml diff --git a/roles/desktop/files/quickshell/Common/StatusHelpers.js b/desktop/quickshell/Common/StatusHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/StatusHelpers.js rename to desktop/quickshell/Common/StatusHelpers.js diff --git a/roles/desktop/files/quickshell/Common/StatusPlaceholder.qml b/desktop/quickshell/Common/StatusPlaceholder.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/StatusPlaceholder.qml rename to desktop/quickshell/Common/StatusPlaceholder.qml diff --git a/roles/desktop/files/quickshell/Common/Sym.qml b/desktop/quickshell/Common/Sym.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Sym.qml rename to desktop/quickshell/Common/Sym.qml diff --git a/roles/desktop/files/quickshell/Common/SysInfo.qml b/desktop/quickshell/Common/SysInfo.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/SysInfo.qml rename to desktop/quickshell/Common/SysInfo.qml index 0b138479..5d66a915 100644 --- a/roles/desktop/files/quickshell/Common/SysInfo.qml +++ b/desktop/quickshell/Common/SysInfo.qml @@ -423,13 +423,13 @@ Singleton { } // ---- idle timeouts ------------------------------------------------- - // cybexos-runtime renders hypridle's configuration from shell.json + // nerdos-runtime renders hypridle's configuration from shell.json // when hypridle starts, so a change applies by restarting the service // once the file holds it. The comparison uses the saved text, not the // live values: those can be ahead of the file while a save is in flight. // A user-owned hypridle.conf replaces the rendered file entirely. readonly property string idleUserConfigPath: Quickshell.env("HOME") - + "/.config/cybexos/hypr/hypridle.conf" + + "/.config/nerdos/hypr/hypridle.conf" property string idleTimeoutsApplied: "" property string idleTimeoutsError: "" property bool idleUserConfig: false @@ -511,7 +511,7 @@ Singleton { Process { id: idleUserConfigProc - // The same test cybexos-runtime applies before preferring it. + // The same test nerdos-runtime applies before preferring it. command: ["sh", "-c", '[ -f "$1" ] && [ ! -L "$1" ]', "sh", root.idleUserConfigPath] onExited: exitCode => root.idleUserConfig = exitCode === 0 @@ -772,7 +772,7 @@ Singleton { // the Studio Display it reported the closed lid's backlight, and writing // brightnessctl moved that panel instead of the one being looked at. readonly property string brightnessTool: - Quickshell.env("HOME") + "/.local/bin/brightness-control" + "brightness-control" // The reading follows the pointer immediately so the slider stays live // under a drag; the process that writes it is coalesced onto the last diff --git a/roles/desktop/files/quickshell/Common/SysInfoHelpers.js b/desktop/quickshell/Common/SysInfoHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/SysInfoHelpers.js rename to desktop/quickshell/Common/SysInfoHelpers.js diff --git a/roles/desktop/files/quickshell/Common/SystemSettings.qml b/desktop/quickshell/Common/SystemSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/SystemSettings.qml rename to desktop/quickshell/Common/SystemSettings.qml diff --git a/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml b/desktop/quickshell/Common/SystemSettingsBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml rename to desktop/quickshell/Common/SystemSettingsBackend.qml diff --git a/roles/desktop/files/quickshell/Common/SystemTheme.qml b/desktop/quickshell/Common/SystemTheme.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/SystemTheme.qml rename to desktop/quickshell/Common/SystemTheme.qml index 34702cfc..59e7ddb8 100644 --- a/roles/desktop/files/quickshell/Common/SystemTheme.qml +++ b/desktop/quickshell/Common/SystemTheme.qml @@ -11,7 +11,7 @@ import "SettingsHelpers.js" as SettingsHelpers // terminal, compositor, GTK and the lock screen. Theme has already chosen // between the fixed and wallpaper palettes and applied its contrast floors, so // this exports those results rather than a second design. scripts/ -// theme-apply.py renders them into ~/.local/state/cybexos/theme and reloads +// theme-apply.py renders them into ~/.local/state/nerdos/theme and reloads // only the applications whose files changed (docs/system-theme.md). Singleton { id: root diff --git a/roles/desktop/files/quickshell/Common/T3Actions.qml b/desktop/quickshell/Common/T3Actions.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Actions.qml rename to desktop/quickshell/Common/T3Actions.qml diff --git a/roles/desktop/files/quickshell/Common/T3Code.qml b/desktop/quickshell/Common/T3Code.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Code.qml rename to desktop/quickshell/Common/T3Code.qml diff --git a/roles/desktop/files/quickshell/Common/T3CodeHelpers.js b/desktop/quickshell/Common/T3CodeHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/T3CodeHelpers.js rename to desktop/quickshell/Common/T3CodeHelpers.js diff --git a/roles/desktop/files/quickshell/Common/T3Connection.qml b/desktop/quickshell/Common/T3Connection.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Connection.qml rename to desktop/quickshell/Common/T3Connection.qml diff --git a/roles/desktop/files/quickshell/Common/T3Detail.qml b/desktop/quickshell/Common/T3Detail.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Detail.qml rename to desktop/quickshell/Common/T3Detail.qml diff --git a/roles/desktop/files/quickshell/Common/T3Drafts.qml b/desktop/quickshell/Common/T3Drafts.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Drafts.qml rename to desktop/quickshell/Common/T3Drafts.qml diff --git a/roles/desktop/files/quickshell/Common/T3Favorites.qml b/desktop/quickshell/Common/T3Favorites.qml similarity index 96% rename from roles/desktop/files/quickshell/Common/T3Favorites.qml rename to desktop/quickshell/Common/T3Favorites.qml index f7289b2c..37ec58a0 100644 --- a/roles/desktop/files/quickshell/Common/T3Favorites.qml +++ b/desktop/quickshell/Common/T3Favorites.qml @@ -20,7 +20,7 @@ Singleton { // would silently fork the list. Settings and the wallpaper palette are // pinned for the same reason. readonly property string statePath: - Quickshell.env("HOME") + "/.local/state/cybexos/shell/t3-model-favorites.json" + Quickshell.env("HOME") + "/.local/state/nerdos/shell/t3-model-favorites.json" function isFavorite(instanceId, model) { return indexOf(instanceId, model) >= 0; diff --git a/roles/desktop/files/quickshell/Common/T3Git.qml b/desktop/quickshell/Common/T3Git.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Git.qml rename to desktop/quickshell/Common/T3Git.qml diff --git a/roles/desktop/files/quickshell/Common/T3Rpc.qml b/desktop/quickshell/Common/T3Rpc.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Rpc.qml rename to desktop/quickshell/Common/T3Rpc.qml diff --git a/roles/desktop/files/quickshell/Common/T3Socket.qml b/desktop/quickshell/Common/T3Socket.qml similarity index 95% rename from roles/desktop/files/quickshell/Common/T3Socket.qml rename to desktop/quickshell/Common/T3Socket.qml index 03d1e2b7..0ecb81ef 100644 --- a/roles/desktop/files/quickshell/Common/T3Socket.qml +++ b/desktop/quickshell/Common/T3Socket.qml @@ -2,7 +2,7 @@ import QtQuick import QtWebSockets // Thin WebSocket wrapper loaded dynamically by T3Code so a machine -// without the QtWebSockets QML module (qt6-qtwebsockets-devel) degrades +// without the QtWebSockets QML module degrades // to an offline chip instead of taking down the whole shell. Item { id: root diff --git a/roles/desktop/files/quickshell/Common/T3Theme.qml b/desktop/quickshell/Common/T3Theme.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Theme.qml rename to desktop/quickshell/Common/T3Theme.qml diff --git a/roles/desktop/files/quickshell/Common/T3Threads.qml b/desktop/quickshell/Common/T3Threads.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Threads.qml rename to desktop/quickshell/Common/T3Threads.qml diff --git a/roles/desktop/files/quickshell/Common/TablerGlyphs.js b/desktop/quickshell/Common/TablerGlyphs.js similarity index 100% rename from roles/desktop/files/quickshell/Common/TablerGlyphs.js rename to desktop/quickshell/Common/TablerGlyphs.js diff --git a/roles/desktop/files/quickshell/Common/TablerIcons.qml b/desktop/quickshell/Common/TablerIcons.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/TablerIcons.qml rename to desktop/quickshell/Common/TablerIcons.qml diff --git a/roles/desktop/files/quickshell/Common/Tailscale.qml b/desktop/quickshell/Common/Tailscale.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/Tailscale.qml rename to desktop/quickshell/Common/Tailscale.qml index 79911d70..04944415 100644 --- a/roles/desktop/files/quickshell/Common/Tailscale.qml +++ b/desktop/quickshell/Common/Tailscale.qml @@ -365,7 +365,7 @@ Singleton { if (actionProc.up) actionError = ""; if (authPending) { - Quickshell.execDetached(["notify-send", "--app-name=CybexOS", + Quickshell.execDetached(["notify-send", "--app-name=NerdOS", "Connected to Tailscale", host + (ip !== "" ? " · " + ip : "")]); } authPending = false; diff --git a/roles/desktop/files/quickshell/Common/TailscaleHelpers.js b/desktop/quickshell/Common/TailscaleHelpers.js similarity index 95% rename from roles/desktop/files/quickshell/Common/TailscaleHelpers.js rename to desktop/quickshell/Common/TailscaleHelpers.js index 09365231..8a814611 100644 --- a/roles/desktop/files/quickshell/Common/TailscaleHelpers.js +++ b/desktop/quickshell/Common/TailscaleHelpers.js @@ -49,14 +49,14 @@ function errorMessage(code, message, privileged) { function command(up, privileged, login) { // The timeout runs at the same privilege level as tailscale, so even a // shell restart cannot leave a root-owned login command waiting forever. - var args = ["/usr/bin/timeout", "--kill-after=2s", "20s", "/usr/bin/tailscale"]; + var args = ["timeout", "--kill-after=2s", "20s", "tailscale"]; // A bare up is Tailscale's special reconnect path: adding even --json // makes it reject existing non-default preferences. Only new sign-ins // use JSON; a reconnect that discovers expired credentials also emits // its HTTPS login URL on stderr, which the caller handles separately. args = args.concat(!up ? ["down"] : login === false ? ["up"] : ["up", "--json", "--timeout=15s"]); - return privileged ? ["/usr/bin/pkexec"].concat(args) : args; + return privileged ? ["pkexec"].concat(args) : args; } if (typeof module !== "undefined") diff --git a/roles/desktop/files/quickshell/Common/Theme.qml b/desktop/quickshell/Common/Theme.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Theme.qml rename to desktop/quickshell/Common/Theme.qml index 1062a1a3..2919f3dc 100644 --- a/roles/desktop/files/quickshell/Common/Theme.qml +++ b/desktop/quickshell/Common/Theme.qml @@ -399,14 +399,7 @@ Singleton { readonly property color okBgSoft: Qt.rgba(okSeed.r, okSeed.g, okSeed.b, dark ? 0.10 : 0.08) readonly property color okBorder: Qt.rgba(okSeed.r, okSeed.g, okSeed.b, 0.30) - // The update run's transaction feed. The tags keep the terminal update - // script's cyan/pink source identity, recalibrated for the panel; `well` - // is the one recessed console surface, the only fill that sits below the - // tile instead of above it. - readonly property color feedDnf: SettingsHelpers.ensureContrast( - dark ? "#6cc7ec" : "#1d7fae", copyReferenceBg.toString(), 4.5) - readonly property color feedFlatpak: SettingsHelpers.ensureContrast( - dark ? "#e98fd2" : "#a83d8a", copyReferenceBg.toString(), 4.5) + // The recessed console surface sits below the surrounding tiles. readonly property color well: dark ? Qt.rgba(16 / 255, 14 / 255, 28 / 255, 0.55) : Qt.rgba(24 / 255, 22 / 255, 44 / 255, 0.05) @@ -456,7 +449,7 @@ Singleton { readonly property string fontNumeric: Settings.font === "mono" ? fontMono : "Geist Mono" // Bundled Tabler icons. Sym owns name resolution, font loading and the // consistent outline rendering; views must not draw icon codepoints directly. - readonly property string fontIcon: "Cybex Tabler Outline" + readonly property string fontIcon: "NerdOS Tabler Outline" // One library owns the scale AND its usage by native and plugin surfaces. readonly property var typography: Typography.resolve(fontBaseSize) @@ -546,7 +539,7 @@ Singleton { readonly property int pickerRowHeight: scaled(40) // A panel takes the shared surface corner and everything inside it takes the bar's // chip corner. `surfaceRadius` stays where it is: it is the compositor's - // window rounding (roles/desktop/templates/looknfeel.lua.j2) and the Hug corners + // window rounding (desktop/hypr/looknfeel.lua) and the Hug corners // that have to match it, not a shell-internal design choice. readonly property int popRadius: panelRadius readonly property int cardRadius: chipRadius diff --git a/roles/desktop/files/quickshell/Common/ThemeHelpers.js b/desktop/quickshell/Common/ThemeHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ThemeHelpers.js rename to desktop/quickshell/Common/ThemeHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Themes.qml b/desktop/quickshell/Common/Themes.qml similarity index 93% rename from roles/desktop/files/quickshell/Common/Themes.qml rename to desktop/quickshell/Common/Themes.qml index 61cc79b6..fc21328b 100644 --- a/roles/desktop/files/quickshell/Common/Themes.qml +++ b/desktop/quickshell/Common/Themes.qml @@ -7,7 +7,7 @@ import "ThemeHelpers.js" as ThemeHelpers // Named appearance themes (docs/shell-appearance.md#themes). The theme files, // their validation, wallpaper copying and the mapping of a theme to settings -// live in scripts/theme-packs.py, which `cybex theme` runs too; this lists +// live in scripts/theme-packs.py, which `nerd theme` runs too; this lists // themes for Settings → Appearance and applies one as a single undoable // settings change. Nothing is read until Appearance opens or IPC asks; from // then on a theme folder added or removed by hand appears without a restart. @@ -15,9 +15,9 @@ Singleton { id: root readonly property string script: Quickshell.shellDir + "/scripts/theme-packs.py" - // cybexos-runtime exports the user theme root to the shell. - readonly property string userRoot: Quickshell.env("CYBEXOS_THEME_ROOT") - || Quickshell.env("HOME") + "/.local/share/cybexos/themes" + // nerdos-runtime exports the user theme root to the shell. + readonly property string userRoot: Quickshell.env("NERDOS_THEME_ROOT") + || Quickshell.env("HOME") + "/.local/share/nerdos/themes" property var entries: [] property bool loaded: false // The last failure, for the Theme row's hint. @@ -69,7 +69,7 @@ Singleton { resolveRequest.running = true; } - // `theme use` IPC: a payload `cybex theme apply` resolved. The shell checks + // `theme use` IPC: a payload `nerd theme apply` resolved. The shell checks // it against its own schema before anything changes. Returns "ok" or // "error: ". function use(text) { diff --git a/roles/desktop/files/quickshell/Common/Toggle.qml b/desktop/quickshell/Common/Toggle.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Toggle.qml rename to desktop/quickshell/Common/Toggle.qml diff --git a/roles/desktop/files/quickshell/Common/Typography.js b/desktop/quickshell/Common/Typography.js similarity index 96% rename from roles/desktop/files/quickshell/Common/Typography.js rename to desktop/quickshell/Common/Typography.js index 6a04dba2..ede60c3c 100644 --- a/roles/desktop/files/quickshell/Common/Typography.js +++ b/desktop/quickshell/Common/Typography.js @@ -1,6 +1,6 @@ // Shared native/plugin typography. Logical pixels; output scale belongs to Qt. // Reference: Omarchy 961ec7f39fd0d70c7d2944c5b80585a86713693d, -// shell/Commons/Style.qml. CybexOS uses a 14px default with 18px headings. +// shell/Commons/Style.qml. NerdOS uses a 14px default with 18px headings. // Usage rationale: docs/shell-typography.md. var SCALE = { caption: ["caption", 0.833], bodySmall: ["body-small", 0.917], diff --git a/desktop/quickshell/Common/Updates.qml b/desktop/quickshell/Common/Updates.qml new file mode 100644 index 00000000..cb8542a1 --- /dev/null +++ b/desktop/quickshell/Common/Updates.qml @@ -0,0 +1,72 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// A client of the durable NixOS lifecycle. Closing/reloading the shell never +// owns or interrupts the systemd worker. Every apply names the reviewed build. +Singleton { + id: root + property var sources: ({upstream:{}, nerdos:{}}) + property var deployment: ({state:"idle"}) + readonly property bool available: sources.available !== false && deployment.available !== false + readonly property bool busy: checker.running + readonly property bool runActive: ["discovering", "building", "validating", "activating", "recovering"].includes(deployment.state) + readonly property string runState: runActive ? "running" : deployment.state === "done" ? "done" + : ["failed", "interrupted", "rolled-back", "recovery-failed"].includes(deployment.state) ? "failed" : "idle" + readonly property string error: [sources.upstream.error || "", sources.nerdos.error || "", deployment.error || ""].filter(x => x).join("\n") + readonly property string packageError: sources.upstream.error || "" + readonly property int upstreamCount: sources.upstream.state === "available" ? 1 : 0 + readonly property bool projectAvailable: sources.nerdos.state === "available" + readonly property string projectVersion: sources.nerdos.available || "" + readonly property string installedVersion: sources.nerdos.installed || deployment.installedVersion || "" + readonly property string projectNotes: deployment.notes || sources.nerdos.notes || "" + readonly property int total: upstreamCount + (projectAvailable ? 1 : 0) + readonly property string summary: !available ? (sources.reason || deployment.phase || "System updates are available after installation.") + : busy ? "Checking update sources…" : runActive ? runPhaseLabel + : total > 0 ? "Updates available" : error ? "Some update sources could not be checked" : "No updates reported" + readonly property string checkedLabel: checkedAt ? Qt.formatTime(checkedAt, "HH:mm") : "Not checked" + property var checkedAt: null + readonly property int runPercent: -1 + readonly property int runElapsed: deployment.phaseElapsedSeconds || 0 + readonly property bool runCancelled: false + readonly property string runPhaseLabel: deployment.phase || "" + readonly property string failMessage: deployment.error || deployment.phase || "" + readonly property bool rebootRecommended: ["awaiting-boot", "awaiting-login"].includes(deployment.state) + readonly property string rebootRecommendation: rebootRecommended ? "Reboot when ready, then sign in to confirm health" : "" + readonly property bool kernelPending: rebootRecommended + readonly property bool ready: deployment.state === "ready" + property bool detailsOpen: false + + function parse(text) { try { return JSON.parse(text); } catch (_) { return null; } } + function check() { if (!checker.running) checker.running = true; } + function request(args) { if (available && !requester.running) { requester.command = ["nerd", "update"].concat(args); requester.running = true; } } + function stage(scope) { request(["stage", scope]); } + function run() { stage("both"); } + function apply() { if (ready) request(["apply", deployment.transaction, deployment.reviewToken]); } + function rollback() { request(["rollback"]); } + function confirm() { request(["confirm"]); } + function dismissRun() { check(); } + function openLog() { if (deployment.transaction) Quickshell.execDetached(["nerdos-apps", "terminal", "--hold", "--", "nerd", "update", "log", deployment.transaction]); } + function openRecoveryPoints() { Quickshell.execDetached(["nerdos-apps", "terminal", "--hold", "--", "nerd", "update", "history"]); } + function openFirmware() { Quickshell.execDetached(["nerdos-apps", "terminal", "--hold", "--", "nerdos-firmware-update", "interactive"]); } + + Process { + id: checker + command: ["nerd", "update", "check"] + stdout: StdioCollector { onStreamFinished: { const result = root.parse(text); if (result) { root.sources = result; root.checkedAt = new Date(); } } } + stderr: StdioCollector { onStreamFinished: { if (text.trim()) console.warn("Update check: " + text.trim()); } } + } + Process { + id: requester + stdout: StdioCollector { onStreamFinished: { poll.running = true; } } + stderr: StdioCollector { onStreamFinished: { if (text.trim()) root.deployment = Object.assign({}, root.deployment, {error:text.trim()}); } } + } + Process { + id: poll + command: ["nerd", "update", "status"] + stdout: StdioCollector { onStreamFinished: { const result = root.parse(text); if (result) root.deployment = result; } } + } + Timer { interval: 2000; repeat: true; running: true; triggeredOnStart: true; onTriggered: { if (!poll.running) poll.running = true; } } + Timer { interval: Math.max(10, Number((Settings.modOpts.updates || {}).pollMins) || 60) * 60000; repeat: true; running: true; triggeredOnStart: true; onTriggered: root.check() } +} diff --git a/desktop/quickshell/Common/UpdatesHelpers.js b/desktop/quickshell/Common/UpdatesHelpers.js new file mode 100644 index 00000000..60b4e739 --- /dev/null +++ b/desktop/quickshell/Common/UpdatesHelpers.js @@ -0,0 +1,7 @@ +function rebootLabel(recommendation, kernelPending) { return recommendation || (kernelPending ? "Reboot when ready" : ""); } +function pendingSummary(upstream, apps, firmware, project, version) { + const parts = []; + if (upstream) parts.push("Upstream NixOS packages"); + if (project) parts.push("NerdOS " + version); + return parts.join(" · ") || "Check update sources"; +} diff --git a/roles/desktop/files/quickshell/Common/UserPlugins.qml b/desktop/quickshell/Common/UserPlugins.qml similarity index 97% rename from roles/desktop/files/quickshell/Common/UserPlugins.qml rename to desktop/quickshell/Common/UserPlugins.qml index b079aad5..49525c8c 100644 --- a/roles/desktop/files/quickshell/Common/UserPlugins.qml +++ b/desktop/quickshell/Common/UserPlugins.qml @@ -12,6 +12,7 @@ Singleton { property var widgets: [] property var barConfig: ({}) property string discoveryError: "" + readonly property bool discoveryBusy: scanner.running || refreshPending || registryChanged.running property string mutationError: "" readonly property string error: [mutationError, discoveryError].filter(message => message !== "").join("\n") property string lastResult: "" @@ -28,8 +29,8 @@ Singleton { readonly property var enabledWidgets: widgets.filter(plugin => plugin.enabled) readonly property string helper: Quickshell.shellDir + "/scripts/user-plugins.py" // Resolved as scripts/user-plugins.py roots() does. - readonly property string registryPath: (Quickshell.env("CYBEXOS_USER_CONFIG_ROOT") - || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/cybexos") + readonly property string registryPath: (Quickshell.env("NERDOS_USER_CONFIG_ROOT") + || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/nerdos") + "/plugins.json" function registerHost(host) { diff --git a/roles/desktop/files/quickshell/Common/UserWidgetHost.qml b/desktop/quickshell/Common/UserWidgetHost.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/UserWidgetHost.qml rename to desktop/quickshell/Common/UserWidgetHost.qml diff --git a/roles/desktop/files/quickshell/Common/WallhavenHelpers.js b/desktop/quickshell/Common/WallhavenHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WallhavenHelpers.js rename to desktop/quickshell/Common/WallhavenHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Wallpaper.qml b/desktop/quickshell/Common/Wallpaper.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Wallpaper.qml rename to desktop/quickshell/Common/Wallpaper.qml diff --git a/roles/desktop/files/quickshell/Common/Weather.qml b/desktop/quickshell/Common/Weather.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Weather.qml rename to desktop/quickshell/Common/Weather.qml diff --git a/roles/desktop/files/quickshell/Common/WebIcons.qml b/desktop/quickshell/Common/WebIcons.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/WebIcons.qml rename to desktop/quickshell/Common/WebIcons.qml diff --git a/roles/desktop/files/quickshell/Common/WidgetCatalog.js b/desktop/quickshell/Common/WidgetCatalog.js similarity index 94% rename from roles/desktop/files/quickshell/Common/WidgetCatalog.js rename to desktop/quickshell/Common/WidgetCatalog.js index 145b2c79..79e1f50c 100644 --- a/roles/desktop/files/quickshell/Common/WidgetCatalog.js +++ b/desktop/quickshell/Common/WidgetCatalog.js @@ -30,12 +30,13 @@ var WIDGETS = { gh: { name: "GitHub", short: "GitHub", detail: true, glyph: "code", description: "Watch GitHub repositories and activity." }, updates: { name: "Updates", short: "Updates", tag: "when pending", detail: true, glyph: "update", description: "Check software updates and installation progress." }, tray: { name: "System tray", short: "System tray", tag: "when populated", glyph: "apps", description: "Access background applications." }, + assistant: { name: "Assistant", short: "Assistant", glyph: "assistant", description: "Show or hide the NerdOS Assistant, which answers questions and changes NerdOS for you." }, notifications: { name: "Notifications", short: "Notifications", detail: true, glyph: "notifications", description: "Open notification history and unread messages." }, vol: { name: "Volume", short: "Volume", detail: true, glyph: "volume_up", description: "Adjust volume and audio devices." }, wifi: { name: "Network", short: "Network", glyph: "wifi", description: "Manage network connections." }, bt: { name: "Bluetooth", short: "Bluetooth", tag: "when connected", glyph: "bluetooth", description: "Manage connected Bluetooth devices." }, batt: { name: "Battery", short: "Battery", tag: "on laptops", detail: true, glyph: "battery_full", description: "Monitor battery charge and power status." }, - control: { name: "Control Center", short: "Control Center", glyph: "tune", description: "Open Control Center from the Fedora button. Configure its tabs, overview, and behavior." } + control: { name: "Control Center", short: "Control Center", glyph: "tune", description: "Open Control Center from the NerdOS button. Configure its tabs, overview, and behavior." } }; // Never null: a widget id that outlived its catalog entry still has to draw a diff --git a/roles/desktop/files/quickshell/Common/WidgetEditor.js b/desktop/quickshell/Common/WidgetEditor.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WidgetEditor.js rename to desktop/quickshell/Common/WidgetEditor.js diff --git a/roles/desktop/files/quickshell/Common/WifiState.qml b/desktop/quickshell/Common/WifiState.qml similarity index 90% rename from roles/desktop/files/quickshell/Common/WifiState.qml rename to desktop/quickshell/Common/WifiState.qml index e6339c74..c2009f34 100644 --- a/roles/desktop/files/quickshell/Common/WifiState.qml +++ b/desktop/quickshell/Common/WifiState.qml @@ -5,9 +5,8 @@ import Quickshell.Networking import "StatusHelpers.js" as StatusHelpers // The Wi-Fi device and its active network. `Networking.devices` lists wired -// and wireless alike and only the wireless ones carry a `networks` list, so -// that is what identifies the radio — the same find() the bar, the control -// centre and the Wi-Fi popover each wrote separately. +// and wireless alike. Networks belong to the base NetworkDevice in Quickshell +// 0.3, so select the explicit device type before accessing Wi-Fi-only state. // // Named WifiState, not Network: Quickshell.Networking already exports a // `Network` type, and a consumer importing both would resolve the name by @@ -15,7 +14,7 @@ import "StatusHelpers.js" as StatusHelpers Singleton { id: root - readonly property var device: Networking.devices.values.find(d => d.networks !== undefined) ?? null + readonly property var device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null readonly property var active: device ? (device.networks.values.find(n => n.connected) ?? null) : null readonly property bool connected: active !== null readonly property string name: active ? active.name : "" diff --git a/roles/desktop/files/quickshell/Common/WindowCommands.js b/desktop/quickshell/Common/WindowCommands.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WindowCommands.js rename to desktop/quickshell/Common/WindowCommands.js diff --git a/roles/desktop/files/quickshell/Common/WorkspaceHelpers.js b/desktop/quickshell/Common/WorkspaceHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WorkspaceHelpers.js rename to desktop/quickshell/Common/WorkspaceHelpers.js diff --git a/roles/desktop/files/quickshell/Common/WorkspaceMotion.js b/desktop/quickshell/Common/WorkspaceMotion.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WorkspaceMotion.js rename to desktop/quickshell/Common/WorkspaceMotion.js diff --git a/roles/desktop/files/quickshell/Common/i18n/nl.js b/desktop/quickshell/Common/i18n/nl.js similarity index 79% rename from roles/desktop/files/quickshell/Common/i18n/nl.js rename to desktop/quickshell/Common/i18n/nl.js index 0c6c99cf..ab9c74b5 100644 --- a/roles/desktop/files/quickshell/Common/i18n/nl.js +++ b/desktop/quickshell/Common/i18n/nl.js @@ -1,6 +1,10 @@ // nl translations of the shell's English text, keyed by that text. // Maintained with scripts/i18n-catalog; see docs/shell-translations.md. var MESSAGES = { + " matching packages · showing up to 200": " overeenkomende pakketten · maximaal 200 zichtbaar", + "%1 is not installed": "%1 is niet geïnstalleerd", + "%1 · off until added": "%1 · uit tot je hem toevoegt", + ". Shared paths stay while another profile or recovery point needs them.": ". Gedeelde paden blijven behouden zolang een ander profiel of herstelpunt ze nodig heeft.", "1 hour": "1 uur", "1 line": "1 regel", "1.0× is Hyprland's default": "1.0× is de standaard van Hyprland", @@ -16,7 +20,6 @@ var MESSAGES = { "7 days": "7 dagen", "A desktop notification when an account trips, except for rate limits.": "Een bureaubladmelding wanneer een account uitvalt, behalve bij snelheidslimieten.", "A display turned off here turns back on by itself when the displays it was turned off beside are disconnected.": "Een beeldscherm dat je hier uitschakelt, gaat vanzelf weer aan wanneer de beeldschermen waarnaast het is uitgeschakeld, worden losgekoppeld.", - "A restored recovery point starts at the next restart.": "Een hersteld herstelpunt wordt actief bij de volgende herstart.", "A thin bar counts down the time a toast has left": "Een dunne balk telt de resterende tijd van een pop-upmelding af", "ACCOUNT": "ACCOUNT", "ACTIONS": "ACTIES", @@ -58,9 +61,12 @@ var MESSAGES = { "Add to Center": "In het midden toevoegen", "Add to Left": "Links toevoegen", "Add to Right": "Rechts toevoegen", + "Add to bar": "Aan balk toevoegen", "Add widgets": "Widgets toevoegen", + "Added to the bar.": "Aan de balk toegevoegd.", "Additional DNS": "Extra DNS", "Address": "Adres", + "Adds a screen button to the box. It sends one screenshot of the display the assistant is on, without the assistant, with that question only, and deletes it afterwards.": "Voegt een schermknop toe aan het vak. Die stuurt één schermafbeelding van het beeldscherm waarop de assistent staat, zonder de assistent, alleen met die vraag mee, en verwijdert hem daarna.", "Adjust zoom": "Zoom aanpassen", "Admin console": "Beheerconsole", "Advanced audio controls": "Geavanceerde audiobediening", @@ -69,9 +75,12 @@ var MESSAGES = { "Advanced local model-provider setup": "Geavanceerde configuratie van lokale modelprovider", "Advanced text options": "Geavanceerde tekstopties", "Affects touchpads and mice. Per-device Hyprland rules take precedence.": "Geldt voor touchpads en muizen. Hyprland-regels per apparaat hebben voorrang.", + "Agent": "Agent", "Alert below": "Alarm onder", "All the way left follows the shell's corners": "Helemaal links volgt de hoeken van de shell", + "Allow": "Toestaan", "Allow once": "Eenmalig toestaan", + "Allow this?": "Dit toestaan?", "Also how often quiet repos' workflow runs are read; busy ones every minute": "Ook hoe vaak workflowruns van rustige repo's worden gelezen; drukke elke minuut", "Alt + Shift": "Alt + Shift", "Alternate language": "Alternatieve taal", @@ -79,21 +88,29 @@ var MESSAGES = { "Always allow persists beyond this one request. Select Confirm always to continue.": "Altijd toestaan blijft ook na dit ene verzoek gelden. Kies Altijd bevestigen om door te gaan.", "Always allow this session": "Altijd toestaan in deze sessie", "Always compact": "Altijd compact", + "Always in this conversation": "Altijd in dit gesprek", "Always show": "Altijd tonen", "Amber below": "Oranje onder", - "An external drive is best. Backups go into a cybexos-backup folder inside it.": "Een externe schijf is het beste. Back-ups komen in een submap cybexos-backup.", + "An external drive is best. Backups go into a nerdos-backup folder inside it.": "Een externe schijf is het beste. Back-ups komen in een submap nerdos-backup.", + "Answered by": "Antwoord van", + "Answered by %1. Change the agent.": "Antwoord van %1. Kies een andere agent.", "App icons": "App-pictogrammen", "Appearance": "Weergave", "Appearance, bar, widgets and system": "Weergave, balk, widgets en systeem", "Application audio": "Audio van toepassingen", "Applications": "Toepassingen", - "Applies to every account on this computer. The password always works too.": "Geldt voor elk account op deze computer. Het wachtwoord blijft ook altijd werken.", + "Applied": "Toegepast", + "Applies immediately to your account. Your own shortcuts and explicit editor overrides keep their choices.": "Wordt direct toegepast op je account. Je eigen sneltoetsen en expliciete editorinstellingen behouden hun keuzes.", "Apply": "Toepassen", "Apply language": "Taal toepassen", "Apply preset": "Voorinstelling toepassen", "Apply reset": "Reset toepassen", + "Apply reviewed change": "Beoordeelde wijziging toepassen", + "Apply reviewed generation": "Gecontroleerde generatie toepassen", "Apply timezone": "Tijdzone toepassen", + "Applying asks for the administrator password. A reboot may be needed afterwards; it never happens on its own.": "Toepassen vraagt om het beheerderswachtwoord. Daarna kan een herstart nodig zijn; die gebeurt nooit vanzelf.", "Applying…": "Toepassen…", + "Apply…": "Toepassen…", "Approval": "Goedkeuring", "Approve": "Goedkeuren", "Apps": "Apps", @@ -102,9 +119,15 @@ var MESSAGES = { "Arrange widgets": "Widgets rangschikken", "Arrangement": "Opstelling", "Artist — Title": "Artiest — Titel", + "Ask NerdOS anything…": "Vraag NerdOS wat je wilt…", "Ask anything…": "Vraag iets…", "Ask first": "Eerst vragen", + "Ask the NerdOS Assistant": "Vraag het de NerdOS-assistent", + "Ask the NerdOS Assistant; hold to talk": "Vraag het de NerdOS-assistent; ingedrukt houden om te praten", + "Assistant": "Assistent", + "Assistant settings": "Assistentinstellingen", "At sign-in": "Bij inloggen", + "Attach the screen": "Scherm bijvoegen", "Attached": "Verankerd", "Audio": "Audio", "Authenticate": "Authenticeren", @@ -119,7 +142,11 @@ var MESSAGES = { "Automatic": "Automatisch", "Automatic DNS": "Automatische DNS", "Automatic backups": "Automatische back-ups", + "Automatic follows your nerd agent default, then whichever is installed. Sign-in and permissions stay with the agent itself.": "Automatisch volgt je standaard van nerd agent en anders wat er geïnstalleerd is. Inloggen en rechten blijven bij de agent zelf.", + "Automatic weekly cleanup": "Automatisch wekelijks opruimen", "Available": "Beschikbaar", + "Available applications": "Beschikbare toepassingen", + "Available space": "Beschikbare ruimte", "Awake": "Wakker", "BATTERY HEALTH": "BATTERIJCONDITIE", "BATTERY TELEMETRY": "BATTERIJGEGEVENS", @@ -142,11 +169,11 @@ var MESSAGES = { "Bar indicator": "Balkindicator", "Bar preview": "Voorbeeld van balk", "Bar providers": "Providers in balk", - "Bar widgets, panels, overlays, menus and services made for Omarchy. They run on CybexOS unchanged.": "Balkwidgets, panelen, overlays, menu's en services gemaakt voor Omarchy. Ze werken ongewijzigd op CybexOS.", + "Bar widgets, panels, overlays, menus and services made for Omarchy. They run on NerdOS unchanged.": "Balkwidgets, panelen, overlays, menu's en services gemaakt voor Omarchy. Ze werken ongewijzigd op NerdOS.", "Base font size": "Basislettergrootte", "Battery": "Batterij", "Behavior": "Gedrag", - "Bindings you add in ~/.config/cybexos/hypr/user.lua appear here when they have a description.": "Sneltoetsen die je toevoegt in ~/.config/cybexos/hypr/user.lua verschijnen hier als ze een beschrijving hebben.", + "Bindings you add in ~/.config/nerdos/hypr/user.lua appear here when they have a description.": "Sneltoetsen die je toevoegt in ~/.config/nerdos/hypr/user.lua verschijnen hier als ze een beschrijving hebben.", "Bluetooth": "Bluetooth", "Bluetooth PIN or passkey": "Bluetooth-pincode of -toegangscode", "Bluetooth connected": "Bluetooth verbonden", @@ -163,19 +190,22 @@ var MESSAGES = { "Browse": "Bladeren", "Browse plugins": "Plug-ins bekijken", "Browser": "Browser", + "Build preview": "Voorbeeld bouwen", "Building QR code…": "QR-code maken…", + "Built changes": "Gebouwde wijzigingen", + "Built package changes": "Gebouwde pakketwijzigingen", "Burst limit": "Meldingslimiet", "CALC": "REKENEN", "CI reports": "CI-rapporten", "CLIPBOARD": "KLEMBORD", "CONNECTION": "VERBINDING", "CONNECTIVITY": "VERBINDINGEN", - "COULDN’T CHECK": "CONTROLE MISLUKT", "CPU": "CPU", "CPU usage": "CPU-gebruik", "Calculator": "Rekenmachine", "Calendar": "Agenda", "Cancel": "Annuleren", + "Cancel the recording": "Opname annuleren", "Capacity": "Capaciteit", "Caps Lock": "Caps Lock", "Capture": "Vastleggen", @@ -184,15 +214,22 @@ var MESSAGES = { "Category": "Categorie", "Certificates? Network Settings": "Certificaten? Netwerkinstellingen", "Change": "Wijzigen", + "Change password": "Wachtwoord wijzigen", "Change the backup destination": "Back-upbestemming wijzigen", + "Change your login and disk encryption password together. Use at least 8 characters.": "Wijzig je aanmeld- en schijfversleutelingswachtwoord samen. Gebruik minimaal 8 tekens.", "Changes the timezone for everyone on this computer. Authorization may be required.": "Wijzigt de tijdzone voor iedereen op deze computer. Mogelijk is autorisatie vereist.", "Change…": "Wijzigen…", + "Character": "Personage", "Check every": "Controleren elke", "Check for updates": "Controleren op updates", "Check outcome": "Resultaat controleren", + "Check sign-in": "Inlogstatus controleren", + "Check signed device firmware and confirm installation separately": "Ondertekende apparaatfirmware controleren en installatie afzonderlijk bevestigen", "Checking": "Controleren", "Checking Ethernet…": "Ethernet controleren…", + "Checking sign-in…": "Inlogstatus controleren…", "Checking…": "Controleren…", + "Choose Claude Code or Codex and sign in to your account. Then you can ask your first question.": "Kies Claude Code of Codex en log in op je account. Daarna kun je je eerste vraag stellen.", "Choose SSH host": "SSH-host kiezen", "Choose a backup folder": "Back-upmap kiezen", "Choose a section": "Kies een sectie", @@ -203,16 +240,24 @@ var MESSAGES = { "Choose media player": "Mediaspeler kiezen", "Choose wallpaper folder": "Achtergrondmap kiezen", "Choose what to back up and where": "Kiezen wat er in de back-up komt en waar die wordt bewaard", + "Choose your browser, terminal, editor and viewers": "Kies je browser, terminal, editor en viewers", "Choose…": "Kiezen…", "City name, e.g. Amsterdam": "Plaatsnaam, bijv. Amsterdam", "City or region": "Stad of regio", "Classic": "Klassiek", "Claude Code": "Claude Code", "Claude Code CLI": "Claude Code CLI", + "Claude Code model": "Model van Claude Code", + "Claude Code's default": "Standaard van Claude Code", "Claude model": "Claude-model", + "Clean reviewed items": "Beoordeelde onderdelen opruimen", + "Cleanup preferences": "Opruimvoorkeuren", + "Cleanup preferences changed elsewhere. Discard your draft and review the current preferences.": "De opruimvoorkeuren zijn elders gewijzigd. Verwerp je concept en bekijk de huidige voorkeuren.", + "Cleanup removes obsolete system references and unreachable Nix store contents. Documents, downloads, application data and saved personal configuration stay yours.": "Opruimen verwijdert verouderde systeemverwijzingen en onbereikbare inhoud uit de Nix-store. Documenten, downloads, toepassingsgegevens en opgeslagen persoonlijke configuratie blijven behouden.", "Clear all": "Alles wissen", "Clear notification history": "Meldingsgeschiedenis wissen", "Cleared after this attempt": "Gewist na deze poging", + "Click to ask a question. Drag to move.": "Klik om een vraag te stellen. Sleep om te verplaatsen.", "Clipboard": "Klembord", "Clipboard history": "Klembordgeschiedenis", "Clock": "Klok", @@ -223,13 +268,15 @@ var MESSAGES = { "Close": "Sluiten", "Close network dialog": "Netwerkvenster sluiten", "Close restore": "Herstellen sluiten", + "Close the reply": "Antwoord sluiten", "Close widget settings": "Widgetinstellingen sluiten", "Close window": "Venster sluiten", "Code": "Code", "Codex CLI": "Codex CLI", "Codex model": "Codex-model", + "Codex's default": "Standaard van Codex", "Collapse group": "Groep inklappen", - "Collect diagnostics to share with CybexOS": "Diagnosegegevens verzamelen om met CybexOS te delen", + "Collect diagnostics to share with NerdOS": "Diagnosegegevens verzamelen om met NerdOS te delen", "Colors": "Kleuren", "Combined": "Samen", "Comfortable": "Comfortabel", @@ -241,11 +288,13 @@ var MESSAGES = { "Configure local provider": "Lokale provider configureren", "Confirm": "Bevestigen", "Confirm clear all": "Alles wissen bevestigen", - "Confirm restore": "Herstel bevestigen", "Confirm retry": "Opnieuw proberen bevestigen", + "Confirm this boot": "Deze opstart bevestigen", + "Confirm this boot and desktop": "Deze opstart en desktop bevestigen", + "Confirmed deployments to keep": "Te bewaren bevestigde installaties", "Connect": "Verbinden", "Connect Google": "Google koppelen", - "Connect an account to show its calendars in CybexOS. Signing in opens a separate account window and your browser.": "Koppel een account om de agenda's ervan in CybexOS te tonen. Bij het inloggen worden een apart accountvenster en je browser geopend.", + "Connect an account to show its calendars in NerdOS. Signing in opens a separate account window and your browser.": "Koppel een account om de agenda's ervan in NerdOS te tonen. Bij het inloggen worden een apart accountvenster en je browser geopend.", "Connect another account": "Nog een account koppelen", "Connect automatically when this network is available": "Automatisch verbinden wanneer dit netwerk beschikbaar is", "Connect to Wi-Fi…": "Verbinden met Wi-Fi…", @@ -256,12 +305,14 @@ var MESSAGES = { "Connection setup": "Verbinding instellen", "Connections": "Verbindingen", "Content redacted": "Inhoud afgeschermd", - "Continue in terminal": "Doorgaan in terminal", + "Continue in a terminal": "Verdergaan in een terminal", + "Continuing the conversation": "Het gesprek gaat verder", "Control Center": "Bedieningspaneel", "Control Center widget": "Widget Bedieningspaneel", "Control Panel": "Bedieningspaneel", "Controls appear as soon as a player starts.": "Bediening verschijnt zodra een speler start.", "Copied": "Gekopieerd", + "Copied.": "Gekopieerd.", "Copies the plugin under this ID for you to change. The copy is turned on and this one off.": "Kopieert de plug-in onder deze ID zodat je hem kunt aanpassen. De kopie wordt ingeschakeld en deze uitgeschakeld.", "Copy": "Kopiëren", "Copy SHA": "SHA kopiëren", @@ -274,6 +325,7 @@ var MESSAGES = { "Copy text or QR code from a region": "Tekst of QR-code kopiëren uit een gebied", "Copy the hex value of any pixel": "De hexwaarde van een willekeurige pixel kopiëren", "Copy the recovery key": "Herstelsleutel kopiëren", + "Copy the reply": "Antwoord kopiëren", "Copy verification code": "Verificatiecode kopiëren", "Copying full patch…": "Volledige patch kopiëren…", "Corner radius": "Hoekradius", @@ -281,6 +333,7 @@ var MESSAGES = { "Cost sources": "Kostenbronnen", "Could not load": "Laden mislukt", "Count": "Aantal", + "Create a CPU temperature widget": "Maak een widget voor de CPU-temperatuur", "Critical alerts ignore the timer and stay until dismissed": "Kritieke meldingen negeren de timer en blijven staan tot je ze sluit", "Critical below": "Kritiek onder", "Ctrl + Shift": "Ctrl + Shift", @@ -295,8 +348,6 @@ var MESSAGES = { "Custom DNS servers": "Aangepaste DNS-servers", "Custom height": "Aangepaste hoogte", "Cut": "Knippen", - "CybexOS": "CybexOS", - "CybexOS Settings": "CybexOS-instellingen", "Cycle workspace layout": "Werkbladindeling wisselen", "Cycles": "Cycli", "DISK /": "SCHIJF /", @@ -310,10 +361,12 @@ var MESSAGES = { "Dashboard ↗": "Dashboard ↗", "Date": "Datum", "Date format": "Datumnotatie", + "Days of recovery history": "Dagen herstelgeschiedenis", "Debian container shell": "Debian-containershell", "Decline": "Afwijzen", "Default": "Standaard", - "Default keeps CybexOS's policy for this display. Fullscreen enables variable refresh only for fullscreen windows.": "Standaard behoudt het CybexOS-beleid voor dit beeldscherm. Volledig scherm schakelt variabele verversing alleen in voor vensters op volledig scherm.", + "Default applications": "Standaardtoepassingen", + "Default keeps NerdOS's policy for this display. Fullscreen enables variable refresh only for fullscreen windows.": "Standaard behoudt het NerdOS-beleid voor dit beeldscherm. Volledig scherm schakelt variabele verversing alleen in voor vensters op volledig scherm.", "Default layout": "Standaardindeling", "Default workspace layout": "Standaardwerkbladindeling", "Deletes its files. Its settings and data stay, so installing it again restores them.": "Verwijdert de bestanden. De instellingen en gegevens blijven bewaard, dus bij opnieuw installeren worden ze hersteld.", @@ -324,13 +377,16 @@ var MESSAGES = { "Detail": "Detail", "Details": "Details", "Development": "Ontwikkeling", + "Device firmware…": "Apparaatfirmware…", "Devices": "Apparaten", "Devices join with the hotspot's name and password, or by scanning its QR code.": "Apparaten maken verbinding met de naam en het wachtwoord van de hotspot, of door de QR-code te scannen.", "Dictate in Dutch": "Dicteren in het Nederlands", "Dictate in English": "Dicteren in het Engels", "Dictation": "Dicteren", + "Dictation settings": "Dicteerinstellingen", "Disabled": "Uitgeschakeld", "Discard": "Verwerpen", + "Discard selections": "Selecties verwerpen", "Dismiss": "Sluiten", "Dismiss group": "Groep sluiten", "Dismiss notification from %1": "Melding van %1 sluiten", @@ -367,16 +423,21 @@ var MESSAGES = { "Edited Hermes message": "Bewerkt Hermes-bericht", "Effort": "Inspanning", "Elapsed time": "Verstreken tijd", + "Eligible for cleanup": "Kan worden opgeruimd", "Empty. Drag a widget here, or add one from the widgets below.": "Leeg. Sleep hier een widget naartoe of voeg er een toe uit de widgets hieronder.", "Enable or disable notification toasts": "Pop-upmeldingen in- of uitschakelen", + "Enabled": "Ingeschakeld", "Encrypted copies of your home folder on an external drive or another computer.": "Versleutelde kopieën van je thuismap op een externe schijf of een andere computer.", "End process": "Proces beëindigen", "Enroll a fingerprint for unlocking and sign-in": "Een vingerafdruk registreren voor ontgrendelen en inloggen", + "Enter sends · Esc closes": "Enter verstuurt · Esc sluit", + "Enter sends. Shift Enter inserts a newline.": "Enter verstuurt. Shift Enter voegt een nieuwe regel in.", "Enter to restore or switch · drag to move · Page Up/Down for workspaces": "Enter om te herstellen of wisselen · slepen om te verplaatsen · Page Up/Down voor werkbladen", "Enter to send. Control Enter inserts a newline.": "Enter om te verzenden. Control Enter voegt een nieuwe regel in.", "Enterprise certificates, bridges and routing are in the advanced editor.": "Enterprise-certificaten, bridges en routering vind je in de geavanceerde editor.", "Enterprise identity": "Enterprise-identiteit", "Error": "Fout", + "Estimated system contents: ": "Geschatte systeeminhoud: ", "Ethernet": "Ethernet", "Events": "Afspraken", "Every page back to its defaults. You can undo for 8 seconds.": "Elke pagina terug naar de standaardwaarden. Je kunt dit 8 seconden lang ongedaan maken.", @@ -386,15 +447,14 @@ var MESSAGES = { "Exit Hyprland": "Hyprland afsluiten", "Expand commit": "Commit uitvouwen", "Expand group": "Groep uitklappen", + "Explain what is on my screen": "Leg uit wat er op mijn scherm staat", "Explicit shell command": "Expliciete shell-opdracht", "Export databases to a readable home folder before backup. Stop databases when backing up their files directly; private container volumes still need a readable export.": "Exporteer databases voor de back-up naar een leesbare map in je thuismap. Stop databases als je hun bestanden rechtstreeks opneemt; privécontainervolumes hebben nog steeds een leesbare export nodig.", "Extra notifications collect in the center": "Extra meldingen worden verzameld in het meldingencentrum", "FILES": "BESTANDEN", "Factory reset": "Fabrieksinstellingen terugzetten", - "Factory reset…": "Fabrieksinstellingen terugzetten…", "Faults": "Storingen", "Favourites": "Favorieten", - "Fedora container shell": "Fedora-containershell", "File or folder": "Bestand of map", "Files": "Bestanden", "Filesystem": "Bestandssysteem", @@ -407,7 +467,7 @@ var MESSAGES = { "Finger": "Vinger", "Fingerprint": "Vingerafdruk", "Fingerprint, security keys and sign-in": "Vingerafdruk, beveiligingssleutels en inloggen", - "Firmware": "Firmware", + "Finish signing in in the terminal and browser. This box will update when you’re connected.": "Rond het inloggen af in de terminal en browser. Dit vak wordt bijgewerkt zodra je verbonden bent.", "Fixed": "Vast", "Flipped": "Gespiegeld", "Floating": "Zwevend", @@ -445,19 +505,23 @@ var MESSAGES = { "Hermes conversation: %1": "Hermes-gesprek: %1", "Hidden providers stay in the panel": "Verborgen providers blijven in het paneel", "Hidden until it clears or changes": "Verborgen tot het is opgelost of verandert", + "Hide": "Verbergen", "Hide account emails": "E-mailadressen van accounts verbergen", "Hide advanced settings": "Geavanceerde instellingen verbergen", "Hide coordinates": "Coördinaten verbergen", "Hide details": "Details verbergen", "Hide empty": "Lege verbergen", "Hide password": "Wachtwoord verbergen", + "Hide the assistant": "Assistent verbergen", "Hides until you point at the bottom edge": "Verdwijnt tot je naar de onderrand wijst", "Hides when idle; point at the screen edge to bring it back": "Verdwijnt bij inactiviteit; wijs naar de schermrand om hem terug te halen", "High contrast": "Hoog contrast", "History limit": "Geschiedenislimiet", "History storage unavailable": "Opslag van geschiedenis niet beschikbaar", + "Hold Super+I to talk": "Houd Super+I ingedrukt om te praten", "Hotspot": "Hotspot", "Hottest sensor": "Warmste sensor", + "Hover the character or tap Super+I to type. Hold Super+I to talk, and release to send.": "Wijs het personage aan of tik op Super+I om te typen. Houd Super+I ingedrukt om te praten en laat los om te versturen.", "Hue": "Tint", "Hug": "Omsluitend", "I saved it": "Ik heb hem bewaard", @@ -470,11 +534,11 @@ var MESSAGES = { "Idle": "Inactief", "Idle inhibit": "Wakker houden", "Idle settings": "Inactiviteitsinstellingen", + "Image viewer": "Afbeeldingsviewer", "Import VPN": "VPN importeren", "In the bar": "In de balk", "Inbox": "Inbox", "Inbox is clear": "Inbox is leeg", - "Include Flatpak": "Flatpak meenemen", "Includes readable home files, Steam saves and rootless container volumes. Caches, downloaded games, container images and mounted filesystems are excluded.": "Bevat leesbare persoonlijke bestanden, Steam-opslagbestanden en rootless containervolumes. Caches, gedownloade spellen, containerimages en aangekoppelde bestandssystemen worden overgeslagen.", "Indicator settings": "Indicatorinstellingen", "Input": "Invoer", @@ -483,8 +547,10 @@ var MESSAGES = { "Input level": "Invoerniveau", "Input volume": "Invoervolume", "Install": "Installeren", + "Install and remove applications": "Toepassingen installeren en verwijderen", "Install theme from Git…": "Thema installeren vanuit Git…", "Install web app…": "Web-app installeren…", + "Installed": "Geïnstalleerd", "Installed locales only. Applies system wide after signing out; explicit regional format overrides are preserved.": "Alleen geïnstalleerde landinstellingen. Geldt systeembreed na afmelden; expliciet ingestelde regionale notaties blijven behouden.", "Installed plugins": "Geïnstalleerde plug-ins", "Interface font": "Interfacelettertype", @@ -496,7 +562,6 @@ var MESSAGES = { "JSON value, for example true, 42, or \"text\"": "JSON-waarde, bijvoorbeeld true, 42 of \"tekst\"", "KNOWN NETWORKS": "BEKENDE NETWERKEN", "Keep it somewhere safe, such as a password manager or on paper. It opens your backups on any computer. If this computer's keyring and this key are both lost, the backups cannot be opened. It is not shown again.": "Bewaar hem op een veilige plek, bijvoorbeeld in een wachtwoordbeheerder of op papier. Hiermee open je je back-ups op elke computer. Als zowel de sleutelbos van deze computer als deze sleutel verloren gaan, kunnen de back-ups niet meer worden geopend. De sleutel wordt niet opnieuw getoond.", - "Keep this system": "Dit systeem behouden", "Keeps mode, colors, bar and font. The wallpaper is not included.": "Bewaart modus, kleuren, balk en lettertype. De achtergrond wordt niet meegenomen.", "Keyboard": "Toetsenbord", "Keyboard changes not applied yet": "Toetsenbordwijzigingen nog niet toegepast", @@ -510,6 +575,7 @@ var MESSAGES = { "Large": "Groot", "Larger": "Groter", "Last backup": "Laatste back-up", + "Last cleanup removed": "Verwijderd bij de laatste opruiming", "Last deploy check": "Laatste implementatiecontrole", "Last error": "Laatste fout", "Latest commits": "Recente commits", @@ -522,6 +588,7 @@ var MESSAGES = { "Layouts apply to your desktop session.": "Indelingen gelden voor je bureaubladsessie.", "Leave empty to use the default route. Enter an interface name to pin it.": "Laat leeg om de standaardroute te gebruiken. Voer een interfacenaam in om die vast te zetten.", "Legacy models": "Oudere modellen", + "Let’s connect your assistant": "Laten we je assistent verbinden", "Library": "Bibliotheek", "Light": "Licht", "Lightness": "Lichtheid", @@ -534,6 +601,7 @@ var MESSAGES = { "Loading more…": "Meer laden…", "Loading peers…": "Peers laden…", "Loading repositories…": "Repository's laden…", + "Loading the machine’s locked package catalogue…": "De vastgezette pakketcatalogus van deze computer laden…", "Loading wallpapers…": "Achtergronden laden…", "Loading…": "Laden…", "Local provider credentials are stored by Hermes in ~/.hermes/.env. Remote WebUI passwords are never retained by the menubar.": "Inloggegevens van lokale providers worden door Hermes opgeslagen in ~/.hermes/.env. Wachtwoorden voor externe WebUI's worden nooit door de menubalk bewaard.", @@ -542,21 +610,26 @@ var MESSAGES = { "Lock, suspend, log out, restart or shut down": "Vergrendelen, slaapstand, afmelden, opnieuw opstarten of uitschakelen", "Locks the screen first": "Vergrendelt eerst het scherm", "Log out": "Afmelden", + "Logo only": "Alleen logo", "Longitude": "Lengtegraad", + "Look": "Uiterlijk", "Look ahead": "Vooruitkijken", - "Look for CybexOS, package and firmware updates": "Zoeken naar updates voor CybexOS, pakketten en firmware", + "Look for NerdOS and NixOS package updates": "Zoeken naar updates voor NerdOS en NixOS-pakketten", "Lower is warmer": "Lager is warmer", "MACHINE": "COMPUTER", "MEMORY": "GEHEUGEN", "MICROPHONE": "MICROFOON", "MORE WIDGETS": "MEER WIDGETS", "MUTE": "GEDEMPT", - "Manage": "Beheren", + "Make the menubar transparent": "Maak de menubalk doorzichtig", "Manage Omarchy plugins…": "Omarchy-plug-ins beheren…", "Manage this plugin": "Deze plug-in beheren", + "Managed by existing configuration": "Beheerd door bestaande configuratie", + "Managed in /etc/nixos/local": "Beheerd in /etc/nixos/local", "Management key": "Beheersleutel", "Manual": "Handmatig", "Markdown note": "Markdown-notitie", + "Mascot": "Mascotte", "Match shell style": "Shellstijl volgen", "Max width": "Max. breedte", "Maximize / restore": "Maximaliseren / herstellen", @@ -613,6 +686,9 @@ var MESSAGES = { "Native titlebars for windows that need them": "Native titelbalken voor vensters die ze nodig hebben", "Natural scrolling": "Natuurlijk scrollen", "Needs you": "Wacht op jou", + "Needs your answer": "Wacht op je antwoord", + "NerdOS Assistant": "NerdOS-assistent", + "NerdOS Settings": "NerdOS-instellingen", "Network": "Netwerk", "Network connections": "Netwerkverbindingen", "Network interface": "Netwerkinterface", @@ -622,6 +698,7 @@ var MESSAGES = { "Never": "Nooit", "New ID": "Nieuwe ID", "New chat": "Nieuwe chat", + "New conversation": "Nieuw gesprek", "New key…": "Nieuwe sleutel…", "New sessions": "Nieuwe sessies", "New since last visit": "Nieuw sinds laatste bezoek", @@ -636,6 +713,8 @@ var MESSAGES = { "Night light settings": "Instellingen voor nachtlicht", "Nightly": "Nightly", "Nights": "'s Nachts", + "Nix development shell": "Nix-ontwikkelomgeving", + "NixOS generations retain system code and configuration. Credentials, VPN profiles, documents, databases and plugin data stay in persistent storage. Generation rollback does not restore application data.": "NixOS-generaties bewaren systeemcode en configuratie. Inloggegevens, VPN-profielen, documenten, databases en plugingegevens blijven in permanente opslag. Terugkeren naar een generatie herstelt geen toepassingsgegevens.", "No": "Nee", "No Bluetooth adapter": "Geen Bluetooth-adapter", "No Ethernet ports": "Geen ethernetpoorten", @@ -651,6 +730,8 @@ var MESSAGES = { "No models found": "Geen modellen gevonden", "No notes yet": "Nog geen notities", "No notifications": "Geen meldingen", + "No package changes; configuration only.": "Geen pakketwijzigingen; alleen configuratie.", + "No package closure changes": "Geen wijzigingen in pakketten", "No paired devices": "Geen gekoppelde apparaten", "No plugins yet": "Nog geen plug-ins", "No repositories this account can see": "Geen repository's zichtbaar voor dit account", @@ -658,16 +739,21 @@ var MESSAGES = { "No sessions": "Geen sessies", "No sources snoozed": "Geen bronnen gesluimerd", "None": "Geen", + "Not installed. Add it in Software.": "Niet geïnstalleerd. Voeg het toe via Software.", + "Not now": "Niet nu", + "Not part of this installation": "Geen onderdeel van deze installatie", "Not stored by the menubar": "Niet opgeslagen door de menubalk", "Note title": "Notitietitel", "Notes": "Notities", "Nothing": "Niets", "Nothing new": "Niets nieuws", "Nothing playing": "Er wordt niets afgespeeld", + "Nothing to continue yet.": "Er is nog niets om mee verder te gaan.", + "Nothing was heard.": "Er was niets te horen.", "Notification history": "Meldingsgeschiedenis", "Notifications": "Meldingen", "Notify about faults": "Melden bij storingen", - "Notify when updates appear": "Melden wanneer er updates zijn", + "Now %1": "Nu %1", "Now playing": "Nu aan het afspelen", "Numbers": "Cijfers", "OCR": "OCR", @@ -678,26 +764,27 @@ var MESSAGES = { "Omarchy plugins": "Omarchy-plug-ins", "On": "Aan", "On-screen display": "Schermweergave", - "One is taken automatically before each update.": "Vóór elke update wordt er automatisch een gemaakt.", "Online": "Online", "Online accounts": "Online-accounts", "Only on battery": "Alleen op batterij", "Only show workspaces that have windows": "Alleen werkruimten met vensters tonen", "Open": "Openen", "Open Control Panel": "Bedieningspaneel openen", - "Open CybexOS Settings": "CybexOS-instellingen openen", "Open Indicators widget settings": "Widgetinstellingen van Indicatoren openen", + "Open NerdOS Settings": "NerdOS-instellingen openen", "Open Network Settings": "Netwerkinstellingen openen", "Open Security settings": "Beveiligingsinstellingen openen", + "Open Software": "Software openen", + "Open Software…": "Software openen…", "Open T3 Code": "T3 Code openen", "Open a recovery terminal": "Een herstelterminal openen", "Open conversation history": "Gespreksgeschiedenis openen", "Open full local setup": "Volledige lokale configuratie openen", - "Open full log": "Volledig logboek openen", "Open link": "Link openen", "Open list": "Lijst openen", "Open on GitHub": "Openen op GitHub", "Open on hover": "Openen bij aanwijzen", + "Open private build log": "Privébouwlog openen", "Open recovery points": "Herstelpunten openen", "Open system monitor": "Systeemmonitor openen", "Open the current wallpaper's Wallhaven page": "Wallhaven-pagina van de huidige achtergrond openen", @@ -723,7 +810,9 @@ var MESSAGES = { "Output device and volume": "Uitvoerapparaat en volume", "Overview": "Overzicht", "Overview contents": "Inhoud van Overzicht", + "PDF viewer": "Pdf-viewer", "POWER PROFILE": "ENERGIEPROFIEL", + "Packages declared elsewhere keep their existing configuration owners.": "Pakketten die elders zijn vastgelegd, blijven daar beheerd.", "Packet loss": "Pakketverlies", "Panel": "Paneel", "Panel border": "Rand van panelen", @@ -747,6 +836,7 @@ var MESSAGES = { "Personal files": "Persoonlijke bestanden", "Personalize": "Personaliseren", "Pick a colour": "Kleur kiezen", + "Pin": "Vastzetten", "Ping": "Ping", "Pinned": "Vastgezet", "Pinned sessions": "Vastgezette sessies", @@ -782,9 +872,13 @@ var MESSAGES = { "Presets…": "Voorinstellingen…", "Press Enter to copy result": "Druk op Enter om het resultaat te kopiëren", "Press Enter to search, then Down to reach the results.": "Druk op Enter om te zoeken en daarna op pijl-omlaag om naar de resultaten te gaan.", + "Preview": "Voorbeeld", + "Preview changes": "Wijzigingen bekijken", "Preview truncated at 100,000 characters / 2,000 lines": "Voorbeeld afgekapt op 100.000 tekens / 2.000 regels", "Preview update": "Update vooraf bekijken", + "Preview upstream packages, NerdOS, or both in Updates. Review the complete build before applying. Personal inputs and Flatpak applications are managed separately.": "Bekijk upstream-pakketten, NerdOS of beide in Updates. Controleer de volledige build voordat je deze toepast. Persoonlijke bronnen en Flatpak-applicaties worden afzonderlijk beheerd.", "Preview: a toast from WhatsApp, Sarah Jansen": "Voorbeeld: een pop-upmelding van WhatsApp, Sarah Jansen", + "Previous generations are available in the boot menu.": "Eerdere generaties zijn beschikbaar in het opstartmenu.", "Previous or next window in group": "Vorig of volgend venster in groep", "Previous track": "Vorig nummer", "Previous workspace": "Vorige werkruimte", @@ -794,6 +888,7 @@ var MESSAGES = { "Private browser window": "Privébrowservenster", "Project": "Project", "Prompt too long — open T3 Code": "Prompt te lang — open T3 Code", + "Protected": "Beschermd", "Provider API key (optional for local endpoints)": "API-sleutel van provider (optioneel voor lokale endpoints)", "QUICK ACTIONS": "SNELLE ACTIES", "Question": "Vraag", @@ -810,8 +905,11 @@ var MESSAGES = { "RUN": "UITVOEREN", "Read-only access · actions are disabled": "Alleen-lezentoegang · acties zijn uitgeschakeld", "Reader": "Lezer", + "Reads each reply aloud and asks the agent to keep it short.": "Leest elk antwoord voor en vraagt de agent het kort te houden.", "Ready plan": "Plan gereed", "Ready to review": "Klaar voor review", + "Ready, hidden": "Klaar, verborgen", + "Ready, on screen": "Klaar, op het scherm", "Reasoning": "Redeneren", "Receive": "Ontvangen", "Recent account repos": "Recente accountrepo's", @@ -823,6 +921,7 @@ var MESSAGES = { "Recording": "Opnemen", "Recovery key": "Herstelsleutel", "Recovery points": "Herstelpunten", + "Recovery points to retire": "Te verwijderen herstelpunten", "Reduce motion": "Beweging beperken", "Refine": "Verfijnen", "Refresh": "Vernieuwen", @@ -833,6 +932,7 @@ var MESSAGES = { "Region": "Gebied", "Region & formats": "Regio en notaties", "Register a FIDO2 security key": "Een FIDO2-beveiligingssleutel registreren", + "Release to send · Esc cancels": "Loslaten om te versturen · Esc annuleert", "Remember": "Onthouden", "Remember notification history": "Meldingsgeschiedenis onthouden", "Reminders": "Herinneringen", @@ -843,12 +943,15 @@ var MESSAGES = { "Remove from bar": "Verwijderen uit balk", "Remove from this computer?": "Van deze computer verwijderen?", "Remove key": "Sleutel verwijderen", + "Remove selection": "Selectie verwijderen", + "Remove the screenshot": "Schermafbeelding verwijderen", "Remove this fingerprint?": "Deze vingerafdruk verwijderen?", "Remove this key?": "Deze sleutel verwijderen?", "Remove this plugin?": "Deze plug-in verwijderen?", "Remove web app…": "Web-app verwijderen…", "Remove…": "Verwijderen…", "Repeated %1 times": "%1 keer herhaald", + "Reply": "Antwoorden", "Repo refresh": "Repo's vernieuwen", "Report a problem": "Een probleem melden", "Repositories": "Repository's", @@ -875,14 +978,23 @@ var MESSAGES = { "Restore minimized window": "Geminimaliseerd venster herstellen", "Restore the system from a recovery point": "Het systeem herstellen vanaf een herstelpunt", "Restore window": "Venster herstellen", + "Restored %1; %2 changed again since, so it was left alone.": "%1 hersteld; %2 is sindsdien opnieuw gewijzigd en is daarom ongemoeid gelaten.", "Restore…": "Herstellen…", + "Resume cleanup": "Opruimen hervatten", "Retention": "Bewaartermijn", + "Retired": "Verwijderd als herstelpunt", "Retry": "Opnieuw proberen", "Retry diff": "Diff opnieuw proberen", "Retry request": "Verzoek opnieuw proberen", "Retry saving notes": "Notities opnieuw opslaan", "Retrying…": "Opnieuw proberen…", "Review": "Bekijken", + "Review cleanup": "Opruiming beoordelen", + "Review cleanup…": "Opruiming beoordelen…", + "Review deployment…": "Installatie beoordelen…", + "Review device firmware…": "Apparaatfirmware bekijken…", + "Review disk space and retained recovery points": "Bekijk schijfruimte en bewaarde herstelpunten", + "Review firmware separately in a terminal. Firmware changes are not reverted by a system rollback.": "Bekijk firmware afzonderlijk in een terminal. Firmwarewijzigingen worden niet teruggedraaid door systeemherstel.", "Review reset": "Reset bekijken", "Roomy": "Ruim", "Rotate": "Afwisselen", @@ -890,6 +1002,7 @@ var MESSAGES = { "Row height and touch-target size": "Rijhoogte en grootte van aanraakdoelen", "Run Again": "Opnieuw uitvoeren", "Run settings": "Uitvoeringsinstellingen", + "Runs after a confirmed boot and skips active updates or unresolved recovery. Changes are staged for review.": "Wordt uitgevoerd na een bevestigde opstart en slaat actieve updates of onopgelost herstel over. Wijzigingen worden klaargezet ter beoordeling.", "SESSION": "SESSIE", "SSH host": "SSH-host", "SSH server": "SSH-server", @@ -900,14 +1013,16 @@ var MESSAGES = { "Save current as theme…": "Huidige weergave opslaan als thema…", "Save setting": "Instelling opslaan", "Save your new recovery key.": "Bewaar je nieuwe herstelsleutel.", - "Saved per display in ~/.config/cybexos/displays.json. Monitor rules in ~/.config/cybexos/hypr/user.lua take precedence.": "Per beeldscherm opgeslagen in ~/.config/cybexos/displays.json. Monitorregels in ~/.config/cybexos/hypr/user.lua hebben voorrang.", + "Saved per display in ~/.config/nerdos/displays.json. Monitor rules in ~/.config/nerdos/hypr/user.lua take precedence.": "Per beeldscherm opgeslagen in ~/.config/nerdos/displays.json. Monitorregels in ~/.config/nerdos/hypr/user.lua hebben voorrang.", "Saved · paste a new key to replace it": "Opgeslagen · plak een nieuwe sleutel om deze te vervangen", "Saver": "Zuinig", "Scale": "Schaal", "Scales text and spacing together": "Schaalt tekst en witruimte samen", "Screen": "Scherm", + "Screen context": "Schermcontext", "Screen off": "Scherm uit", "Screen recording": "Schermopname", + "Screen · %1": "Scherm · %1", "Screenshot": "Schermafbeelding", "Screenshot a region or window": "Schermafbeelding maken van gebied of venster", "Screenshot the whole screen": "Schermafbeelding maken van het hele scherm", @@ -920,11 +1035,14 @@ var MESSAGES = { "Search Hermes conversation history": "Hermes-gespreksgeschiedenis doorzoeken", "Search Hermes models": "Hermes-modellen zoeken", "Search Wallhaven wallpapers": "Wallhaven-achtergronden zoeken", + "Search and review native package changes": "Zoek en beoordeel wijzigingen aan systeempakketten", "Search conversations": "Gesprekken zoeken", "Search models": "Modellen zoeken", "Search models…": "Modellen zoeken…", + "Search native applications and packages": "Zoek toepassingen en systeempakketten", "Search repositories": "Repository's zoeken", "Search settings": "Instellingen zoeken", + "Search software": "Software zoeken", "Search threads": "Threads zoeken", "Search weather location by city": "Weerlocatie zoeken op plaats", "Seconds": "Seconden", @@ -936,12 +1054,14 @@ var MESSAGES = { "Select and launch OpenCode, Claude Code, or Codex": "OpenCode, Claude Code of Codex kiezen en starten", "Select one for the Storage tile": "Kies er een voor de tegel Opslag", "Select one or more options.": "Kies een of meer opties.", + "Selected": "Geselecteerd", "Send": "Verzenden", "Send grouped preview": "Gegroepeerd voorbeeld versturen", "Send test": "Test versturen", "Send to Hermes": "Naar Hermes sturen", "Send verification preview": "Verificatievoorbeeld versturen", "Sending…": "Verzenden…", + "Sent with this question only": "Alleen met deze vraag meegestuurd", "Separate": "Afzonderlijk", "Separate from remote WebUI sign-in. Use this only when Hermes runs on this machine.": "Los van het inloggen bij de externe WebUI. Gebruik dit alleen als Hermes op deze computer draait.", "Server URL": "Server-URL", @@ -950,6 +1070,7 @@ var MESSAGES = { "Session sign-in required. An HTTP 302 from Hermes WebUI means its browser session has expired or requires sign-in; it is not a usable API response. Enter the password again to renew this client session.": "Opnieuw inloggen vereist voor deze sessie. Een HTTP 302 van Hermes WebUI betekent dat de browsersessie is verlopen of dat inloggen nodig is; het is geen bruikbaar API-antwoord. Voer het wachtwoord opnieuw in om deze clientsessie te vernieuwen.", "Set a location in Settings": "Stel een locatie in bij Instellingen", "Set a value the plugin documents but does not list here": "Stel een waarde in die de plug-in documenteert maar hier niet vermeldt", + "Set nerdos.authentication.fingerprint in a personal NixOS module, then preview with nerd update stage local and apply the reviewed change. Enrol and remove fingerprints here.": "Stel nerdos.authentication.fingerprint in een persoonlijke NixOS-module in, bekijk het voorstel met nerd update stage local en pas de gecontroleerde wijziging toe. Registreer en verwijder hier vingerafdrukken.", "Set up": "Instellen", "Set up Fusebox": "Fusebox instellen", "Set up backups": "Back-ups instellen", @@ -968,6 +1089,7 @@ var MESSAGES = { "Shell": "Shell", "Shell health": "Shellstatus", "Shortcut": "Sneltoets", + "Show": "Tonen", "Show Hermes activity beside its icon": "Toon Hermes-activiteit naast het pictogram", "Show commits": "Commits tonen", "Show details": "Details tonen", @@ -985,6 +1107,7 @@ var MESSAGES = { "Show password and QR code": "Wachtwoord en QR-code tonen", "Show pending updates and their progress": "Openstaande updates en hun voortgang tonen", "Show server name": "Servernaam tonen", + "Show the assistant": "Assistent tonen", "Show the charge number next to the icon": "Toon het laadpercentage naast het pictogram", "Show the date next to the time": "Toon de datum naast de tijd", "Show the remaining time directly beside a timed icon": "Toon de resterende tijd direct naast een pictogram met timer", @@ -998,9 +1121,17 @@ var MESSAGES = { "Shuffle wallpaper now": "Achtergrond nu wisselen", "Shut down": "Uitschakelen", "Sign in": "Inloggen", + "Sign in to %1": "Inloggen bij %1", "Sign in to WebUI": "Inloggen bij WebUI", + "Sign in to set up the assistant": "Log in om de assistent in te stellen", + "Sign-in": "Inloggen", + "Sign-in could not start.": "Inloggen kon niet worden gestart.", "Sign-in expired": "Inlogsessie verlopen", "Sign-in policy applies after login or reboot. Restarting only the menubar resumes its current state and original deadline.": "Het inlogbeleid geldt na inloggen of opnieuw opstarten. Als alleen de menubalk opnieuw start, worden de huidige status en de oorspronkelijke deadline hervat.", + "Sign-in required": "Inloggen vereist", + "Sign-in status could not be checked. Try again.": "De inlogstatus kon niet worden gecontroleerd. Probeer het opnieuw.", + "Signed in": "Ingelogd", + "Signed in. Your question is ready to send.": "Ingelogd. Je vraag is klaar om te versturen.", "Signing in…": "Inloggen…", "Size": "Grootte", "Slack": "Slack", @@ -1010,22 +1141,29 @@ var MESSAGES = { "Snooze 8 hours": "8 uur sluimeren", "Snoozed": "Gesnoozed", "Snooze…": "Snoozen…", + "Software": "Software", "Solid panels and stronger borders": "Ondoorzichtige panelen en sterkere randen", + "Something went wrong.": "Er ging iets mis.", "Sort": "Sorteren", "Sound": "Geluid", "Sound settings": "Geluidsinstellingen", "Source": "Bron", "Source and accounts": "Bron en accounts", "Source snoozes": "Gesluimerde bronnen", + "Speak": "Voorlezen", + "Speak replies": "Antwoorden voorlezen", + "Speech is unavailable: speech-dispatcher is not running.": "Spraak is niet beschikbaar: speech-dispatcher draait niet.", "Speed test": "Snelheidstest", "Spotify": "Spotify", "Square usage cards": "Vierkante gebruikskaarten", + "Staged change · built and checked": "Klaargezette wijziging · gebouwd en gecontroleerd", "Start": "Starten", "Start Hyprlock": "Hyprlock starten", "Start a backup with the saved settings": "Een back-up starten met de opgeslagen instellingen", "Start a new conversation": "Begin een nieuw gesprek", "Start expanded": "Uitgevouwen starten", "Start or stop screen recording": "Schermopname starten of stoppen", + "Start the assistant": "Assistent starten", "Statistics unavailable": "Statistieken niet beschikbaar", "Status": "Status", "Status group": "Statusgroep", @@ -1036,9 +1174,16 @@ var MESSAGES = { "Step 1 of 3 · Choose where backups go. They are encrypted, so only you can read them.": "Stap 1 van 3 · Kies waar de back-ups naartoe gaan. Ze worden versleuteld, zodat alleen jij ze kunt lezen.", "Step 2 of 3 · Save your recovery key.": "Stap 2 van 3 · Bewaar je herstelsleutel.", "Step 3 of 3 · The first backup copies everything, so it takes a while.": "Stap 3 van 3 · De eerste back-up kopieert alles en duurt daarom even.", + "Stop": "Stoppen", "Stop Hermes": "Hermes stoppen", "Stop connecting": "Verbinden stoppen", + "Stop recording": "Opname stoppen", "Stop watching": "Niet meer volgen", + "Stopped. Nothing was changed.": "Gestopt. Er is niets gewijzigd.", + "Stopped. Undo reverts what it had changed.": "Gestopt. Ongedaan maken draait terug wat er al gewijzigd was.", + "Storage": "Opslag", + "Storage & cleanup": "Opslag en opruimen", + "Storage is running low. Review cleanup before building another deployment.": "De opslagruimte raakt op. Beoordeel de opruiming voordat je een nieuwe installatie bouwt.", "Store private history across shell restarts and logins": "Privégeschiedenis bewaren na shellherstarts en aanmeldingen", "Style": "Stijl", "Submit": "Verzenden", @@ -1053,6 +1198,9 @@ var MESSAGES = { "Switch layouts": "Wisselen van indeling", "Switch output to %1": "Uitvoer wijzigen naar %1", "System": "Systeem", + "System change": "Systeemwijziging", + "System cleanup": "Systeem opruimen", + "System generations": "Systeemgeneraties", "System language": "Systeemtaal", "System monitor": "Systeemmonitor", "System timezone": "Systeemtijdzone", @@ -1066,6 +1214,9 @@ var MESSAGES = { "Tabs": "Tabbladen", "Tailscale": "Tailscale", "Take a scrolling screenshot": "Scrollende schermafbeelding maken", + "Talk instead of typing": "Praten in plaats van typen", + "Talking": "Praten", + "Tap the microphone to send": "Tik op de microfoon om te versturen", "Tap to click": "Tikken om te klikken", "Tasks": "Taken", "Tells apps to limit background data": "Vraagt apps om achtergrondgegevens te beperken", @@ -1073,22 +1224,33 @@ var MESSAGES = { "Terminal": "Terminal", "Test notification": "Testmelding", "Text & size": "Tekst en grootte", + "Text editor": "Teksteditor", "Text size": "Tekstgrootte", + "The NerdOS glasses as they are; the ring around them shows what the assistant is doing.": "De NerdOS-bril zoals hij is; de ring eromheen laat zien wat de assistent doet.", "The address of Fusebox's dashboard.": "Het adres van het Fusebox-dashboard.", + "The assistant could not start.": "De assistent kon niet starten.", + "The assistant has replied": "De assistent heeft geantwoord", + "The assistant is working": "De assistent is bezig", + "The assistant needs your answer": "De assistent wacht op je antwoord", + "The assistant service is not running": "De assistentdienst draait niet", + "The assistant service is not running.": "De assistentdienst draait niet.", "The bar layout button cycles the layout of its own display": "De indelingsknop op de balk wisselt de indeling van het eigen beeldscherm", "The configured count applies to recent account and org repositories. Every watched repository is additive to that list and, when enabled, the workflow-report scope. Repository refresh uses the interval above; the Inbox checks repository events and GitHub notifications every minute.": "Het ingestelde aantal geldt voor recente repository's van het account en organisaties. Elke gevolgde repository komt bij die lijst en, indien ingeschakeld, bij het bereik van de workflowrapporten. Repository's worden vernieuwd volgens het interval hierboven; de Inbox controleert elke minuut op repository-gebeurtenissen en GitHub-meldingen.", "The dock appears automatically on floating workspaces. Also show it in tiled and scrolling layouts.": "Het dock verschijnt automatisch op zwevende werkbladen. Toon het ook bij tegel- en schuifindelingen.", "The dock appears on floating workspaces. Minimized windows remain in the overview and launcher.": "Het dock verschijnt op zwevende werkbladen. Geminimaliseerde vensters blijven in het overzicht en de starter.", - "The five newest are kept; your home folder is never rolled back. To try one without changing anything, restart and hold Shift (or press Esc) until the boot menu appears, then open CybexOS recovery points.": "De vijf nieuwste worden bewaard; je thuismap wordt nooit teruggezet. Om er een te proberen zonder iets te wijzigen, start je opnieuw op en houd je Shift ingedrukt (of druk je op Esc) tot het opstartmenu verschijnt; open daar CybexOS recovery points.", + "The glasses with eyes behind the lenses, which follow what the assistant is doing.": "De bril met ogen achter de glazen, die volgen wat de assistent doet.", + "The microphone stopped. Try again.": "De microfoon is gestopt. Probeer het opnieuw.", "The open dashboard updates every 2 seconds.": "Het geopende dashboard wordt elke 2 seconden bijgewerkt.", + "The running system, its fallback and pending recovery are always protected. Pin another confirmed recovery point to keep it indefinitely.": "Het actieve systeem, de terugvalversie en lopend herstel zijn altijd beschermd. Zet een ander bevestigd herstelpunt vast om het onbeperkt te bewaren.", + "The screen could not be captured.": "Het scherm kon niet worden vastgelegd.", "The size that Text size and Interface scale build on": "De grootte waarop Tekstgrootte en Interfaceschaal voortbouwen", + "The updater does not hold this change as built and checked, so it cannot be applied. Deny it and ask again.": "De updater heeft deze wijziging niet als gebouwd en gecontroleerd klaarstaan, dus ze kan niet worden toegepast. Weiger haar en vraag het opnieuw.", "Theme": "Thema", "Theme name": "Themanaam", - "They need the standard Btrfs layout (%1).": "Hiervoor is de standaard Btrfs-indeling nodig (%1).", - "They need the standard Btrfs layout.": "Hiervoor is de standaard Btrfs-indeling nodig.", + "Thinking": "Denkt na", "This approval needs the full T3 Code client.": "Voor deze goedkeuring is de volledige T3 Code-client nodig.", "This credential does not grant orchestration read access.": "Deze inloggegevens geven geen leestoegang tot orchestratie.", - "This destination already holds CybexOS backups. Enter their recovery key to keep using them from this computer.": "Deze bestemming bevat al CybexOS-back-ups. Voer de bijbehorende herstelsleutel in om ze vanaf deze computer te blijven gebruiken.", + "This destination already holds NerdOS backups. Enter their recovery key to keep using them from this computer.": "Deze bestemming bevat al NerdOS-back-ups. Voer de bijbehorende herstelsleutel in om ze vanaf deze computer te blijven gebruiken.", "This plan stays in the source project and Default mode. Provider, model, traits, and access remain adjustable.": "Dit plan blijft in het bronproject en in de modus Standaard. Provider, model, eigenschappen en toegang blijven aanpasbaar.", "This question needs the full T3 Code client.": "Voor deze vraag is de volledige T3 Code-client nodig.", "This server reports no temperature sensors.": "Deze server meldt geen temperatuursensoren.", @@ -1122,17 +1284,22 @@ var MESSAGES = { "Touch a registered FIDO2 or U2F key for the lock screen, sudo and system prompts. Your password keeps working.": "Raak een geregistreerde FIDO2- of U2F-sleutel aan voor het vergrendelscherm, sudo en systeemverzoeken. Je wachtwoord blijft werken.", "Touchpad": "Touchpad", "Touchpad changes not applied yet": "Touchpadwijzigingen nog niet toegepast", + "Transcribing…": "Uitschrijven…", + "Transcription took too long.": "Het uitschrijven duurde te lang.", "Try again": "Opnieuw proberen", "Turn off effects and notifications while playing": "Effecten en meldingen uitschakelen tijdens het spelen", "Turns off panel, reveal and hover animations": "Schakelt animaties van panelen, onthullen en aanwijzen uit", "Type a time, or use the arrow keys to step by %1 minutes": "Typ een tijd of gebruik de pijltoetsen voor stappen van %1 minuten", "Type to filter windows": "Typ om vensters te filteren", "UPDATES": "UPDATES", + "Uncompressed store contents; filesystem space can differ.": "Ongecomprimeerde store-inhoud; de ruimte op het bestandssysteem kan afwijken.", "Undo": "Ongedaan maken", "Undo layout change": "Indelingswijziging ongedaan maken", "Undo note deletion": "Verwijderen van notitie ongedaan maken", + "Undone.": "Ongedaan gemaakt.", "Unmanaged (%1)": "Onbeheerd (%1)", "Unmute": "Dempen opheffen", + "Unpin": "Losmaken", "Unsnooze": "Sluimeren opheffen", "Unsnooze all": "Alle sluimerstanden opheffen", "Until off": "Tot uitgezet", @@ -1145,6 +1312,7 @@ var MESSAGES = { "Updates": "Updates", "Upload": "Upload", "Uploaded": "Geüpload", + "Use %1": "%1 gebruiken", "Use Add to start a note": "Gebruik Toevoegen om een notitie te beginnen", "Use Add to start a note.": "Gebruik Toevoegen om een notitie te beginnen.", "Use Up and Down to choose a result, then Enter to apply it.": "Kies een resultaat met pijl-omhoog en pijl-omlaag en druk op Enter om het toe te passen.", @@ -1163,18 +1331,21 @@ var MESSAGES = { "Variant": "Variant", "Verb only": "Alleen werkwoord", "Verification notification": "Verificatiemelding", + "Video player": "Videospeler", + "View generations": "Generaties bekijken", + "View log": "Logboek bekijken", "View pull request": "Pull request bekijken", "Visibility": "Zichtbaarheid", "Visiting workspace": "Bezocht werkblad", + "Voice and screen": "Stem en scherm", "Volume": "Volume", "Volume down": "Volume omlaag", "Volume up": "Volume omhoog", + "Voxtype is not running": "Voxtype draait niet", + "Voxtype, on this computer": "Voxtype, op deze computer", "WATCHED REPOS": "GEVOLGDE REPO'S", "WEB": "WEB", - "WHAT DNF SAID": "WAT DNF MELDDE", - "WHAT HAPPENED": "WAT ER GEBEURDE", "WINDOWS": "VENSTERS", - "Waiting for the transaction…": "Wachten op de transactie…", "Waiting…": "Wachten…", "Waits while plugged in; suspends once unplugged": "Wacht zolang de stekker erin zit; gaat in slaapstand zodra de stekker eruit is", "Wallpaper": "Achtergrond", @@ -1185,9 +1356,10 @@ var MESSAGES = { "Watched pushes and failed/action-required workflows": "Gevolgde pushes en mislukte workflows of workflows die actie vereisen", "Weather location": "Weerlocatie", "Web apps": "Web-apps", + "Web browser": "Webbrowser", "WebUI password": "WebUI-wachtwoord", "What a click on it starts, and whether it stays on after you sign in": "Wat een klik erop start, en of het aan blijft nadat je inlogt", - "What will change": "Wat er verandert", + "What can you do?": "Wat kun je allemaal?", "WhatsApp": "WhatsApp", "Where the volume and brightness pop-up appears": "Waar de pop-up voor volume en helderheid verschijnt", "While open": "Zolang geopend", @@ -1198,7 +1370,7 @@ var MESSAGES = { "Widget layout actions": "Acties voor widgetindeling", "Widget settings…": "Widgetinstellingen…", "Widgets": "Widgets", - "Widgets paused · Your settings are preserved · cybex shell status": "Widgets gepauzeerd · Je instellingen blijven behouden · cybex shell status", + "Widgets paused · Your settings are preserved · nerd shell status": "Widgets gepauzeerd · Je instellingen blijven behouden · nerd shell status", "Width": "Breedte", "Window": "Venster", "Windows": "Vensters", @@ -1206,6 +1378,8 @@ var MESSAGES = { "Workflow rows in the Inbox for recent and watched repositories": "Workflowregels in de Inbox voor recente en gevolgde repository's", "Working": "Bezig", "Working only": "Alleen Bezig", + "Working. Click to tuck the box away.": "Bezig. Klik om het vak weg te stoppen.", + "Working…": "Bezig…", "Workspace": "Werkblad", "Workspace command failed": "Werkbladopdracht mislukt", "Workspace controls are waiting for the compositor": "Werkbladbediening wacht op de compositor", @@ -1215,14 +1389,20 @@ var MESSAGES = { "Write a Markdown note…": "Schrijf een Markdown-notitie…", "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX": "XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX", "Yes": "Ja", + "You · %1": "Jij · %1", "YouTube": "YouTube", + "Your question is kept here until you’re signed in.": "Je vraag blijft hier bewaard tot je bent ingelogd.", + "Your saved application is unavailable. An available fallback is used until you choose another.": "Je opgeslagen toepassing is niet beschikbaar. Er wordt een beschikbaar alternatief gebruikt totdat je een andere kiest.", "Zoom in or out": "In- of uitzoomen", "alias or user@host": "alias of gebruiker@host", + "and %1 more": "en nog %1", "back to page": "terug naar pagina", "counting…": "tellen…", "en, nl, auto, or a comma-separated list": "en, nl, auto of een door komma's gescheiden lijst", "exit node": "exit-node", + "installed": "geïnstalleerd", "move": "navigeren", + "not installed": "niet geïnstalleerd", "omarchy plugin add https://github.com/…": "omarchy plugin add https://github.com/…", "open row": "rij openen", "provider/model-id (optional when discoverable)": "provider/model-id (optioneel als het automatisch gevonden kan worden)", diff --git a/desktop/quickshell/Common/qmldir b/desktop/quickshell/Common/qmldir new file mode 100644 index 00000000..50db1f22 --- /dev/null +++ b/desktop/quickshell/Common/qmldir @@ -0,0 +1,118 @@ +# Sibling-type resolution inside Common/ relies on this explicit index; a +# directory carrying a qmldir is no longer implicitly scanned, so every +# Common/*.qml type must be listed. qmldir.test.cjs enforces that. +# +# T3Socket.qml and HermesSocket.qml are deliberately absent, and must stay +# absent. They import QtWebSockets, which is an optional package +# (qt6-qtwebsockets-devel). Their connection singletons pull them in through +# Loaders by URL instead, so a machine without the package gets Loader.Error +# on that one object — an actionable offline chip — rather than a failure to +# resolve a registered type. qmldir.test.cjs carries both exemptions. + +singleton Theme Theme.qml +singleton I18n I18n.qml +singleton Activity Activity.qml +singleton T3Theme T3Theme.qml +singleton Palette Palette.qml +singleton SystemTheme SystemTheme.qml +singleton Themes Themes.qml +singleton Popouts Popouts.qml +singleton Screens Screens.qml +singleton DesktopWorkspaces DesktopWorkspaces.qml +singleton Launcher Launcher.qml +singleton LauncherProviders LauncherProviders.qml +singleton T3Code T3Code.qml +singleton T3Connection T3Connection.qml +singleton T3Rpc T3Rpc.qml +singleton T3Actions T3Actions.qml +singleton T3Detail T3Detail.qml +singleton T3Drafts T3Drafts.qml +singleton T3Favorites T3Favorites.qml +singleton T3Git T3Git.qml +singleton T3Threads T3Threads.qml +singleton Hermes Hermes.qml +singleton HermesConversations HermesConversations.qml +singleton HermesConnection HermesConnection.qml +singleton HermesRpc HermesRpc.qml +singleton HermesTheme HermesTheme.qml +singleton GitHub GitHub.qml +singleton Notifs Notifs.qml +singleton SysInfo SysInfo.qml +singleton Osd Osd.qml +singleton Weather Weather.qml +singleton Calendar Calendar.qml +singleton Wallpaper Wallpaper.qml +singleton OnlineWallpapers OnlineWallpapers.qml +singleton Settings Settings.qml +singleton UserPlugins UserPlugins.qml +singleton Audio Audio.qml +singleton NetworkStatus NetworkStatus.qml +singleton NetworkDetails NetworkDetails.qml +singleton NetworkOverlayState NetworkOverlayState.qml +singleton WifiState WifiState.qml +singleton EthernetState EthernetState.qml +singleton BluetoothState BluetoothState.qml +singleton Battery Battery.qml +singleton BatteryHealth BatteryHealth.qml +singleton BarOverflow BarOverflow.qml +singleton ShellHealth ShellHealth.qml +singleton BrandIcons BrandIcons.qml +singleton TablerIcons TablerIcons.qml +singleton WebIcons WebIcons.qml +singleton Media Media.qml +singleton ModelUsageHub ModelUsageHub.qml +singleton Tailscale Tailscale.qml +singleton Updates Updates.qml +singleton Recorder Recorder.qml +singleton GameMode GameMode.qml +singleton Dictation Dictation.qml +singleton Assistant Assistant.qml +singleton Reminders Reminders.qml +singleton Recovery Recovery.qml +singleton Backup Backup.qml +singleton Notes Notes.qml +singleton Session Session.qml +singleton Startup Startup.qml +singleton Overview Overview.qml +singleton Dock Dock.qml +singleton ProcessList ProcessList.qml + +# Plain types (not singletons). The two controls live here rather than in +# Popovers/ or Settings/ because both directories draw them and both already +# import "../Common" for Theme, so unifying them cost no new import anywhere. +Claim Claim.qml +UserWidgetHost UserWidgetHost.qml +OmarchyBarApi OmarchyBarApi.qml +AnimatedText AnimatedText.qml +HSlider HSlider.qml +Sym Sym.qml +BrandIcon BrandIcon.qml +NotifActions NotifActions.qml +NotifButton NotifButton.qml +NotifSnoozes NotifSnoozes.qml +NotifCard NotifCard.qml +NotifIcon NotifIcon.qml +Revealer Revealer.qml +ScrollChrome ScrollChrome.qml +SocketContract SocketContract.qml +StatusPlaceholder StatusPlaceholder.qml +Toggle Toggle.qml +StateLayer StateLayer.qml +HugCorner HugCorner.qml +OmarchyPluginApi OmarchyPluginApi.qml +OmarchyEntryHost OmarchyEntryHost.qml +singleton OmarchyPlugins OmarchyPlugins.qml +singleton OmarchyApplications OmarchyApplications.qml +OmarchyServiceApi OmarchyServiceApi.qml + +singleton SystemSettings SystemSettings.qml +singleton DisplaySettings DisplaySettings.qml +SystemSettingsBackend SystemSettingsBackend.qml +singleton RemoteServer RemoteServer.qml +singleton Fusebox Fusebox.qml + +CommandRequest CommandRequest.qml +JsonCommand JsonCommand.qml +singleton DefaultApplications DefaultApplications.qml +singleton Software Software.qml +singleton Maintenance Maintenance.qml diff --git a/roles/desktop/files/quickshell/Commons/Border.qml b/desktop/quickshell/Commons/Border.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Border.qml rename to desktop/quickshell/Commons/Border.qml diff --git a/roles/desktop/files/quickshell/Commons/BorderGeometry.js b/desktop/quickshell/Commons/BorderGeometry.js similarity index 100% rename from roles/desktop/files/quickshell/Commons/BorderGeometry.js rename to desktop/quickshell/Commons/BorderGeometry.js diff --git a/roles/desktop/files/quickshell/Commons/Color.qml b/desktop/quickshell/Commons/Color.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Color.qml rename to desktop/quickshell/Commons/Color.qml diff --git a/roles/desktop/files/quickshell/Commons/Style.qml b/desktop/quickshell/Commons/Style.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Style.qml rename to desktop/quickshell/Commons/Style.qml diff --git a/roles/desktop/files/quickshell/Commons/Util.qml b/desktop/quickshell/Commons/Util.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Util.qml rename to desktop/quickshell/Commons/Util.qml diff --git a/roles/desktop/files/quickshell/Commons/qmldir b/desktop/quickshell/Commons/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Commons/qmldir rename to desktop/quickshell/Commons/qmldir diff --git a/roles/desktop/files/quickshell/DockWindow.qml b/desktop/quickshell/DockWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/DockWindow.qml rename to desktop/quickshell/DockWindow.qml diff --git a/roles/desktop/files/quickshell/LauncherView.qml b/desktop/quickshell/LauncherView.qml similarity index 100% rename from roles/desktop/files/quickshell/LauncherView.qml rename to desktop/quickshell/LauncherView.qml diff --git a/roles/desktop/files/quickshell/LauncherWindow.qml b/desktop/quickshell/LauncherWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/LauncherWindow.qml rename to desktop/quickshell/LauncherWindow.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/BlockMeter.qml b/desktop/quickshell/ModelUsage/BlockMeter.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/BlockMeter.qml rename to desktop/quickshell/ModelUsage/BlockMeter.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/CostBackend.qml b/desktop/quickshell/ModelUsage/CostBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/CostBackend.qml rename to desktop/quickshell/ModelUsage/CostBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/CostPriceEditor.qml b/desktop/quickshell/ModelUsage/CostPriceEditor.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/CostPriceEditor.qml rename to desktop/quickshell/ModelUsage/CostPriceEditor.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/CostSettings.qml b/desktop/quickshell/ModelUsage/CostSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/CostSettings.qml rename to desktop/quickshell/ModelUsage/CostSettings.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/LICENSE b/desktop/quickshell/ModelUsage/LICENSE similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/LICENSE rename to desktop/quickshell/ModelUsage/LICENSE diff --git a/roles/desktop/files/quickshell/ModelUsage/Panel.qml b/desktop/quickshell/ModelUsage/Panel.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/Panel.qml rename to desktop/quickshell/ModelUsage/Panel.qml diff --git a/desktop/quickshell/ModelUsage/README.md b/desktop/quickshell/ModelUsage/README.md new file mode 100644 index 00000000..40d058e0 --- /dev/null +++ b/desktop/quickshell/ModelUsage/README.md @@ -0,0 +1,40 @@ +# Model Usage (built in) + +This directory is the [Model Usage](https://github.com/DigitalPals/omarchy-modelusage) +Omarchy plugin, vendored as the shell's built-in Model Usage widget. + +Commit: `d4986628d0493aa303d02de0d9017237747704ec` (version 1.3.2) + +The upstream QML and JavaScript receive the reviewed +[`integration.patch`](../../../scripts/model-usage/integration.patch): +shared quota and legacy activity workers, idle/offline polling control, and +first-use popup construction. Assets, helper scripts and licenses are unchanged. The +manifest, documentation, keeper deployment and CI are not copied. The unit +tests and fixtures live in `tests/model-usage/`, with the manifest as test data. + +How the shell hosts it: + +- `Bar/Modules/ModelUsage.qml` is the `modelusage` bar module. It gives + `Panel.qml` the Omarchy `bar` facade that installed plugins receive + (`Common/OmarchyBarApi.qml`). The panel keeps its `qs.Ui` and `qs.Commons` + imports, which resolve to the shell's own Omarchy compatibility types. +- The panel's settings live in `shell.json` as `modOpts.modelusage`. + `Common/SettingsHelpers.js` mirrors the manifest's defaults and bounds, and + the panel saves through the facade's `updateEntryInline`. +- The scripts keep their upstream state and credential paths + (`~/.local/state/omarchy/model-usage`, `~/.config/omarchy/...`), so an + installed copy of the plugin and the built-in share them. +- `scripts/user-plugins.py` refuses to load the `digitalpals.model-usage` + package, so an installed copy never draws a second widget. + +To update, run `scripts/sync-model-usage ` from the repository root, +review the diff, and run `./tests/run`. If the manifest's settings changed, +update `modelusage` in `Common/SettingsHelpers.js` to match. +The sync applies the integration patch before replacing files and aborts on +conflicts. Rebase the patch when upstream changes these integration points; +do not silently drop it. Host lifecycle tests live outside the vendored tests. + +The native Rust integration is displayed as [Fusebox](https://github.com/DigitalPals/Fusebox). +Limits settings include a link to that repository. The saved `cliproxy` source, +URL/key settings and `rust` protocol marker remain compatible with existing +installations; the Go integration retains its CLIProxyAPI name. diff --git a/roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml b/desktop/quickshell/ModelUsage/ResetBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml rename to desktop/quickshell/ModelUsage/ResetBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/ResetConfirmation.qml b/desktop/quickshell/ModelUsage/ResetConfirmation.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/ResetConfirmation.qml rename to desktop/quickshell/ModelUsage/ResetConfirmation.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsActions.qml b/desktop/quickshell/ModelUsage/SettingsActions.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsActions.qml rename to desktop/quickshell/ModelUsage/SettingsActions.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsCredential.qml b/desktop/quickshell/ModelUsage/SettingsCredential.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsCredential.qml rename to desktop/quickshell/ModelUsage/SettingsCredential.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsField.qml b/desktop/quickshell/ModelUsage/SettingsField.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsField.qml rename to desktop/quickshell/ModelUsage/SettingsField.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsHeader.qml b/desktop/quickshell/ModelUsage/SettingsHeader.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsHeader.qml rename to desktop/quickshell/ModelUsage/SettingsHeader.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsSection.qml b/desktop/quickshell/ModelUsage/SettingsSection.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsSection.qml rename to desktop/quickshell/ModelUsage/SettingsSection.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md b/desktop/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md similarity index 88% rename from roles/desktop/files/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md rename to desktop/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md index 944e218d..6a9f5148 100644 --- a/roles/desktop/files/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md +++ b/desktop/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md @@ -24,11 +24,11 @@ The plugin's architecture and component usage follow Omarchy Quattro. The Claude > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE > SOFTWARE. -## DigitalPals/fedora-config +## DigitalPals/cybexos-config -The existing Fedora/Quickshell Model Usage module at commit `6aa074432f36548381ead91ed39d57c34529e327` was the functional and segmented-meter design reference. That checkout did not contain a repository-level license. No source file, theme implementation, or asset from that repository is redistributed here; the backend and Omarchy-native QML were independently implemented against current provider and Omarchy interfaces. +The existing CybexOS/Quickshell Model Usage module at commit `6aa074432f36548381ead91ed39d57c34529e327` was the functional and segmented-meter design reference. That checkout did not contain a repository-level license. No source file, theme implementation, or asset from that repository is redistributed here; the backend and Omarchy-native QML were independently implemented against current provider and Omarchy interfaces. -CLIProxyAPI source selection and account-pool behavior were also reviewed at Fedora commit `431def720d4b905c1563d600be33ca50eed621bb`. The management protocol was checked against [`router-for-me/CLIProxyAPI`](https://github.com/router-for-me/CLIProxyAPI) at commit `5b2785617d1e7de84a9f4dee599d275a4ccd8999`. This integration is independently implemented; no source or assets from either reference are redistributed. +CLIProxyAPI source selection and account-pool behavior were also reviewed at CybexOS commit `431def720d4b905c1563d600be33ca50eed621bb`. The management protocol was checked against [`router-for-me/CLIProxyAPI`](https://github.com/router-for-me/CLIProxyAPI) at commit `5b2785617d1e7de84a9f4dee599d275a4ccd8999`. This integration is independently implemented; no source or assets from either reference are redistributed. ## T3 Code diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml b/desktop/quickshell/ModelUsage/UsageActivityBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml rename to desktop/quickshell/ModelUsage/UsageActivityBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml b/desktop/quickshell/ModelUsage/UsageBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml rename to desktop/quickshell/ModelUsage/UsageBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageCosts.qml b/desktop/quickshell/ModelUsage/UsageCosts.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageCosts.qml rename to desktop/quickshell/ModelUsage/UsageCosts.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageDisclosure.qml b/desktop/quickshell/ModelUsage/UsageDisclosure.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageDisclosure.qml rename to desktop/quickshell/ModelUsage/UsageDisclosure.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageHistory.qml b/desktop/quickshell/ModelUsage/UsageHistory.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageHistory.qml rename to desktop/quickshell/ModelUsage/UsageHistory.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageLiveConnection.qml b/desktop/quickshell/ModelUsage/UsageLiveConnection.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageLiveConnection.qml rename to desktop/quickshell/ModelUsage/UsageLiveConnection.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageLogic.js b/desktop/quickshell/ModelUsage/UsageLogic.js similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageLogic.js rename to desktop/quickshell/ModelUsage/UsageLogic.js diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageNavigation.qml b/desktop/quickshell/ModelUsage/UsageNavigation.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageNavigation.qml rename to desktop/quickshell/ModelUsage/UsageNavigation.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSelect.qml b/desktop/quickshell/ModelUsage/UsageSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSelect.qml rename to desktop/quickshell/ModelUsage/UsageSelect.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml b/desktop/quickshell/ModelUsage/UsageSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml rename to desktop/quickshell/ModelUsage/UsageSettings.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSharedActivityBackend.qml b/desktop/quickshell/ModelUsage/UsageSharedActivityBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSharedActivityBackend.qml rename to desktop/quickshell/ModelUsage/UsageSharedActivityBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSharedBackend.qml b/desktop/quickshell/ModelUsage/UsageSharedBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSharedBackend.qml rename to desktop/quickshell/ModelUsage/UsageSharedBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageTabs.qml b/desktop/quickshell/ModelUsage/UsageTabs.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageTabs.qml rename to desktop/quickshell/ModelUsage/UsageTabs.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/claude-bar.svg b/desktop/quickshell/ModelUsage/assets/claude-bar.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/claude-bar.svg rename to desktop/quickshell/ModelUsage/assets/claude-bar.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/claude.svg b/desktop/quickshell/ModelUsage/assets/claude.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/claude.svg rename to desktop/quickshell/ModelUsage/assets/claude.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/codex-bar.svg b/desktop/quickshell/ModelUsage/assets/codex-bar.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/codex-bar.svg rename to desktop/quickshell/ModelUsage/assets/codex-bar.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/codex-light.svg b/desktop/quickshell/ModelUsage/assets/codex-light.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/codex-light.svg rename to desktop/quickshell/ModelUsage/assets/codex-light.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/codex.svg b/desktop/quickshell/ModelUsage/assets/codex.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/codex.svg rename to desktop/quickshell/ModelUsage/assets/codex.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/kimi-bar.svg b/desktop/quickshell/ModelUsage/assets/kimi-bar.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/kimi-bar.svg rename to desktop/quickshell/ModelUsage/assets/kimi-bar.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/kimi.svg b/desktop/quickshell/ModelUsage/assets/kimi.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/kimi.svg rename to desktop/quickshell/ModelUsage/assets/kimi.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/cost-fetch.py b/desktop/quickshell/ModelUsage/scripts/cost-fetch.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/cost-fetch.py rename to desktop/quickshell/ModelUsage/scripts/cost-fetch.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/keeper_client.py b/desktop/quickshell/ModelUsage/scripts/keeper_client.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/keeper_client.py rename to desktop/quickshell/ModelUsage/scripts/keeper_client.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/management-key.py b/desktop/quickshell/ModelUsage/scripts/management-key.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/management-key.py rename to desktop/quickshell/ModelUsage/scripts/management-key.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/model_usage_common.py b/desktop/quickshell/ModelUsage/scripts/model_usage_common.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/model_usage_common.py rename to desktop/quickshell/ModelUsage/scripts/model_usage_common.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py b/desktop/quickshell/ModelUsage/scripts/proxy-activity.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py rename to desktop/quickshell/ModelUsage/scripts/proxy-activity.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-live.py b/desktop/quickshell/ModelUsage/scripts/proxy-live.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/proxy-live.py rename to desktop/quickshell/ModelUsage/scripts/proxy-live.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py b/desktop/quickshell/ModelUsage/scripts/reset-credit.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py rename to desktop/quickshell/ModelUsage/scripts/reset-credit.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/t3_costs.py b/desktop/quickshell/ModelUsage/scripts/t3_costs.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/t3_costs.py rename to desktop/quickshell/ModelUsage/scripts/t3_costs.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py b/desktop/quickshell/ModelUsage/scripts/usage-fetch.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py rename to desktop/quickshell/ModelUsage/scripts/usage-fetch.py diff --git a/roles/desktop/files/quickshell/NetworkOverlayWindow.qml b/desktop/quickshell/NetworkOverlayWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/NetworkOverlayWindow.qml rename to desktop/quickshell/NetworkOverlayWindow.qml diff --git a/roles/desktop/files/quickshell/NotificationToasts.qml b/desktop/quickshell/NotificationToasts.qml similarity index 100% rename from roles/desktop/files/quickshell/NotificationToasts.qml rename to desktop/quickshell/NotificationToasts.qml diff --git a/roles/desktop/files/quickshell/OsdWindow.qml b/desktop/quickshell/OsdWindow.qml similarity index 98% rename from roles/desktop/files/quickshell/OsdWindow.qml rename to desktop/quickshell/OsdWindow.qml index a4728325..ce17855f 100644 --- a/roles/desktop/files/quickshell/OsdWindow.qml +++ b/desktop/quickshell/OsdWindow.qml @@ -15,7 +15,7 @@ PanelWindow { // Stay mapped through the fade-out; unmap once the pill is gone. visible: Osd.active || pill.opacity > 0.001 screen: Screens.focused - // Placement follows CybexOS Settings; when pill and bar share an edge the + // Placement follows NerdOS Settings; when pill and bar share an edge the // margin clears the bar zone instead of hugging the screen edge. readonly property bool atTop: Settings.osd === "top" readonly property int barClearance: Theme.barTopMargin + Theme.barHeight + 12 diff --git a/roles/desktop/files/quickshell/OverviewView.qml b/desktop/quickshell/OverviewView.qml similarity index 100% rename from roles/desktop/files/quickshell/OverviewView.qml rename to desktop/quickshell/OverviewView.qml diff --git a/roles/desktop/files/quickshell/OverviewWindow.qml b/desktop/quickshell/OverviewWindow.qml similarity index 97% rename from roles/desktop/files/quickshell/OverviewWindow.qml rename to desktop/quickshell/OverviewWindow.qml index a4eeff6a..75c2b284 100644 --- a/roles/desktop/files/quickshell/OverviewWindow.qml +++ b/desktop/quickshell/OverviewWindow.qml @@ -54,7 +54,7 @@ PanelWindow { onCleared: Overview.close() } - // Shell-wide and registered once. `cybexos-runtime ipc overview toggle` + // Shell-wide and registered once. `nerdos-runtime ipc overview toggle` // is also what the touchpad's three-finger swipe up runs. IpcHandler { target: "overview" diff --git a/roles/desktop/files/quickshell/PolkitPrompt.qml b/desktop/quickshell/PolkitPrompt.qml similarity index 100% rename from roles/desktop/files/quickshell/PolkitPrompt.qml rename to desktop/quickshell/PolkitPrompt.qml diff --git a/roles/desktop/files/quickshell/PolkitWindow.qml b/desktop/quickshell/PolkitWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/PolkitWindow.qml rename to desktop/quickshell/PolkitWindow.qml diff --git a/roles/desktop/files/quickshell/Popovers/ActionButton.qml b/desktop/quickshell/Popovers/ActionButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/ActionButton.qml rename to desktop/quickshell/Popovers/ActionButton.qml diff --git a/roles/desktop/files/quickshell/Popovers/AudioPopover.qml b/desktop/quickshell/Popovers/AudioPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/AudioPopover.qml rename to desktop/quickshell/Popovers/AudioPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/BatteryPopover.qml b/desktop/quickshell/Popovers/BatteryPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/BatteryPopover.qml rename to desktop/quickshell/Popovers/BatteryPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/BlockMeter.qml b/desktop/quickshell/Popovers/BlockMeter.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/BlockMeter.qml rename to desktop/quickshell/Popovers/BlockMeter.qml diff --git a/roles/desktop/files/quickshell/Popovers/BluetoothPopover.qml b/desktop/quickshell/Popovers/BluetoothPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/BluetoothPopover.qml rename to desktop/quickshell/Popovers/BluetoothPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/CalendarPopover.qml b/desktop/quickshell/Popovers/CalendarPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/CalendarPopover.qml rename to desktop/quickshell/Popovers/CalendarPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/ControlCenterPopover.qml b/desktop/quickshell/Popovers/ControlCenterPopover.qml similarity index 99% rename from roles/desktop/files/quickshell/Popovers/ControlCenterPopover.qml rename to desktop/quickshell/Popovers/ControlCenterPopover.qml index aa0551b0..5764f72b 100644 --- a/roles/desktop/files/quickshell/Popovers/ControlCenterPopover.qml +++ b/desktop/quickshell/Popovers/ControlCenterPopover.qml @@ -14,13 +14,13 @@ import "../Common" // Three things this panel used to carry live elsewhere now. The battery // reading belongs to its own menubar widget and BatteryPopover, which also // owns the power profile; Tailscale keeps its detail view, reachable from -// `cybexos-runtime ipc popouts toggle tailscale`, but no longer holds a cell +// `nerdos-runtime ipc popouts toggle tailscale`, but no longer holds a cell // here. // What is left is one shape per idea: a card, a full-width row, a grid tile, // a segmented track, separated by the SectionLabel the rest of the shell // already draws. // -// The Fedora button opens this dashboard; the four status widgets open their +// The NerdOS button opens this dashboard; the four status widgets open their // dedicated detail views directly. Surface { id: root @@ -62,7 +62,7 @@ Surface { clearSessionConfirmation(); } - readonly property string binDir: Quickshell.env("HOME") + "/.local/bin/" + readonly property string binDir: "" readonly property var btConnected: BluetoothState.devices.filter(d => d.connected) readonly property real contentWidth: Math.max(0, root.width - 2 * root.padding) @@ -90,7 +90,7 @@ Surface { readonly property real sessionActionWidth: Math.max(0, (contentWidth - 4 * sessionSpacing) / 5) readonly property var sessionActions: [ - { key: "lock", glyph: "lock", label: I18n.tr("Lock") }, + ...(!Session.live ? [{ key: "lock", glyph: "lock", label: I18n.tr("Lock") }] : []), { key: "suspend", glyph: "bedtime", label: I18n.tr("Suspend") }, { key: "logout", glyph: "logout", label: I18n.tr("Log out") }, { key: "restart", glyph: "restart_alt", label: I18n.tr("Restart") }, @@ -1015,7 +1015,7 @@ Surface { border.width: activeFocus ? 1 : 0 border.color: Theme.accentText Accessible.role: Accessible.Button - Accessible.name: I18n.tr("Open CybexOS Settings") + Accessible.name: I18n.tr("Open NerdOS Settings") Accessible.onPressAction: Settings.showPanel() Keys.onPressed: event => { diff --git a/roles/desktop/files/quickshell/Popovers/DaySheetPopover.qml b/desktop/quickshell/Popovers/DaySheetPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/DaySheetPopover.qml rename to desktop/quickshell/Popovers/DaySheetPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerBluetooth.qml b/desktop/quickshell/Popovers/Drawer/DrawerBluetooth.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerBluetooth.qml rename to desktop/quickshell/Popovers/Drawer/DrawerBluetooth.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerFooter.qml b/desktop/quickshell/Popovers/Drawer/DrawerFooter.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerFooter.qml rename to desktop/quickshell/Popovers/Drawer/DrawerFooter.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerIconButton.qml b/desktop/quickshell/Popovers/Drawer/DrawerIconButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerIconButton.qml rename to desktop/quickshell/Popovers/Drawer/DrawerIconButton.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerNetwork.qml b/desktop/quickshell/Popovers/Drawer/DrawerNetwork.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerNetwork.qml rename to desktop/quickshell/Popovers/Drawer/DrawerNetwork.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerNotifications.qml b/desktop/quickshell/Popovers/Drawer/DrawerNotifications.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerNotifications.qml rename to desktop/quickshell/Popovers/Drawer/DrawerNotifications.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml b/desktop/quickshell/Popovers/Drawer/DrawerOverview.qml similarity index 98% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml rename to desktop/quickshell/Popovers/Drawer/DrawerOverview.qml index aee10e97..b76e792d 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml +++ b/desktop/quickshell/Popovers/Drawer/DrawerOverview.qml @@ -313,8 +313,8 @@ Column { text: Updates.runActive ? Updates.runPhaseLabel : Updates.runState === "failed" ? Updates.failMessage : Updates.total > 0 - ? UpdatesHelpers.pendingSummary(Updates.dnfCount, - Updates.flatpakCount, Updates.firmwareCount, + ? UpdatesHelpers.pendingSummary(Updates.upstreamCount, + 0, 0, Updates.projectAvailable, Updates.projectVersion) : "" font.family: Theme.fontMenu @@ -414,7 +414,7 @@ Column { onClicked: { Popouts.close(); Quickshell.execDetached(["sh", "-c", - Quickshell.env("HOME") + "/.local/bin/screenshot region"]); + "screenshot region"]); } } @@ -426,7 +426,7 @@ Column { DrawerIconButton { glyph: "settings" - accessibleName: "CybexOS Settings" + accessibleName: "NerdOS Settings" onClicked: { Popouts.close(); Settings.showPanel(undefined, Popouts.hostScreenName); diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPopover.qml b/desktop/quickshell/Popovers/Drawer/DrawerPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerPopover.qml rename to desktop/quickshell/Popovers/Drawer/DrawerPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml b/desktop/quickshell/Popovers/Drawer/DrawerPower.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml rename to desktop/quickshell/Popovers/Drawer/DrawerPower.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerProcesses.qml b/desktop/quickshell/Popovers/Drawer/DrawerProcesses.qml similarity index 99% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerProcesses.qml rename to desktop/quickshell/Popovers/Drawer/DrawerProcesses.qml index e5b042d2..71701d4a 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerProcesses.qml +++ b/desktop/quickshell/Popovers/Drawer/DrawerProcesses.qml @@ -31,7 +31,7 @@ Column { function openMonitor() { Popouts.close(); // The same command as Super+Shift+T (bindings.lua). - Quickshell.execDetached(["kitty", "-e", "btop"]); + Quickshell.execDetached(["nerdos-apps", "terminal", "--", "btop"]); } Item { diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSliderRow.qml b/desktop/quickshell/Popovers/Drawer/DrawerSliderRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerSliderRow.qml rename to desktop/quickshell/Popovers/Drawer/DrawerSliderRow.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSound.qml b/desktop/quickshell/Popovers/Drawer/DrawerSound.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerSound.qml rename to desktop/quickshell/Popovers/Drawer/DrawerSound.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSystemHero.qml b/desktop/quickshell/Popovers/Drawer/DrawerSystemHero.qml similarity index 97% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerSystemHero.qml rename to desktop/quickshell/Popovers/Drawer/DrawerSystemHero.qml index 63a45134..befecf64 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSystemHero.qml +++ b/desktop/quickshell/Popovers/Drawer/DrawerSystemHero.qml @@ -5,7 +5,7 @@ import "../../Common/SysInfoHelpers.js" as SysInfoHelpers import ".." // The fixed identity and health summary at the top of Overview. It stays -// independent of drawer section settings: opening the Fedora button always +// independent of drawer section settings: opening the NerdOS button always // answers which machine this is and how its core resources are doing. Rectangle { id: root @@ -19,8 +19,8 @@ Rectangle { border.color: Theme.hairlineSoft readonly property int logoSize: Theme.scaled(56) - readonly property bool fedoraIdentity: - SysInfo.osId.toLowerCase() === "fedora" && SysInfo.osName !== "" + readonly property bool nerdosIdentity: + SysInfo.osId.toLowerCase() === "nixos" && SysInfo.osName !== "" readonly property string osTitle: SysInfo.osName !== "" ? SysInfo.osName : "System information unavailable" readonly property string osReleaseLine: { @@ -210,13 +210,13 @@ Rectangle { BrandIcon { anchors.fill: parent - name: "fedora" - visible: root.fedoraIdentity + name: "nerdos" + visible: root.nerdosIdentity } Rectangle { anchors.fill: parent - visible: !root.fedoraIdentity + visible: !root.nerdosIdentity radius: Theme.rowRadius color: Theme.chipHover diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerTabs.qml b/desktop/quickshell/Popovers/Drawer/DrawerTabs.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerTabs.qml rename to desktop/quickshell/Popovers/Drawer/DrawerTabs.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerTile.qml b/desktop/quickshell/Popovers/Drawer/DrawerTile.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerTile.qml rename to desktop/quickshell/Popovers/Drawer/DrawerTile.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/qmldir b/desktop/quickshell/Popovers/Drawer/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/qmldir rename to desktop/quickshell/Popovers/Drawer/qmldir diff --git a/roles/desktop/files/quickshell/Popovers/FillSlider.qml b/desktop/quickshell/Popovers/FillSlider.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/FillSlider.qml rename to desktop/quickshell/Popovers/FillSlider.qml diff --git a/roles/desktop/files/quickshell/Popovers/FuseboxPopover.qml b/desktop/quickshell/Popovers/FuseboxPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/FuseboxPopover.qml rename to desktop/quickshell/Popovers/FuseboxPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/GitHubPopover.qml b/desktop/quickshell/Popovers/GitHubPopover.qml similarity index 99% rename from roles/desktop/files/quickshell/Popovers/GitHubPopover.qml rename to desktop/quickshell/Popovers/GitHubPopover.qml index c5f410a2..979f302f 100644 --- a/roles/desktop/files/quickshell/Popovers/GitHubPopover.qml +++ b/desktop/quickshell/Popovers/GitHubPopover.qml @@ -1648,7 +1648,7 @@ Surface { // long account name is enough to cause, and then // the provenance takes its own line under them. // Two absolutely positioned halves of one row is - // what let "−39641" and "CybexFleetBootstrap" + // what let "−39641" and "NerdOSFleetBootstrap" // render on top of each other. Item { id: metaBox diff --git a/roles/desktop/files/quickshell/Popovers/HDivider.qml b/desktop/quickshell/Popovers/HDivider.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HDivider.qml rename to desktop/quickshell/Popovers/HDivider.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesAuthPanel.qml b/desktop/quickshell/Popovers/HermesAuthPanel.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesAuthPanel.qml rename to desktop/quickshell/Popovers/HermesAuthPanel.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesBarControl.qml b/desktop/quickshell/Popovers/HermesBarControl.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesBarControl.qml rename to desktop/quickshell/Popovers/HermesBarControl.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesComposer.qml b/desktop/quickshell/Popovers/HermesComposer.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesComposer.qml rename to desktop/quickshell/Popovers/HermesComposer.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesInboxPage.qml b/desktop/quickshell/Popovers/HermesInboxPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesInboxPage.qml rename to desktop/quickshell/Popovers/HermesInboxPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesInlineSelect.qml b/desktop/quickshell/Popovers/HermesInlineSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesInlineSelect.qml rename to desktop/quickshell/Popovers/HermesInlineSelect.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesModelPicker.qml b/desktop/quickshell/Popovers/HermesModelPicker.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesModelPicker.qml rename to desktop/quickshell/Popovers/HermesModelPicker.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesPopover.qml b/desktop/quickshell/Popovers/HermesPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesPopover.qml rename to desktop/quickshell/Popovers/HermesPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesRequestCard.qml b/desktop/quickshell/Popovers/HermesRequestCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesRequestCard.qml rename to desktop/quickshell/Popovers/HermesRequestCard.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesSessionStrip.qml b/desktop/quickshell/Popovers/HermesSessionStrip.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesSessionStrip.qml rename to desktop/quickshell/Popovers/HermesSessionStrip.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesToolCard.qml b/desktop/quickshell/Popovers/HermesToolCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesToolCard.qml rename to desktop/quickshell/Popovers/HermesToolCard.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesTranscript.qml b/desktop/quickshell/Popovers/HermesTranscript.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesTranscript.qml rename to desktop/quickshell/Popovers/HermesTranscript.qml diff --git a/roles/desktop/files/quickshell/Popovers/IconButton.qml b/desktop/quickshell/Popovers/IconButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/IconButton.qml rename to desktop/quickshell/Popovers/IconButton.qml diff --git a/roles/desktop/files/quickshell/Popovers/LinkText.qml b/desktop/quickshell/Popovers/LinkText.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/LinkText.qml rename to desktop/quickshell/Popovers/LinkText.qml diff --git a/roles/desktop/files/quickshell/Popovers/MediaPopover.qml b/desktop/quickshell/Popovers/MediaPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/MediaPopover.qml rename to desktop/quickshell/Popovers/MediaPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/NotesPopover.qml b/desktop/quickshell/Popovers/NotesPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/NotesPopover.qml rename to desktop/quickshell/Popovers/NotesPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/NotifsPopover.qml b/desktop/quickshell/Popovers/NotifsPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/NotifsPopover.qml rename to desktop/quickshell/Popovers/NotifsPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/OverflowPopover.qml b/desktop/quickshell/Popovers/OverflowPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/OverflowPopover.qml rename to desktop/quickshell/Popovers/OverflowPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/PopoutPanel.qml b/desktop/quickshell/Popovers/PopoutPanel.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/PopoutPanel.qml rename to desktop/quickshell/Popovers/PopoutPanel.qml diff --git a/roles/desktop/files/quickshell/Popovers/ReminderPopover.qml b/desktop/quickshell/Popovers/ReminderPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/ReminderPopover.qml rename to desktop/quickshell/Popovers/ReminderPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml b/desktop/quickshell/Popovers/RemoteServerPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml rename to desktop/quickshell/Popovers/RemoteServerPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/SectionLabel.qml b/desktop/quickshell/Popovers/SectionLabel.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/SectionLabel.qml rename to desktop/quickshell/Popovers/SectionLabel.qml diff --git a/roles/desktop/files/quickshell/Popovers/Surface.qml b/desktop/quickshell/Popovers/Surface.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Surface.qml rename to desktop/quickshell/Popovers/Surface.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3BarControl.qml b/desktop/quickshell/Popovers/T3BarControl.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3BarControl.qml rename to desktop/quickshell/Popovers/T3BarControl.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3CodePopover.qml b/desktop/quickshell/Popovers/T3CodePopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3CodePopover.qml rename to desktop/quickshell/Popovers/T3CodePopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3Composer.qml b/desktop/quickshell/Popovers/T3Composer.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3Composer.qml rename to desktop/quickshell/Popovers/T3Composer.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3InboxPage.qml b/desktop/quickshell/Popovers/T3InboxPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3InboxPage.qml rename to desktop/quickshell/Popovers/T3InboxPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3InlineSelect.qml b/desktop/quickshell/Popovers/T3InlineSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3InlineSelect.qml rename to desktop/quickshell/Popovers/T3InlineSelect.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3ModelPicker.qml b/desktop/quickshell/Popovers/T3ModelPicker.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3ModelPicker.qml rename to desktop/quickshell/Popovers/T3ModelPicker.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3NewThreadPage.qml b/desktop/quickshell/Popovers/T3NewThreadPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3NewThreadPage.qml rename to desktop/quickshell/Popovers/T3NewThreadPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3Picker.qml b/desktop/quickshell/Popovers/T3Picker.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3Picker.qml rename to desktop/quickshell/Popovers/T3Picker.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3RequestCard.qml b/desktop/quickshell/Popovers/T3RequestCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3RequestCard.qml rename to desktop/quickshell/Popovers/T3RequestCard.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3ThreadPage.qml b/desktop/quickshell/Popovers/T3ThreadPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3ThreadPage.qml rename to desktop/quickshell/Popovers/T3ThreadPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/TailscalePopover.qml b/desktop/quickshell/Popovers/TailscalePopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/TailscalePopover.qml rename to desktop/quickshell/Popovers/TailscalePopover.qml diff --git a/desktop/quickshell/Popovers/UpdatesPopover.qml b/desktop/quickshell/Popovers/UpdatesPopover.qml new file mode 100644 index 00000000..2e9560b0 --- /dev/null +++ b/desktop/quickshell/Popovers/UpdatesPopover.qml @@ -0,0 +1,104 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import "../Common" + +Surface { + id: root + implicitWidth: Theme.drawerWidth + spacing: 16 + Text { width: parent.width; text: I18n.tr("Updates"); color: Theme.textHi; font.pixelSize: Theme.typography.heading; font.bold: true } + Text { width: parent.width; text: Updates.summary; color: Theme.textHi; wrapMode: Text.Wrap } + Text { + width: parent.width + visible: Updates.available + text: "NixOS packages: " + (Updates.sources.upstream.state || "Checking…") + + "\nNerdOS: " + Updates.installedVersion + (Updates.projectVersion ? " → " + Updates.projectVersion : "") + + " · " + (Updates.sources.nerdos.state || "Checking…") + color: Theme.textHi; wrapMode: Text.Wrap + } + Text { width: parent.width; visible: text !== ""; text: Updates.error; color: Theme.textHi; wrapMode: Text.Wrap; textFormat: Text.PlainText } + Row { + visible: Updates.available + spacing: 8 + Repeater { + model: [{label:"Upstream", scope:"upstream"}, {label:"NerdOS", scope:"nerdos"}, {label:"Both", scope:"both"}] + ActionButton { + required property var modelData + width: 106; height: 38 + label: I18n.tr("Preview") + " " + modelData.label + tint: Theme.textHi + enabled: !Updates.runActive && !["awaiting-boot", "awaiting-login", "recovery-failed"].includes(Updates.deployment.state) + onTriggered: Updates.stage(modelData.scope) + } + } + } + Text { width: parent.width; visible: Updates.available; text: Updates.runPhaseLabel; color: Theme.textHi; wrapMode: Text.Wrap } + ScrollView { + width: parent.width + height: Math.min(240, details.implicitHeight + 8) + visible: Updates.projectNotes !== "" || Updates.ready + clip: true + contentWidth: availableWidth + Column { + id: details + width: parent.width + spacing: 12 + Text { + width: parent.width; text: Updates.projectNotes + color: Theme.textHi; wrapMode: Text.Wrap; textFormat: Text.PlainText + } + Text { + width: parent.width; visible: Updates.ready + text: I18n.tr("Built package changes") + "\n" + (Updates.deployment.changes || I18n.tr("No package closure changes")) + color: Theme.textHi; font.family: "monospace"; font.pixelSize: Theme.typography.metadata + wrapMode: Text.Wrap; textFormat: Text.PlainText + } + } + } + ActionButton { + width: parent.width; height: 42; visible: Updates.ready + label: I18n.tr("Apply reviewed generation"); tint: Theme.textHi + onTriggered: Updates.apply() + } + Text { + width: parent.width; visible: text !== ""; text: Updates.deployment.recovery || "" + color: Theme.textHi; wrapMode: Text.Wrap + } + Row { + visible: Updates.available + spacing: 8 + Repeater { + model: [{label:"Check sources", action:"check"}, {label:"Recovery", action:"openRecoveryPoints"}, {label:"Rollback", action:"rollback"}] + ActionButton { + required property var modelData + width: 106; height: 36 + label: I18n.tr(modelData.label); tint: Theme.textHi + enabled: !Updates.runActive + onTriggered: Updates[modelData.action]() + } + } + } + ActionButton { + width: parent.width; height: 40; visible: Updates.deployment.state === "awaiting-login" + label: I18n.tr("Confirm this boot and desktop"); tint: Theme.textHi + onTriggered: Updates.confirm() + } + ActionButton { + width: parent.width; visible: Boolean(Updates.deployment.transaction) + label: I18n.tr("Open private build log"); tint: Theme.textHi + onTriggered: Updates.openLog() + } + ActionButton { + width: parent.width; height: 40; visible: Updates.available; enabled: !Updates.runActive + label: I18n.tr("Device firmware…"); tint: Theme.textHi + onTriggered: Updates.openFirmware() + } + Text { + visible: Updates.available + width: parent.width; color: Theme.textHi; font.pixelSize: Theme.typography.secondary + text: I18n.tr("Review firmware separately in a terminal. Firmware changes are not reverted by a system rollback.") + wrapMode: Text.Wrap + } + Text { width: parent.width; text: "Updates preserve your personal modules, widgets, preferences and data. Reboots are always your choice."; color: Theme.textHi; font.pixelSize: Theme.typography.secondary; wrapMode: Text.Wrap } +} diff --git a/roles/desktop/files/quickshell/Popovers/WeatherPopover.qml b/desktop/quickshell/Popovers/WeatherPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/WeatherPopover.qml rename to desktop/quickshell/Popovers/WeatherPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/WifiPopover.qml b/desktop/quickshell/Popovers/WifiPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/WifiPopover.qml rename to desktop/quickshell/Popovers/WifiPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/qmldir b/desktop/quickshell/Popovers/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Popovers/qmldir rename to desktop/quickshell/Popovers/qmldir diff --git a/roles/desktop/files/quickshell/Settings/AboutPage.qml b/desktop/quickshell/Settings/AboutPage.qml similarity index 99% rename from roles/desktop/files/quickshell/Settings/AboutPage.qml rename to desktop/quickshell/Settings/AboutPage.qml index 4abddbcf..14c2b50a 100644 --- a/roles/desktop/files/quickshell/Settings/AboutPage.qml +++ b/desktop/quickshell/Settings/AboutPage.qml @@ -108,7 +108,7 @@ SettingsPage { ValueRow { width: parent.width - label: "~/.config/cybexos/shell.json" + label: "~/.config/nerdos/shell.json" labelMono: true SettingsAction { diff --git a/roles/desktop/files/quickshell/Settings/AccountsPage.qml b/desktop/quickshell/Settings/AccountsPage.qml similarity index 97% rename from roles/desktop/files/quickshell/Settings/AccountsPage.qml rename to desktop/quickshell/Settings/AccountsPage.qml index 9bd72bf1..ccd2944c 100644 --- a/roles/desktop/files/quickshell/Settings/AccountsPage.qml +++ b/desktop/quickshell/Settings/AccountsPage.qml @@ -4,7 +4,7 @@ import Quickshell import "../Common" // Online accounts: the GNOME Online Accounts on this computer, and whether -// CybexOS reads their calendars. Signing in happens in the account window +// NerdOS reads their calendars. Signing in happens in the account window // that Add account opens; this page lists what is connected, and removes. // // Status is hint text, not a standing control: loading and updating are @@ -44,7 +44,7 @@ SettingsPage { id: note width: parent.width maximumLines: 5 - text: I18n.tr("Connect an account to show its calendars in CybexOS. Signing in opens a separate account window and your browser.") + text: I18n.tr("Connect an account to show its calendars in NerdOS. Signing in opens a separate account window and your browser.") } } SettingsHint { diff --git a/roles/desktop/files/quickshell/Settings/ActionRow.qml b/desktop/quickshell/Settings/ActionRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ActionRow.qml rename to desktop/quickshell/Settings/ActionRow.qml diff --git a/roles/desktop/files/quickshell/Settings/AppearancePage.qml b/desktop/quickshell/Settings/AppearancePage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/AppearancePage.qml rename to desktop/quickshell/Settings/AppearancePage.qml diff --git a/desktop/quickshell/Settings/ApplicationsPage.qml b/desktop/quickshell/Settings/ApplicationsPage.qml new file mode 100644 index 00000000..6b85eece --- /dev/null +++ b/desktop/quickshell/Settings/ApplicationsPage.qml @@ -0,0 +1,47 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" + +SettingsPage { + id: page + Component.onCompleted: DefaultApplications.refresh() + Column { + width: parent.width + spacing: Theme.settingsGroupSpacing + SettingsGroup { + width: parent.width + title: I18n.tr("Default applications") + SettingsHint { width: parent.width; text: I18n.tr("Applies immediately to your account. Your own shortcuts and explicit editor overrides keep their choices.") } + Repeater { + model: [{id:"browser", label:"Web browser"}, {id:"terminal", label:"Terminal"}, + {id:"editor", label:"Text editor"}, {id:"pdf", label:"PDF viewer"}, + {id:"image", label:"Image viewer"}, {id:"video", label:"Video player"}] + delegate: SelectRow { + required property var modelData + readonly property var role: DefaultApplications.roles[modelData.id] || ({}) + width: parent.width + label: I18n.tr(modelData.label) + property string searchAnchor: "applications-" + modelData.id + model: role.choices || [] + current: role.selected || "" + enabled: !DefaultApplications.busy && model.length > 0 + hint: role.unavailable ? I18n.tr("Your saved application is unavailable. An available fallback is used until you choose another.") : "" + onPicked: value => DefaultApplications.select(modelData.id, value) + } + } + SettingsHint { width: parent.width; visible: DefaultApplications.error !== ""; text: DefaultApplications.error; tone: "error" } + ValueRow { + width: parent.width; label: I18n.tr("Available applications") + SettingsAction { text: I18n.tr("Refresh"); enabled: !DefaultApplications.busy; onTriggered: DefaultApplications.refresh() } + } + } + SettingsGroup { + width: parent.width; title: I18n.tr("Software") + ValueRow { + width: parent.width; label: I18n.tr("Install and remove applications") + SettingsAction { text: I18n.tr("Open Software…"); glyph: "apps"; onTriggered: Software.show() } + } + } + DeploymentReview { width: parent.width; kind: "software" } + } +} diff --git a/roles/desktop/files/quickshell/Settings/ApplyBar.qml b/desktop/quickshell/Settings/ApplyBar.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ApplyBar.qml rename to desktop/quickshell/Settings/ApplyBar.qml diff --git a/desktop/quickshell/Settings/AssistantPage.qml b/desktop/quickshell/Settings/AssistantPage.qml new file mode 100644 index 00000000..a7ef6205 --- /dev/null +++ b/desktop/quickshell/Settings/AssistantPage.qml @@ -0,0 +1,167 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import "../Common" +import "../Common/AssistantHelpers.js" as AssistantHelpers + +// Assistant: who answers, what it may see and hear, and how it looks +// (docs/assistant.md). The agents keep their own sign-in and settings; this +// page only chooses between them. Screen context and spoken replies are off +// until switched on here. +SettingsPage { + id: page + pageReset: true + + readonly property bool claudeInstalled: !!(Assistant.agents.claude && Assistant.agents.claude.available) + readonly property bool codexInstalled: !!(Assistant.agents.codex && Assistant.agents.codex.available) + + function installedLabel(installed) { + return !Assistant.connected ? "" : installed ? I18n.tr("installed") : I18n.tr("not installed"); + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Theme.settingsGroupSpacing + + SettingsGroup { + width: parent.width + title: I18n.tr("Assistant") + + ValueRow { + width: parent.width + label: I18n.tr("Status") + value: !Settings.assistantConfigured ? I18n.tr("Not part of this installation") + : Assistant.connected && Assistant.setupRequired ? I18n.tr("Sign in to set up the assistant") + : Assistant.connected ? (Settings.assistantShown ? I18n.tr("Ready, on screen") : I18n.tr("Ready, hidden")) + : I18n.tr("The assistant service is not running") + dotColor: Assistant.connected ? Theme.ok : Theme.textFaint + + SettingsAction { + visible: Settings.assistantConfigured && Assistant.connected + text: Settings.assistantShown ? I18n.tr("Hide") : I18n.tr("Show") + onTriggered: Assistant.toggleShown(Settings.panelScreenName) + } + SettingsAction { + visible: Settings.assistantConfigured && !Assistant.connected + text: I18n.tr("Start") + onTriggered: Quickshell.execDetached(["systemctl", "--user", "start", "nerdos-assistant.service"]) + } + } + SettingsHint { + width: parent.width + text: I18n.tr("Hover the character or tap Super+I to type. Hold Super+I to talk, and release to send.") + } + } + + SettingsGroup { + width: parent.width + title: I18n.tr("Agent") + + PickerRow { + width: parent.width + label: I18n.tr("Answered by") + settingKey: "assistantAgent" + model: [ + { value: "auto", label: I18n.tr("Automatic") }, + { value: "claude", label: "Claude Code" }, + { value: "codex", label: "Codex" } + ] + caption: Settings.assistantAgent === "auto" && Assistant.connected + ? I18n.format("Now %1", [AssistantHelpers.AGENT_LABELS[Assistant.agent]]) : "" + captionMono: false + } + SettingsHint { + width: parent.width + text: I18n.tr("Automatic follows your nerd agent default, then whichever is installed. Sign-in and permissions stay with the agent itself.") + } + ValueRow { + width: parent.width + label: I18n.tr("Sign-in") + value: Assistant.authChecking ? I18n.tr("Checking sign-in…") + : Assistant.agentReady ? I18n.tr("Signed in") : I18n.tr("Sign-in required") + SettingsAction { + text: Assistant.agentReady ? I18n.tr("Check sign-in") : I18n.tr("Sign in") + enabled: Assistant.connected && !Assistant.authChecking && !Assistant.busy + onTriggered: { + if (Assistant.agentReady) + Assistant.refreshAuth(); + else { + Assistant.openComposer(true); + Assistant.refreshAuth(); + } + } + } + } + SelectRow { + width: parent.width + label: I18n.tr("Claude Code model") + settingKey: "assistantClaudeModel" + model: [{ value: "default", label: I18n.tr("Claude Code's default") }, + { value: "fable", label: "Fable" }, { value: "sonnet", label: "Sonnet" }, + { value: "opus", label: "Opus" }] + hint: page.installedLabel(page.claudeInstalled) + } + SelectRow { + width: parent.width + label: I18n.tr("Codex model") + settingKey: "assistantCodexModel" + model: [{ value: "default", label: I18n.tr("Codex's default") }, + { value: "gpt-5.6-luna", label: "Luna" }, { value: "gpt-5.6-terra", label: "Terra" }, + { value: "gpt-5.6-sol", label: "Sol" }] + hint: page.installedLabel(page.codexInstalled) + } + } + + SettingsGroup { + width: parent.width + title: I18n.tr("Voice and screen") + + SwitchRow { + width: parent.width + label: I18n.tr("Speak replies") + settingKey: "assistantSpeak" + description: Assistant.connected && !Assistant.speechAvailable + ? I18n.tr("Speech is unavailable: speech-dispatcher is not running.") + : I18n.tr("Reads each reply aloud and asks the agent to keep it short.") + } + SwitchRow { + width: parent.width + label: I18n.tr("Screen context") + settingKey: "assistantScreenContext" + description: I18n.tr("Adds a screen button to the box. It sends one screenshot of the display the assistant is on, without the assistant, with that question only, and deletes it afterwards.") + } + ValueRow { + width: parent.width + label: I18n.tr("Talking") + value: Assistant.voiceAvailable ? I18n.tr("Voxtype, on this computer") : I18n.tr("Voxtype is not running") + SettingsAction { + text: I18n.tr("Dictation settings") + onTriggered: Settings.openWidgetSettings("indicators") + } + } + } + + SettingsGroup { + width: parent.width + title: I18n.tr("Character") + + PickerRow { + width: parent.width + label: I18n.tr("Look") + settingKey: "assistantCharacter" + model: [ + { value: "mascot", label: I18n.tr("Mascot") }, + { value: "mark", label: I18n.tr("Logo only") } + ] + } + SettingsHint { + width: parent.width + text: Settings.assistantCharacter === "mark" + ? I18n.tr("The NerdOS glasses as they are; the ring around them shows what the assistant is doing.") + : I18n.tr("The glasses with eyes behind the lenses, which follow what the assistant is doing.") + } + } + } +} diff --git a/roles/desktop/files/quickshell/Settings/BackupPage.qml b/desktop/quickshell/Settings/BackupPage.qml similarity index 98% rename from roles/desktop/files/quickshell/Settings/BackupPage.qml rename to desktop/quickshell/Settings/BackupPage.qml index c0ba4d39..f123dfb3 100644 --- a/roles/desktop/files/quickshell/Settings/BackupPage.qml +++ b/desktop/quickshell/Settings/BackupPage.qml @@ -17,7 +17,7 @@ import "LocalTime.js" as LocalTime // switch and restore. Restoring never replaces anything: files go into a // new folder under ~/Restored, which then opens in Files. // -// Everything that matters lives in the cybexos-backup helper (Common/ +// Everything that matters lives in the nerdos-backup helper (Common/ // Backup.qml); this page holds only what the person is typing. SettingsPage { id: page @@ -203,7 +203,7 @@ SettingsPage { label: I18n.tr("Folder") value: page.folder === "" ? "None chosen" : page.folder valueMono: page.folder !== "" - hint: I18n.tr("An external drive is best. Backups go into a cybexos-backup folder inside it.") + hint: I18n.tr("An external drive is best. Backups go into a nerdos-backup folder inside it.") SettingsAction { text: I18n.tr("Choose…") @@ -240,7 +240,7 @@ SettingsPage { label: I18n.tr("Recovery key") value: page.recoveryText placeholder: I18n.tr("XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX") - hint: I18n.tr("This destination already holds CybexOS backups. Enter their recovery key to keep using them from this computer.") + hint: I18n.tr("This destination already holds NerdOS backups. Enter their recovery key to keep using them from this computer.") onEdited: text => page.recoveryText = text } ValueRow { @@ -444,7 +444,6 @@ SettingsPage { RecoveryGroup { width: parent.width - nowMs: page.nowMs } } } diff --git a/roles/desktop/files/quickshell/Settings/BarBackgroundGroup.qml b/desktop/quickshell/Settings/BarBackgroundGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/BarBackgroundGroup.qml rename to desktop/quickshell/Settings/BarBackgroundGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/BarLayoutGroups.qml b/desktop/quickshell/Settings/BarLayoutGroups.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/BarLayoutGroups.qml rename to desktop/quickshell/Settings/BarLayoutGroups.qml diff --git a/roles/desktop/files/quickshell/Settings/BarPreview.qml b/desktop/quickshell/Settings/BarPreview.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/BarPreview.qml rename to desktop/quickshell/Settings/BarPreview.qml diff --git a/roles/desktop/files/quickshell/Settings/ChipToggleRow.qml b/desktop/quickshell/Settings/ChipToggleRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ChipToggleRow.qml rename to desktop/quickshell/Settings/ChipToggleRow.qml diff --git a/roles/desktop/files/quickshell/Settings/ChoiceRow.qml b/desktop/quickshell/Settings/ChoiceRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ChoiceRow.qml rename to desktop/quickshell/Settings/ChoiceRow.qml diff --git a/roles/desktop/files/quickshell/Settings/CornerPickerRow.qml b/desktop/quickshell/Settings/CornerPickerRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/CornerPickerRow.qml rename to desktop/quickshell/Settings/CornerPickerRow.qml diff --git a/desktop/quickshell/Settings/DeploymentReview.qml b/desktop/quickshell/Settings/DeploymentReview.qml new file mode 100644 index 00000000..a0e49583 --- /dev/null +++ b/desktop/quickshell/Settings/DeploymentReview.qml @@ -0,0 +1,24 @@ +import QtQuick +import "../Common" + +SettingsGroup { + id: root + property string kind: "" + title: I18n.tr("System change") + visible: Updates.deployment.changeKind === kind + SettingsHint { width: parent.width; text: Updates.runPhaseLabel } + SettingsHint { width: parent.width; visible: Updates.deployment.error !== undefined && Updates.deployment.error !== ""; text: Updates.deployment.error || ""; tone: "error" } + Text { + width: parent.width; visible: Updates.ready + text: Updates.deployment.changes || "" + textFormat: Text.PlainText + color: Theme.textMid; font.family: Theme.fontMono; font.pixelSize: Theme.typography.secondary + wrapMode: Text.WrapAnywhere + } + ValueRow { + width: parent.width; label: Updates.ready ? I18n.tr("Built changes") : I18n.tr("Deployment") + SettingsAction { text: I18n.tr("View log"); onTriggered: Updates.openLog() } + SettingsAction { text: I18n.tr("Apply reviewed change"); primary: true; visible: Updates.ready; onTriggered: Updates.apply() } + SettingsAction { text: I18n.tr("Confirm this boot"); visible: Updates.deployment.state === "awaiting-login"; onTriggered: Updates.confirm() } + } +} diff --git a/roles/desktop/files/quickshell/Settings/DisplayArrangement.qml b/desktop/quickshell/Settings/DisplayArrangement.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/DisplayArrangement.qml rename to desktop/quickshell/Settings/DisplayArrangement.qml diff --git a/roles/desktop/files/quickshell/Settings/DisplaysPage.qml b/desktop/quickshell/Settings/DisplaysPage.qml similarity index 97% rename from roles/desktop/files/quickshell/Settings/DisplaysPage.qml rename to desktop/quickshell/Settings/DisplaysPage.qml index 70e5e614..c56457da 100644 --- a/roles/desktop/files/quickshell/Settings/DisplaysPage.qml +++ b/desktop/quickshell/Settings/DisplaysPage.qml @@ -5,7 +5,7 @@ import "../Common/DisplayHelpers.js" as Displays // Settings -> Displays. Edits are drafted against the live outputs, applied // as a trial that restores itself unless kept, and only then saved to -// ~/.config/cybexos/displays.json, which Hyprland reads through displays.lua. +// ~/.config/nerdos/displays.json, which Hyprland reads through displays.lua. // // The arrangement is also the display picker: the group under it edits // whichever display is selected there. Unlike the shell settings on the @@ -404,12 +404,12 @@ SettingsPage { label: I18n.tr("Adaptive sync") choices: Displays.VRR_CHOICES current: page.selected ? page.selected.vrr : -1 - hint: I18n.tr("Default keeps CybexOS's policy for this display. Fullscreen enables variable refresh only for fullscreen windows.") + hint: I18n.tr("Default keeps NerdOS's policy for this display. Fullscreen enables variable refresh only for fullscreen windows.") onPicked: value => page.edit(page.selected.key, { vrr: value }) } SettingsHint { width: parent.width - text: I18n.tr("Saved per display in ~/.config/cybexos/displays.json. Monitor rules in ~/.config/cybexos/hypr/user.lua take precedence.") + text: I18n.tr("Saved per display in ~/.config/nerdos/displays.json. Monitor rules in ~/.config/nerdos/hypr/user.lua take precedence.") } } diff --git a/roles/desktop/files/quickshell/Settings/DraftFieldRow.qml b/desktop/quickshell/Settings/DraftFieldRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/DraftFieldRow.qml rename to desktop/quickshell/Settings/DraftFieldRow.qml diff --git a/roles/desktop/files/quickshell/Settings/DrawerPage.qml b/desktop/quickshell/Settings/DrawerPage.qml similarity index 99% rename from roles/desktop/files/quickshell/Settings/DrawerPage.qml rename to desktop/quickshell/Settings/DrawerPage.qml index 696847c6..18ab6a59 100644 --- a/roles/desktop/files/quickshell/Settings/DrawerPage.qml +++ b/desktop/quickshell/Settings/DrawerPage.qml @@ -10,7 +10,7 @@ SettingsPage { id: page readonly property var tabMeta: ({ - overview: { glyph: "dashboard", label: I18n.tr("Overview"), from: "the Fedora button" }, + overview: { glyph: "dashboard", label: I18n.tr("Overview"), from: "the NerdOS button" }, sound: { glyph: "volume_down", label: I18n.tr("Sound"), from: "the volume glyph" }, network: { glyph: "wifi", label: I18n.tr("Network"), from: "the network glyphs" }, bluetooth: { glyph: "bluetooth", label: I18n.tr("Bluetooth"), from: "the Bluetooth glyph" }, diff --git a/roles/desktop/files/quickshell/Settings/FieldRow.qml b/desktop/quickshell/Settings/FieldRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/FieldRow.qml rename to desktop/quickshell/Settings/FieldRow.qml diff --git a/roles/desktop/files/quickshell/Settings/FolderDialog.qml b/desktop/quickshell/Settings/FolderDialog.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/FolderDialog.qml rename to desktop/quickshell/Settings/FolderDialog.qml diff --git a/roles/desktop/files/quickshell/Settings/GitHubWatchList.qml b/desktop/quickshell/Settings/GitHubWatchList.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/GitHubWatchList.qml rename to desktop/quickshell/Settings/GitHubWatchList.qml diff --git a/roles/desktop/files/quickshell/Settings/HotspotGroup.qml b/desktop/quickshell/Settings/HotspotGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/HotspotGroup.qml rename to desktop/quickshell/Settings/HotspotGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/IdleTimeline.qml b/desktop/quickshell/Settings/IdleTimeline.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/IdleTimeline.qml rename to desktop/quickshell/Settings/IdleTimeline.qml diff --git a/roles/desktop/files/quickshell/Settings/IdleTimelineModel.js b/desktop/quickshell/Settings/IdleTimelineModel.js similarity index 100% rename from roles/desktop/files/quickshell/Settings/IdleTimelineModel.js rename to desktop/quickshell/Settings/IdleTimelineModel.js diff --git a/roles/desktop/files/quickshell/Settings/InputDraft.js b/desktop/quickshell/Settings/InputDraft.js similarity index 100% rename from roles/desktop/files/quickshell/Settings/InputDraft.js rename to desktop/quickshell/Settings/InputDraft.js diff --git a/roles/desktop/files/quickshell/Settings/InputDraftState.qml b/desktop/quickshell/Settings/InputDraftState.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/InputDraftState.qml rename to desktop/quickshell/Settings/InputDraftState.qml diff --git a/roles/desktop/files/quickshell/Settings/InputDrafts.qml b/desktop/quickshell/Settings/InputDrafts.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/InputDrafts.qml rename to desktop/quickshell/Settings/InputDrafts.qml diff --git a/roles/desktop/files/quickshell/Settings/IpSettings.qml b/desktop/quickshell/Settings/IpSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/IpSettings.qml rename to desktop/quickshell/Settings/IpSettings.qml diff --git a/roles/desktop/files/quickshell/Settings/KeyboardPage.qml b/desktop/quickshell/Settings/KeyboardPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/KeyboardPage.qml rename to desktop/quickshell/Settings/KeyboardPage.qml diff --git a/roles/desktop/files/quickshell/Settings/LocalTime.js b/desktop/quickshell/Settings/LocalTime.js similarity index 100% rename from roles/desktop/files/quickshell/Settings/LocalTime.js rename to desktop/quickshell/Settings/LocalTime.js diff --git a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml b/desktop/quickshell/Settings/ModuleDetailView.qml similarity index 97% rename from roles/desktop/files/quickshell/Settings/ModuleDetailView.qml rename to desktop/quickshell/Settings/ModuleDetailView.qml index 79890a2b..27c021c8 100644 --- a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml +++ b/desktop/quickshell/Settings/ModuleDetailView.qml @@ -1584,43 +1584,11 @@ SettingsPage { onResetRequested: view.resetOpt("pollMins") } - SwitchRow { + SettingsHint { width: parent.width - label: I18n.tr("Include Flatpak") - checked: view.opts.flatpak - dirty: view.optDirty("flatpak") - onToggled: value => view.setOpt("flatpak", value) - onResetRequested: view.resetOpt("flatpak") + text: I18n.tr("Preview upstream packages, NerdOS, or both in Updates. Review the complete build before applying. Personal inputs and Flatpak applications are managed separately.") } - SwitchRow { - width: parent.width - label: I18n.tr("Notify when updates appear") - checked: view.opts.notify - dirty: view.optDirty("notify") - onToggled: value => view.setOpt("notify", value) - onResetRequested: view.resetOpt("notify") - } - - // A system timer, not a widget option: its enablement is the - // setting (see Updates.autoDownloadState), and changing it asks - // for authorization. Hidden where the timer is not installed. - SwitchRow { - width: parent.width - visible: Updates.autoDownloadState !== "unavailable" - label: I18n.tr("Download updates automatically") - description: Updates.autoDownloadError !== "" ? Updates.autoDownloadError - : "In the background, on AC power and unmetered networks. Installing still waits for you." - hintTone: Updates.autoDownloadError !== "" ? "error" : "info" - checked: Updates.autoDownloadState === "enabled" - // Nothing to reset here: systemd keeps the choice, not shell.json. - dirty: false - resetKeys: [] - disabledReason: Updates.autoDownloadBusy ? "Waiting for authorization…" - : Updates.autoDownloadState === "unknown" ? "Checking…" : "" - onToggled: value => Updates.setAutoDownload(value) - Component.onCompleted: Updates.refreshAutoDownload() - } } } diff --git a/roles/desktop/files/quickshell/Settings/ModulesPage.qml b/desktop/quickshell/Settings/ModulesPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ModulesPage.qml rename to desktop/quickshell/Settings/ModulesPage.qml diff --git a/roles/desktop/files/quickshell/Settings/MuteButton.qml b/desktop/quickshell/Settings/MuteButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/MuteButton.qml rename to desktop/quickshell/Settings/MuteButton.qml diff --git a/roles/desktop/files/quickshell/Settings/NetworkPage.qml b/desktop/quickshell/Settings/NetworkPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/NetworkPage.qml rename to desktop/quickshell/Settings/NetworkPage.qml diff --git a/roles/desktop/files/quickshell/Settings/NightLightGroup.qml b/desktop/quickshell/Settings/NightLightGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/NightLightGroup.qml rename to desktop/quickshell/Settings/NightLightGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/NotificationsPage.qml b/desktop/quickshell/Settings/NotificationsPage.qml similarity index 98% rename from roles/desktop/files/quickshell/Settings/NotificationsPage.qml rename to desktop/quickshell/Settings/NotificationsPage.qml index eab68ff6..645c6095 100644 --- a/roles/desktop/files/quickshell/Settings/NotificationsPage.qml +++ b/desktop/quickshell/Settings/NotificationsPage.qml @@ -21,18 +21,18 @@ SettingsPage { } function sendTest() { - Notifs.send({ appName: "CybexOS Settings", appIcon: "preferences-system-notifications", + Notifs.send({ appName: "NerdOS Settings", appIcon: "preferences-system-notifications", summary: I18n.tr("Test notification"), body: I18n.tr("Toasts use your current position, duration, and style settings.") }); } function sendGroupedPreview() { for (let i = 1; i <= 4; i++) - Notifs.send({ appName: "CybexOS Settings", summary: I18n.tr("Grouped popups"), body: "Preview " + i }); + Notifs.send({ appName: "NerdOS Settings", summary: I18n.tr("Grouped popups"), body: "Preview " + i }); } function sendVerificationPreview() { - Notifs.send({ appName: "CybexOS Settings", summary: I18n.tr("Send verification preview"), + Notifs.send({ appName: "NerdOS Settings", summary: I18n.tr("Send verification preview"), body: "Your verification code is 482913. https://example.com/" }); } diff --git a/roles/desktop/files/quickshell/Settings/PickerRow.qml b/desktop/quickshell/Settings/PickerRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PickerRow.qml rename to desktop/quickshell/Settings/PickerRow.qml diff --git a/roles/desktop/files/quickshell/Settings/PillRow.qml b/desktop/quickshell/Settings/PillRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PillRow.qml rename to desktop/quickshell/Settings/PillRow.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginAppearance.qml b/desktop/quickshell/Settings/PluginAppearance.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginAppearance.qml rename to desktop/quickshell/Settings/PluginAppearance.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginRow.qml b/desktop/quickshell/Settings/PluginRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginRow.qml rename to desktop/quickshell/Settings/PluginRow.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginSource.js b/desktop/quickshell/Settings/PluginSource.js similarity index 88% rename from roles/desktop/files/quickshell/Settings/PluginSource.js rename to desktop/quickshell/Settings/PluginSource.js index fdbdea17..8746585d 100644 --- a/roles/desktop/files/quickshell/Settings/PluginSource.js +++ b/desktop/quickshell/Settings/PluginSource.js @@ -2,12 +2,12 @@ // pasted into its Source field. plugins.omarchy.org hands out a shell // command ("omarchy plugin add https://github.com/acme/weather.git // --enable"), so the field accepts that as well as a bare Git URL or local -// path: a leading prompt and the "omarchy|cybex plugin add|install" words go, +// path: a leading prompt and the "omarchy|nerd plugin add|install" words go, // flags go, quotes around the source go. New plugins still start switched // off here; --enable is dropped rather than honoured. Returns "" when // nothing usable is left. Pure — no Qt APIs — so it runs under Node in tests. -var COMMAND = /^(?:[$#>]\s*)?(?:omarchy|cybex)\s+plugins?\s+(?:add|install)(?:\s+|$)/i; +var COMMAND = /^(?:[$#>]\s*)?(?:omarchy|nerd)\s+plugins?\s+(?:add|install)(?:\s+|$)/i; function unquote(token) { var match = /^(["'])(.*)\1$/.exec(token); diff --git a/roles/desktop/files/quickshell/Settings/PluginWidgetSettings.qml b/desktop/quickshell/Settings/PluginWidgetSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginWidgetSettings.qml rename to desktop/quickshell/Settings/PluginWidgetSettings.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginsPage.qml b/desktop/quickshell/Settings/PluginsPage.qml similarity index 98% rename from roles/desktop/files/quickshell/Settings/PluginsPage.qml rename to desktop/quickshell/Settings/PluginsPage.qml index 1d6ee1cf..c24792b1 100644 --- a/roles/desktop/files/quickshell/Settings/PluginsPage.qml +++ b/desktop/quickshell/Settings/PluginsPage.qml @@ -4,7 +4,7 @@ import "../Common" import "PluginSource.js" as PluginSource // Omarchy plugins: community bar widgets, panels, overlays, menus and -// services built for Omarchy's shell, which Cybex runs unchanged. The page +// services built for Omarchy's shell, which NerdOS runs unchanged. The page // leads with where to find them (plugins.omarchy.org), then the three steps // from there to a running plugin — copy its install command, paste it into // Source, switch it on — and ends with the installed list, each plugin with @@ -147,7 +147,7 @@ SettingsPage { } Text { width: parent.width - text: I18n.tr("Bar widgets, panels, overlays, menus and services made for Omarchy. They run on CybexOS unchanged.") + text: I18n.tr("Bar widgets, panels, overlays, menus and services made for Omarchy. They run on NerdOS unchanged.") font.family: Theme.fontMenu font.pixelSize: Theme.typography.secondary color: Theme.textFaint diff --git a/roles/desktop/files/quickshell/Settings/PowerPage.qml b/desktop/quickshell/Settings/PowerPage.qml similarity index 97% rename from roles/desktop/files/quickshell/Settings/PowerPage.qml rename to desktop/quickshell/Settings/PowerPage.qml index dc2bb739..4dc1ef55 100644 --- a/roles/desktop/files/quickshell/Settings/PowerPage.qml +++ b/desktop/quickshell/Settings/PowerPage.qml @@ -77,7 +77,7 @@ SettingsPage { SettingsHint { width: parent.width text: SysInfo.idleUserConfig - ? "~/.config/cybexos/hypr/hypridle.conf replaces these timeouts. Remove it to use them." + ? "~/.config/nerdos/hypr/hypridle.conf replaces these timeouts. Remove it to use them." : SysInfo.idleTimeoutsError !== "" ? SysInfo.idleTimeoutsError : "Counted from the last keyboard or pointer input. Stay awake pauses them." tone: SysInfo.idleUserConfig ? "warning" diff --git a/desktop/quickshell/Settings/RecoveryGroup.qml b/desktop/quickshell/Settings/RecoveryGroup.qml new file mode 100644 index 00000000..64eb030f --- /dev/null +++ b/desktop/quickshell/Settings/RecoveryGroup.qml @@ -0,0 +1,15 @@ +import QtQuick +import "../Common" +SettingsGroup { + width: parent.width + title: I18n.tr("Recovery points") + SettingsHint { + width: parent.width + text: I18n.tr("NixOS generations retain system code and configuration. Credentials, VPN profiles, documents, databases and plugin data stay in persistent storage. Generation rollback does not restore application data.") + } + ValueRow { + label: I18n.tr("System generations") + hint: Updates.deployment.recovery || I18n.tr("Previous generations are available in the boot menu.") + SettingsAction { text: I18n.tr("View generations"); onTriggered: Updates.openRecoveryPoints() } + } +} diff --git a/roles/desktop/files/quickshell/Settings/RegionPage.qml b/desktop/quickshell/Settings/RegionPage.qml similarity index 99% rename from roles/desktop/files/quickshell/Settings/RegionPage.qml rename to desktop/quickshell/Settings/RegionPage.qml index 6881a4a9..6c9fb585 100644 --- a/roles/desktop/files/quickshell/Settings/RegionPage.qml +++ b/desktop/quickshell/Settings/RegionPage.qml @@ -40,7 +40,7 @@ SettingsPage { function onSnapshotChanged() { page.load(); } function onLoadedChanged() { page.load(); } function onCompleted(result) { - if (!result.success) return; + if (!result.success || result.staged) return; if (page.service.request.action === "timezone") page.originalTimezone = page.timezone; if (page.service.request.action === "locale") page.originalLocale = page.locale; } diff --git a/roles/desktop/files/quickshell/Settings/ResponsiveActionRow.qml b/desktop/quickshell/Settings/ResponsiveActionRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ResponsiveActionRow.qml rename to desktop/quickshell/Settings/ResponsiveActionRow.qml diff --git a/roles/desktop/files/quickshell/Settings/RowCluster.qml b/desktop/quickshell/Settings/RowCluster.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/RowCluster.qml rename to desktop/quickshell/Settings/RowCluster.qml diff --git a/roles/desktop/files/quickshell/Settings/SectionHeader.qml b/desktop/quickshell/Settings/SectionHeader.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SectionHeader.qml rename to desktop/quickshell/Settings/SectionHeader.qml diff --git a/roles/desktop/files/quickshell/Settings/SecurityPage.qml b/desktop/quickshell/Settings/SecurityPage.qml similarity index 93% rename from roles/desktop/files/quickshell/Settings/SecurityPage.qml rename to desktop/quickshell/Settings/SecurityPage.qml index 06eea08b..24ec15af 100644 --- a/roles/desktop/files/quickshell/Settings/SecurityPage.qml +++ b/desktop/quickshell/Settings/SecurityPage.qml @@ -7,12 +7,13 @@ import "../Common/Format.js" as Format import "../Common/SecurityHelpers.js" as SecurityHelpers // Security: fingerprints and security keys as alternatives to the password -// for the lock screen, sudo and system prompts (docs/security.md). Signing in +// for sudo and system prompts (docs/hardware-security.md). Signing in // after a restart always asks for the password, which unlocks the keyring. // // Nothing here is privileged by itself. fprintd asks for authorization (the // shell's Polkit dialog) before it adds or removes a fingerprint, and the -// fingerprint switch changes the system's authselect profile through pkexec. +// authentication policy is declared in personal NixOS modules and reviewed +// through the staged update lifecycle. // A key registration is a public key in ~/.config/Yubico/u2f_keys. // // Every request runs off the UI thread: the shared settings backend for @@ -136,6 +137,18 @@ SettingsPage { anchors.top: parent.top spacing: Theme.settingsGroupSpacing + SettingsGroup { + width: parent.width + title: I18n.tr("Password") + SettingsHint { width: parent.width; text: I18n.tr("Change your login and disk encryption password together. Use at least 8 characters.") } + SettingsAction { + text: I18n.tr("Change password") + glyph: "lock" + enabled: !Session.live + onTriggered: Quickshell.execDetached(["nerdos-password"]) + } + } + SettingsGroup { width: parent.width title: I18n.tr("Fingerprint") @@ -324,15 +337,18 @@ SettingsPage { } } - SwitchRow { + ValueRow { width: parent.width visible: page.readerReady && page.fingers.length > 0 label: I18n.tr("Use fingerprint to unlock and authorise") - description: I18n.tr("Applies to every account on this computer. The password always works too.") - checked: page.snapshot.fingerprintAuth === true - disabledReason: page.snapshot.fingerprintManaged === false ? "Managed outside CybexOS" - : page.service.busy ? "Updating…" : "" - onToggled: value => page.act({ action: "fingerprint-auth", enabled: value }) + value: page.snapshot.fingerprintAuth === true ? I18n.tr("Enabled") : I18n.tr("Disabled") + hint: I18n.tr("Managed in /etc/nixos/local") + } + SettingsHint { + width: parent.width + visible: page.readerReady && page.fingers.length > 0 + maximumLines: 6 + text: I18n.tr("Set nerdos.authentication.fingerprint in a personal NixOS module, then preview with nerd update stage local and apply the reviewed change. Enrol and remove fingerprints here.") } } diff --git a/roles/desktop/files/quickshell/Settings/SelectRow.qml b/desktop/quickshell/Settings/SelectRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SelectRow.qml rename to desktop/quickshell/Settings/SelectRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsAction.qml b/desktop/quickshell/Settings/SettingsAction.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsAction.qml rename to desktop/quickshell/Settings/SettingsAction.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsDisclosure.qml b/desktop/quickshell/Settings/SettingsDisclosure.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsDisclosure.qml rename to desktop/quickshell/Settings/SettingsDisclosure.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsField.qml b/desktop/quickshell/Settings/SettingsField.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsField.qml rename to desktop/quickshell/Settings/SettingsField.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsGroup.qml b/desktop/quickshell/Settings/SettingsGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsGroup.qml rename to desktop/quickshell/Settings/SettingsGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsHint.qml b/desktop/quickshell/Settings/SettingsHint.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsHint.qml rename to desktop/quickshell/Settings/SettingsHint.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsPage.qml b/desktop/quickshell/Settings/SettingsPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsPage.qml rename to desktop/quickshell/Settings/SettingsPage.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsRow.qml b/desktop/quickshell/Settings/SettingsRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsRow.qml rename to desktop/quickshell/Settings/SettingsRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsSelect.qml b/desktop/quickshell/Settings/SettingsSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsSelect.qml rename to desktop/quickshell/Settings/SettingsSelect.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsSubsection.qml b/desktop/quickshell/Settings/SettingsSubsection.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsSubsection.qml rename to desktop/quickshell/Settings/SettingsSubsection.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsTextRow.qml b/desktop/quickshell/Settings/SettingsTextRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsTextRow.qml rename to desktop/quickshell/Settings/SettingsTextRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsTooltip.qml b/desktop/quickshell/Settings/SettingsTooltip.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsTooltip.qml rename to desktop/quickshell/Settings/SettingsTooltip.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsView.qml b/desktop/quickshell/Settings/SettingsView.qml similarity index 99% rename from roles/desktop/files/quickshell/Settings/SettingsView.qml rename to desktop/quickshell/Settings/SettingsView.qml index 3e6f1dd4..b6b26235 100644 --- a/roles/desktop/files/quickshell/Settings/SettingsView.qml +++ b/desktop/quickshell/Settings/SettingsView.qml @@ -610,12 +610,15 @@ PopoutPanel { case "bar": return barPage; case "workspaces": return workspacesPage; case "notifications": return notificationsPage; + case "assistant": return assistantPage; case "displays": return displaysPage; case "sound": return soundPage; case "network": return networkPage; case "touchpad": return touchpadPage; case "keyboard": return keyboardPage; case "power": return powerPage; + case "applications": return applicationsPage; + case "storage": return storagePage; case "region": return regionPage; case "accounts": return accountsPage; case "security": return securityPage; @@ -801,6 +804,7 @@ PopoutPanel { } Component { id: workspacesPage; WorkspacesPage {} } Component { id: notificationsPage; NotificationsPage {} } + Component { id: assistantPage; AssistantPage {} } Component { id: pluginsPage; PluginsPage {} } Component { id: networkPage; NetworkPage {} } Component { id: soundPage; SoundPage {} } @@ -810,6 +814,8 @@ PopoutPanel { Component { id: touchpadPage; TouchpadPage {} } Component { id: keyboardPage; KeyboardPage {} } Component { id: powerPage; PowerPage {} } + Component { id: applicationsPage; ApplicationsPage {} } + Component { id: storagePage; StoragePage {} } Component { id: regionPage; RegionPage {} } Component { id: backupPage; BackupPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/roles/desktop/files/quickshell/Settings/SliderRow.qml b/desktop/quickshell/Settings/SliderRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SliderRow.qml rename to desktop/quickshell/Settings/SliderRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SoundPage.qml b/desktop/quickshell/Settings/SoundPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SoundPage.qml rename to desktop/quickshell/Settings/SoundPage.qml diff --git a/desktop/quickshell/Settings/StoragePage.qml b/desktop/quickshell/Settings/StoragePage.qml new file mode 100644 index 00000000..3116a751 --- /dev/null +++ b/desktop/quickshell/Settings/StoragePage.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" + +SettingsPage { + id: page + property var draft: ({automatic:true, keepConfirmed:5, keepDays:30}) + readonly property var status: Maintenance.snapshot + readonly property var configuredPolicy: status.desiredPolicy || status.policy + property var policyBaseline: ({automatic:true, keepConfirmed:5, keepDays:30}) + function same(a, b) { return a.automatic === b.automatic && a.keepConfirmed === b.keepConfirmed && a.keepDays === b.keepDays; } + readonly property bool dirty: !same(draft, policyBaseline) + readonly property bool conflict: dirty && !same(policyBaseline, configuredPolicy) + function discard() { policyBaseline = Object.assign({}, configuredPolicy); draft = Object.assign({}, policyBaseline); } + function edit(values) { draft = Object.assign({}, draft, values); } + Binding { target: Maintenance; property: "watched"; value: page.visible && Settings.panelOpen } + Connections { + target: Maintenance + function onSnapshotChanged() { if (!page.dirty || page.same(page.draft, page.configuredPolicy)) page.discard(); } + } + Component.onCompleted: { discard(); Maintenance.refresh(); } + Column { + width: parent.width; spacing: Theme.settingsGroupSpacing + SettingsGroup { + width: parent.width; title: I18n.tr("Storage") + ValueRow { width: parent.width; label: I18n.tr("Available space"); value: Maintenance.bytes(page.status.space.available) + " / " + Maintenance.bytes(page.status.space.total); valueColor: page.status.space.low ? Theme.amber : Theme.textMid } + SettingsHint { width: parent.width; visible: page.status.space.low === true; text: I18n.tr("Storage is running low. Review cleanup before building another deployment."); tone: "warning" } + SettingsHint { width: parent.width; text: I18n.tr("Cleanup removes obsolete system references and unreachable Nix store contents. Documents, downloads, application data and saved personal configuration stay yours.") } + ValueRow { + width: parent.width; label: I18n.tr("System cleanup") + value: Maintenance.busy ? I18n.tr("Working…") : page.status.state || "" + SettingsAction { text: I18n.tr("Review cleanup…"); enabled: !Maintenance.busy; onTriggered: Maintenance.preview() } + SettingsAction { text: I18n.tr("Resume cleanup"); visible: page.status.recoveryPending === true; enabled: !Maintenance.busy; onTriggered: Maintenance.request(["resume"]) } + } + SettingsHint { width: parent.width; visible: Maintenance.error !== ""; text: Maintenance.error; tone: "error" } + ValueRow { + width: parent.width; visible: Maintenance.ready; label: I18n.tr("Recovery points to retire") + value: String((page.status.candidates || []).length) + hint: I18n.tr("Estimated system contents: ") + Maintenance.bytes(page.status.estimatedBytes) + I18n.tr(". Shared paths stay while another profile or recovery point needs them.") + SettingsAction { text: I18n.tr("Clean reviewed items"); enabled: Maintenance.ready; danger: true; onTriggered: Maintenance.clean() } + } + ValueRow { width: parent.width; visible: page.status.reclaimedBytes !== undefined; label: I18n.tr("Last cleanup removed"); value: Maintenance.bytes(page.status.reclaimedBytes); hint: I18n.tr("Uncompressed store contents; filesystem space can differ.") } + } + SettingsGroup { + width: parent.width; title: I18n.tr("Retention") + SwitchRow { + width: parent.width; label: I18n.tr("Automatic weekly cleanup"); property string searchAnchor: "storage-automatic" + checked: page.draft.automatic + description: I18n.tr("Runs after a confirmed boot and skips active updates or unresolved recovery. Changes are staged for review.") + onToggled: value => page.edit({automatic:value}) + } + SelectRow { + width: parent.width; label: I18n.tr("Confirmed deployments to keep") + model: [5, 10, 20, 50, 100].map(v => ({value:v, label:String(v)})); current: page.draft.keepConfirmed + onPicked: value => page.edit({keepConfirmed:value}) + } + SelectRow { + width: parent.width; label: I18n.tr("Days of recovery history") + model: [30, 60, 90, 180, 365].map(v => ({value:v, label:String(v)})); current: page.draft.keepDays + onPicked: value => page.edit({keepDays:value}) + } + ValueRow { + width: parent.width; label: I18n.tr("Cleanup preferences") + SettingsAction { text: I18n.tr("Discard"); enabled: page.dirty; onTriggered: page.discard() } + SettingsAction { text: I18n.tr("Preview changes"); enabled: (page.dirty || page.status.configurationChanged === true) && !page.status.configurationError && !page.conflict && !Maintenance.busy && !Updates.runActive && !Updates.rebootRecommended; onTriggered: Maintenance.changePolicy(page.draft, page.policyBaseline) } + } + } + SettingsHint { width: parent.width; visible: page.conflict; tone: "warning"; text: I18n.tr("Cleanup preferences changed elsewhere. Discard your draft and review the current preferences.") } + SettingsHint { width: parent.width; visible: !!page.status.configurationError; tone: "error"; text: page.status.configurationError || "" } + DeploymentReview { width: parent.width; kind: "maintenance" } + SettingsGroup { + width: parent.width; title: I18n.tr("Recovery points") + SettingsHint { width: parent.width; text: I18n.tr("The running system, its fallback and pending recovery are always protected. Pin another confirmed recovery point to keep it indefinitely.") } + Repeater { + model: page.status.deployments || [] + delegate: ValueRow { + id: recoveryRow + required property var modelData + width: parent.width; label: modelData.id.slice(0, 10) + value: modelData.retired ? I18n.tr("Retired") : modelData.eligible ? I18n.tr("Eligible for cleanup") : I18n.tr("Protected") + hint: (modelData.reasons || []).join(" · ") + SettingsAction { + visible: recoveryRow.modelData.confirmed && !recoveryRow.modelData.retired + text: recoveryRow.modelData.pinned ? I18n.tr("Unpin") : I18n.tr("Pin") + enabled: !Maintenance.busy + onTriggered: Maintenance.pin(recoveryRow.modelData.id, !recoveryRow.modelData.pinned) + } + } + } + } + } +} diff --git a/roles/desktop/files/quickshell/Settings/StreamRow.qml b/desktop/quickshell/Settings/StreamRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/StreamRow.qml rename to desktop/quickshell/Settings/StreamRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SwitchRow.qml b/desktop/quickshell/Settings/SwitchRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SwitchRow.qml rename to desktop/quickshell/Settings/SwitchRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SystemChoice.qml b/desktop/quickshell/Settings/SystemChoice.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SystemChoice.qml rename to desktop/quickshell/Settings/SystemChoice.qml diff --git a/roles/desktop/files/quickshell/Settings/SystemServiceStatus.qml b/desktop/quickshell/Settings/SystemServiceStatus.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SystemServiceStatus.qml rename to desktop/quickshell/Settings/SystemServiceStatus.qml diff --git a/roles/desktop/files/quickshell/Settings/TextSizePreview.qml b/desktop/quickshell/Settings/TextSizePreview.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/TextSizePreview.qml rename to desktop/quickshell/Settings/TextSizePreview.qml diff --git a/roles/desktop/files/quickshell/Settings/TimeRow.qml b/desktop/quickshell/Settings/TimeRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/TimeRow.qml rename to desktop/quickshell/Settings/TimeRow.qml diff --git a/roles/desktop/files/quickshell/Settings/TouchpadPage.qml b/desktop/quickshell/Settings/TouchpadPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/TouchpadPage.qml rename to desktop/quickshell/Settings/TouchpadPage.qml diff --git a/roles/desktop/files/quickshell/Settings/UndoChip.qml b/desktop/quickshell/Settings/UndoChip.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/UndoChip.qml rename to desktop/quickshell/Settings/UndoChip.qml diff --git a/roles/desktop/files/quickshell/Settings/ValueRow.qml b/desktop/quickshell/Settings/ValueRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ValueRow.qml rename to desktop/quickshell/Settings/ValueRow.qml diff --git a/roles/desktop/files/quickshell/Settings/VolumeRow.qml b/desktop/quickshell/Settings/VolumeRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/VolumeRow.qml rename to desktop/quickshell/Settings/VolumeRow.qml diff --git a/roles/desktop/files/quickshell/Settings/VpnGroup.qml b/desktop/quickshell/Settings/VpnGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/VpnGroup.qml rename to desktop/quickshell/Settings/VpnGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/WallpaperOnlineView.qml b/desktop/quickshell/Settings/WallpaperOnlineView.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WallpaperOnlineView.qml rename to desktop/quickshell/Settings/WallpaperOnlineView.qml diff --git a/roles/desktop/files/quickshell/Settings/WallpaperPage.qml b/desktop/quickshell/Settings/WallpaperPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WallpaperPage.qml rename to desktop/quickshell/Settings/WallpaperPage.qml diff --git a/roles/desktop/files/quickshell/Settings/WeatherLocationPicker.qml b/desktop/quickshell/Settings/WeatherLocationPicker.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WeatherLocationPicker.qml rename to desktop/quickshell/Settings/WeatherLocationPicker.qml diff --git a/roles/desktop/files/quickshell/Settings/WidgetEditorRow.qml b/desktop/quickshell/Settings/WidgetEditorRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WidgetEditorRow.qml rename to desktop/quickshell/Settings/WidgetEditorRow.qml diff --git a/roles/desktop/files/quickshell/Settings/WidgetPill.qml b/desktop/quickshell/Settings/WidgetPill.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WidgetPill.qml rename to desktop/quickshell/Settings/WidgetPill.qml diff --git a/roles/desktop/files/quickshell/Settings/WorkspacesPage.qml b/desktop/quickshell/Settings/WorkspacesPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WorkspacesPage.qml rename to desktop/quickshell/Settings/WorkspacesPage.qml diff --git a/desktop/quickshell/Settings/qmldir b/desktop/quickshell/Settings/qmldir new file mode 100644 index 00000000..fc5c108c --- /dev/null +++ b/desktop/quickshell/Settings/qmldir @@ -0,0 +1,84 @@ +# Sibling-type resolution inside Settings/ relies on this explicit index — +# the engine's implicit directory scan proved unreliable for this directory. +# Every Settings/*.qml type must be listed (settings.test.cjs enforces it). +AppearancePage AppearancePage.qml +AssistantPage AssistantPage.qml +BarLayoutGroups BarLayoutGroups.qml +BarBackgroundGroup BarBackgroundGroup.qml +DrawerPage DrawerPage.qml +FolderDialog FolderDialog.qml +GitHubWatchList GitHubWatchList.qml +ModuleDetailView ModuleDetailView.qml +ModulesPage ModulesPage.qml +NotificationsPage NotificationsPage.qml +WorkspacesPage WorkspacesPage.qml +PickerRow PickerRow.qml +PillRow PillRow.qml +ResponsiveActionRow ResponsiveActionRow.qml +SectionHeader SectionHeader.qml +SettingsTextRow SettingsTextRow.qml +SettingsAction SettingsAction.qml +SettingsGroup SettingsGroup.qml +SettingsPage SettingsPage.qml +SettingsRow SettingsRow.qml +SettingsView SettingsView.qml +SliderRow SliderRow.qml +SwitchRow SwitchRow.qml +PowerPage PowerPage.qml +RegionPage RegionPage.qml +TouchpadPage TouchpadPage.qml +KeyboardPage KeyboardPage.qml +NightLightGroup NightLightGroup.qml +RecoveryGroup RecoveryGroup.qml +UndoChip UndoChip.qml +WallpaperPage WallpaperPage.qml +WallpaperOnlineView WallpaperOnlineView.qml +PluginsPage PluginsPage.qml +SettingsSubsection SettingsSubsection.qml +SettingsField SettingsField.qml +WidgetEditorRow WidgetEditorRow.qml +WidgetPill WidgetPill.qml +PluginWidgetSettings PluginWidgetSettings.qml +PluginAppearance PluginAppearance.qml +AboutPage AboutPage.qml +BackupPage BackupPage.qml +SettingsHint SettingsHint.qml +TimeRow TimeRow.qml +CornerPickerRow CornerPickerRow.qml +ChipToggleRow ChipToggleRow.qml +WeatherLocationPicker WeatherLocationPicker.qml + +SoundPage SoundPage.qml +DisplaysPage DisplaysPage.qml +DisplayArrangement DisplayArrangement.qml +NetworkPage NetworkPage.qml +AccountsPage AccountsPage.qml +SecurityPage SecurityPage.qml +IpSettings IpSettings.qml +VpnGroup VpnGroup.qml +HotspotGroup HotspotGroup.qml +SystemChoice SystemChoice.qml +SystemServiceStatus SystemServiceStatus.qml +SelectRow SelectRow.qml +SettingsSelect SettingsSelect.qml +ApplyBar ApplyBar.qml +SettingsDisclosure SettingsDisclosure.qml +SettingsTooltip SettingsTooltip.qml +TextSizePreview TextSizePreview.qml +ChoiceRow ChoiceRow.qml +ActionRow ActionRow.qml +MuteButton MuteButton.qml +VolumeRow VolumeRow.qml +StreamRow StreamRow.qml +DraftFieldRow DraftFieldRow.qml +BarPreview BarPreview.qml +IdleTimeline IdleTimeline.qml +ValueRow ValueRow.qml +FieldRow FieldRow.qml +RowCluster RowCluster.qml +PluginRow PluginRow.qml +InputDraftState InputDraftState.qml +singleton InputDrafts InputDrafts.qml +ApplicationsPage ApplicationsPage.qml +StoragePage StoragePage.qml +DeploymentReview DeploymentReview.qml diff --git a/roles/desktop/files/quickshell/SettingsWindow.qml b/desktop/quickshell/SettingsWindow.qml similarity index 90% rename from roles/desktop/files/quickshell/SettingsWindow.qml rename to desktop/quickshell/SettingsWindow.qml index a1a8b048..bc84c7ae 100644 --- a/roles/desktop/files/quickshell/SettingsWindow.qml +++ b/desktop/quickshell/SettingsWindow.qml @@ -8,7 +8,7 @@ FloatingWindow { id: window // Stable compositor target; the workspace header carries localized copy. - title: "CybexOS Settings" // i18n: untranslated (compositor identity) + title: "NerdOS Settings" // i18n: untranslated (compositor identity) visible: false color: Theme.panelSurface implicitWidth: Math.min(Theme.scaled(900, Theme.contentScale), @@ -31,7 +31,7 @@ FloatingWindow { function onPresentPanel() { window.minimized = false; - Hyprland.dispatch('hl.dsp.focus({ window = "title:^CybexOS Settings$" })'); + Hyprland.dispatch('hl.dsp.focus({ window = "title:^NerdOS Settings$" })'); } } diff --git a/roles/desktop/files/quickshell/ShortcutsOverlay.qml b/desktop/quickshell/ShortcutsOverlay.qml similarity index 99% rename from roles/desktop/files/quickshell/ShortcutsOverlay.qml rename to desktop/quickshell/ShortcutsOverlay.qml index eec2e011..53b7fa67 100644 --- a/roles/desktop/files/quickshell/ShortcutsOverlay.qml +++ b/desktop/quickshell/ShortcutsOverlay.qml @@ -377,7 +377,7 @@ PanelWindow { width: parent.width visible: Session.shortcutGroups.length > 0 wrapMode: Text.Wrap - text: I18n.tr("Bindings you add in ~/.config/cybexos/hypr/user.lua appear here when they have a description.") + text: I18n.tr("Bindings you add in ~/.config/nerdos/hypr/user.lua appear here when they have a description.") font.family: Theme.fontMenu font.pixelSize: Theme.typography.secondary color: Theme.textFaint diff --git a/desktop/quickshell/SoftwareWindow.qml b/desktop/quickshell/SoftwareWindow.qml new file mode 100644 index 00000000..7702ff96 --- /dev/null +++ b/desktop/quickshell/SoftwareWindow.qml @@ -0,0 +1,104 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls as Controls +import Quickshell +import Quickshell.Hyprland +import "Common" +import "Settings" as SettingsUi + +FloatingWindow { + id: window + title: "NerdOS Software" // i18n: untranslated (compositor identity) + visible: Software.open + color: Theme.panelSurface + implicitWidth: 960 + implicitHeight: 720 + minimumSize: Qt.size(560, 420) + onClosed: Software.close() + onVisibleChanged: { if (visible) { screen = Screens.focused; minimized = false; } } + Connections { + target: Software + function onPresent() { + window.minimized = false; + Hyprland.dispatch('hl.dsp.focus({ window = "title:^NerdOS Software$" })'); + } + } + Column { + focus: true + Keys.onEscapePressed: Software.close() + anchors.fill: parent; anchors.margins: Theme.panelPadding + spacing: Theme.controlSpacing + Row { + width: parent.width; spacing: Theme.controlSpacing + Text { text: I18n.tr("Software"); color: Theme.textHi; font.family: Theme.fontMenu; font.pixelSize: Theme.typography.title; anchors.verticalCenter: parent.verticalCenter } + SettingsUi.SettingsAction { text: I18n.tr("Browse"); primary: !Software.installedTab; onTriggered: Software.installedTab = false } + SettingsUi.SettingsAction { text: I18n.tr("Installed"); primary: Software.installedTab; onTriggered: Software.installedTab = true } + SettingsUi.SettingsAction { text: I18n.tr("Refresh"); enabled: !Software.busy; onTriggered: Software.refresh() } + } + Controls.TextField { + id: search + width: parent.width + placeholderText: I18n.tr("Search native applications and packages") + text: Software.query; onTextEdited: Software.query = text + color: Theme.textHi; placeholderTextColor: Theme.textDim + font.family: Theme.fontMenu; font.pixelSize: Theme.typography.control + background: Rectangle { color: Theme.cardFill; radius: Theme.chipRadius; border.color: Theme.stroke; border.width: 1 } + Accessible.name: I18n.tr("Search software") + } + Text { + width: parent.width + text: Software.error || (Software.busy ? I18n.tr("Loading the machine’s locked package catalogue…") + : Software.installedTab ? I18n.tr("Packages declared elsewhere keep their existing configuration owners.") + : String(Software.total) + I18n.tr(" matching packages · showing up to 200")) + color: Software.error ? Theme.redText : Theme.textDim + font.family: Theme.fontMenu; font.pixelSize: Theme.typography.secondary; wrapMode: Text.Wrap + textFormat: Text.PlainText + } + ListView { + id: list + width: parent.width + height: Math.max(100, window.height - y - footer.height - Theme.panelPadding * 2 - Theme.controlSpacing) + clip: true + model: Software.installedTab ? (Software.inventory.packages || []).filter(p => + Software.query === "" || (p.name + " " + p.description).toLowerCase().includes(Software.query.toLowerCase())) : Software.results + spacing: Theme.controlSpacing + Controls.ScrollBar.vertical: Controls.ScrollBar {} + delegate: Rectangle { + id: row + required property var modelData + readonly property string attribute: modelData.attribute || (modelData.attributes || [])[0] || "" + width: ListView.view ? ListView.view.width : 0 + height: Math.max(76, copy.implicitHeight + 20) + color: Theme.cardFill; radius: Theme.chipRadius + Column { + id: copy + anchors.left: parent.left; anchors.leftMargin: 12; anchors.right: action.left; anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter; spacing: 3 + Text { width: parent.width; text: row.modelData.name + " " + (row.modelData.version || ""); textFormat: Text.PlainText; color: Theme.textHi; font.family: Theme.fontMenu; font.pixelSize: Theme.typography.primary; elide: Text.ElideRight } + Text { width: parent.width; text: row.modelData.description || ""; textFormat: Text.PlainText; color: Theme.textDim; font.family: Theme.fontMenu; font.pixelSize: Theme.typography.secondary; wrapMode: Text.Wrap; maximumLineCount: 2; elide: Text.ElideRight } + Text { width: parent.width; visible: Software.installedTab; text: row.modelData.managedElsewhere ? I18n.tr("Managed by existing configuration") : row.attribute; textFormat: Text.PlainText; color: Theme.textFaint; font.family: Theme.fontMono; font.pixelSize: Theme.typography.secondary; elide: Text.ElideRight } + } + SettingsUi.SettingsAction { + id: action + anchors.right: parent.right; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter + visible: row.attribute !== "" + text: Software.draft.includes(row.attribute) ? (Software.installedTab ? I18n.tr("Remove selection") : I18n.tr("Selected")) : I18n.tr("Add") + enabled: !Updates.runActive && !Updates.rebootRecommended + onTriggered: Software.toggle(row.attribute) + } + } + } + Column { + id: footer + width: parent.width; spacing: Theme.controlSpacing + Flow { + width: parent.width; spacing: Theme.controlSpacing + SettingsUi.SettingsAction { text: I18n.tr("Discard selections"); enabled: (Software.dirty || Software.conflict) && !Software.busy; onTriggered: Software.discard() } + SettingsUi.SettingsAction { text: I18n.tr("Build preview"); primary: true; enabled: Software.canStage; onTriggered: Software.stage() } + SettingsUi.SettingsAction { text: I18n.tr("Review deployment…"); visible: Software.ownDeployment; onTriggered: { Settings.showPanel("applications", Screens.focused ? Screens.focused.name : ""); } } + SettingsUi.SettingsAction { text: I18n.tr("Close"); onTriggered: Software.close() } + } + Text { width: parent.width; visible: Software.ownDeployment; text: Updates.runPhaseLabel; color: Theme.textMid; font.family: Theme.fontMenu; font.pixelSize: Theme.typography.secondary; wrapMode: Text.Wrap; textFormat: Text.PlainText } + } + } +} diff --git a/roles/desktop/files/quickshell/Ui/BackgroundMedia.qml b/desktop/quickshell/Ui/BackgroundMedia.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BackgroundMedia.qml rename to desktop/quickshell/Ui/BackgroundMedia.qml diff --git a/roles/desktop/files/quickshell/Ui/BackgroundVideo.qml b/desktop/quickshell/Ui/BackgroundVideo.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BackgroundVideo.qml rename to desktop/quickshell/Ui/BackgroundVideo.qml diff --git a/roles/desktop/files/quickshell/Ui/BarIconButton.qml b/desktop/quickshell/Ui/BarIconButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BarIconButton.qml rename to desktop/quickshell/Ui/BarIconButton.qml diff --git a/roles/desktop/files/quickshell/Ui/BarIndicator.qml b/desktop/quickshell/Ui/BarIndicator.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BarIndicator.qml rename to desktop/quickshell/Ui/BarIndicator.qml diff --git a/roles/desktop/files/quickshell/Ui/BarWidget.qml b/desktop/quickshell/Ui/BarWidget.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BarWidget.qml rename to desktop/quickshell/Ui/BarWidget.qml diff --git a/roles/desktop/files/quickshell/Ui/BorderOverlay.qml b/desktop/quickshell/Ui/BorderOverlay.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BorderOverlay.qml rename to desktop/quickshell/Ui/BorderOverlay.qml diff --git a/roles/desktop/files/quickshell/Ui/BorderSurface.qml b/desktop/quickshell/Ui/BorderSurface.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BorderSurface.qml rename to desktop/quickshell/Ui/BorderSurface.qml diff --git a/roles/desktop/files/quickshell/Ui/Button.qml b/desktop/quickshell/Ui/Button.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Button.qml rename to desktop/quickshell/Ui/Button.qml diff --git a/roles/desktop/files/quickshell/Ui/ButtonGroup.qml b/desktop/quickshell/Ui/ButtonGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ButtonGroup.qml rename to desktop/quickshell/Ui/ButtonGroup.qml diff --git a/roles/desktop/files/quickshell/Ui/ConfirmDialog.qml b/desktop/quickshell/Ui/ConfirmDialog.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ConfirmDialog.qml rename to desktop/quickshell/Ui/ConfirmDialog.qml diff --git a/roles/desktop/files/quickshell/Ui/CursorSurface.qml b/desktop/quickshell/Ui/CursorSurface.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/CursorSurface.qml rename to desktop/quickshell/Ui/CursorSurface.qml diff --git a/roles/desktop/files/quickshell/Ui/Dropdown.qml b/desktop/quickshell/Ui/Dropdown.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Dropdown.qml rename to desktop/quickshell/Ui/Dropdown.qml diff --git a/roles/desktop/files/quickshell/Ui/KeyboardPanel.qml b/desktop/quickshell/Ui/KeyboardPanel.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/KeyboardPanel.qml rename to desktop/quickshell/Ui/KeyboardPanel.qml diff --git a/roles/desktop/files/quickshell/Ui/MultiSelect.qml b/desktop/quickshell/Ui/MultiSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/MultiSelect.qml rename to desktop/quickshell/Ui/MultiSelect.qml diff --git a/roles/desktop/files/quickshell/Ui/NumberField.qml b/desktop/quickshell/Ui/NumberField.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/NumberField.qml rename to desktop/quickshell/Ui/NumberField.qml diff --git a/roles/desktop/files/quickshell/Ui/OpticalGlyph.qml b/desktop/quickshell/Ui/OpticalGlyph.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/OpticalGlyph.qml rename to desktop/quickshell/Ui/OpticalGlyph.qml diff --git a/roles/desktop/files/quickshell/Ui/Panel.qml b/desktop/quickshell/Ui/Panel.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Panel.qml rename to desktop/quickshell/Ui/Panel.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelActionButton.qml b/desktop/quickshell/Ui/PanelActionButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelActionButton.qml rename to desktop/quickshell/Ui/PanelActionButton.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelController.qml b/desktop/quickshell/Ui/PanelController.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelController.qml rename to desktop/quickshell/Ui/PanelController.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelHero.qml b/desktop/quickshell/Ui/PanelHero.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelHero.qml rename to desktop/quickshell/Ui/PanelHero.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelKeyCatcher.qml b/desktop/quickshell/Ui/PanelKeyCatcher.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelKeyCatcher.qml rename to desktop/quickshell/Ui/PanelKeyCatcher.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelSectionHeader.qml b/desktop/quickshell/Ui/PanelSectionHeader.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelSectionHeader.qml rename to desktop/quickshell/Ui/PanelSectionHeader.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelSeparator.qml b/desktop/quickshell/Ui/PanelSeparator.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelSeparator.qml rename to desktop/quickshell/Ui/PanelSeparator.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelSlider.qml b/desktop/quickshell/Ui/PanelSlider.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelSlider.qml rename to desktop/quickshell/Ui/PanelSlider.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelToolTip.qml b/desktop/quickshell/Ui/PanelToolTip.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelToolTip.qml rename to desktop/quickshell/Ui/PanelToolTip.qml diff --git a/roles/desktop/files/quickshell/Ui/PluginBarApi.qml b/desktop/quickshell/Ui/PluginBarApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PluginBarApi.qml rename to desktop/quickshell/Ui/PluginBarApi.qml diff --git a/roles/desktop/files/quickshell/Ui/PointerMoveGate.qml b/desktop/quickshell/Ui/PointerMoveGate.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PointerMoveGate.qml rename to desktop/quickshell/Ui/PointerMoveGate.qml diff --git a/roles/desktop/files/quickshell/Ui/PopupCard.qml b/desktop/quickshell/Ui/PopupCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PopupCard.qml rename to desktop/quickshell/Ui/PopupCard.qml diff --git a/roles/desktop/files/quickshell/Ui/ScreenMoveRemap.qml b/desktop/quickshell/Ui/ScreenMoveRemap.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ScreenMoveRemap.qml rename to desktop/quickshell/Ui/ScreenMoveRemap.qml diff --git a/roles/desktop/files/quickshell/Ui/SearchableDropdown.qml b/desktop/quickshell/Ui/SearchableDropdown.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/SearchableDropdown.qml rename to desktop/quickshell/Ui/SearchableDropdown.qml diff --git a/roles/desktop/files/quickshell/Ui/SpeedTestOverlay.qml b/desktop/quickshell/Ui/SpeedTestOverlay.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/SpeedTestOverlay.qml rename to desktop/quickshell/Ui/SpeedTestOverlay.qml diff --git a/roles/desktop/files/quickshell/Ui/TelemetryChart.qml b/desktop/quickshell/Ui/TelemetryChart.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/TelemetryChart.qml rename to desktop/quickshell/Ui/TelemetryChart.qml diff --git a/roles/desktop/files/quickshell/Ui/TextField.qml b/desktop/quickshell/Ui/TextField.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/TextField.qml rename to desktop/quickshell/Ui/TextField.qml diff --git a/roles/desktop/files/quickshell/Ui/Toggle.qml b/desktop/quickshell/Ui/Toggle.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Toggle.qml rename to desktop/quickshell/Ui/Toggle.qml diff --git a/roles/desktop/files/quickshell/Ui/ToggleSwitch.qml b/desktop/quickshell/Ui/ToggleSwitch.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ToggleSwitch.qml rename to desktop/quickshell/Ui/ToggleSwitch.qml diff --git a/roles/desktop/files/quickshell/Ui/WidgetButton.qml b/desktop/quickshell/Ui/WidgetButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/WidgetButton.qml rename to desktop/quickshell/Ui/WidgetButton.qml diff --git a/roles/desktop/files/quickshell/Ui/qmldir b/desktop/quickshell/Ui/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Ui/qmldir rename to desktop/quickshell/Ui/qmldir diff --git a/roles/desktop/files/quickshell/WebcamOverlay.qml b/desktop/quickshell/WebcamOverlay.qml similarity index 100% rename from roles/desktop/files/quickshell/WebcamOverlay.qml rename to desktop/quickshell/WebcamOverlay.qml diff --git a/desktop/quickshell/assets/README.md b/desktop/quickshell/assets/README.md new file mode 100644 index 00000000..2fcd352f --- /dev/null +++ b/desktop/quickshell/assets/README.md @@ -0,0 +1,44 @@ +# Quickshell icon assets + +The shell chooses icons by semantic role: + +- Generic actions and status use `Common/Sym.qml` and bundled Tabler icons. + See [tabler/README.md](tabler/README.md) for the pinned fonts, name mapping + and regeneration instructions. +- Product identities use `Common/BrandIcon.qml` and the allow-list in + `Common/BrandIcons.qml`. +- Installed applications use the desktop icon theme through + `Quickshell.iconPath()`; tray items retain the status-notifier icon supplied + by their application. +- Small animated primitives may be drawn in QML when an icon-font shape does + not animate or align correctly. + +Do not add an identity SVG directly to a view. Add its canonical asset here, +record its provenance below, register it in `BrandIcons`, and render it through +`BrandIcon`. Small interactive marks also get a `-white.svg` sibling made by +changing paint only. `BrandIcon` cross-fades those two source files so hover +does not rebuild the mark from an alpha mask and change its apparent weight. +T3's canonical mark is already white and is reused for both states. + +## Provenance + +The source revisions are pinned so each path can be audited independently of +later upstream changes. Local changes are limited to adding a shell palette +fill, removing redundant SVG metadata, or cropping a mark out of its app-icon +background. + +| Assets | Vector source | Revision | Local treatment | +| --- | --- | --- | --- | +| `claude.svg`, `gemini.svg` (`googlegemini`), `github.svg`, `kimi.svg`, `tailscale.svg`, `whatsapp.svg`, `youtube.svg` and their `-white.svg` siblings | [Simple Icons](https://github.com/simple-icons/simple-icons) | `4a79bb55697c85b8bc9f3caa22be747e0277ad4f` | Upstream paths with product/shell paint; hover siblings change only that paint. | +| `openai.svg`, `openai-white.svg` | [Tabler Icons: `brand-openai`](https://github.com/tabler/tabler-icons/blob/5a0fe38e97784d94279ce4eb1bf85f9a91bf027e/icons/outline/brand-openai.svg) | `5a0fe38e97784d94279ce4eb1bf85f9a91bf027e` | Upstream strokes with Codex/white paint. Stroke width and geometry are unchanged. | +| `slack.svg`, `slack-white.svg` | [Tabler Icons: `brand-slack`](https://github.com/tabler/tabler-icons/blob/5a0fe38e97784d94279ce4eb1bf85f9a91bf027e/icons/outline/brand-slack.svg) | `5a0fe38e97784d94279ce4eb1bf85f9a91bf027e` | Upstream strokes with Slack red/white paint. Stroke width and geometry are unchanged. | +| `grok.svg`, `grok-white.svg` | [CLI Proxy API Management Center: `grok.svg`](https://github.com/router-for-me/Cli-Proxy-API-Management-Center/blob/382c09109db2e70a6e60697c0ae0cf6ef97198e7/src/assets/icons/grok.svg) | `382c09109db2e70a6e60697c0ae0cf6ef97198e7` | Upstream Grok path with shell-silver/white paint; redundant sizing and inline-style metadata removed. | +| `t3.svg` | [T3 Code production logo](https://github.com/pingdotgg/t3code/blob/a3a8cbd60539b4af4de8f96c892dbd07a2b6c041/assets/prod/logo.svg) | `a3a8cbd60539b4af4de8f96c892dbd07a2b6c041` | Cropped to the wordmark; background removed. | +| `fusebox.svg`, `fusebox-white.svg` | [Fusebox: `assets/icon.svg`](https://github.com/DigitalPals/Fusebox/blob/11fb076e73a96684109cba3b8aa884712846a288/assets/icon.svg) | `11fb076e73a96684109cba3b8aa884712846a288` | Upstream paths and paint; size attributes removed. The hover sibling changes both paints to white. | +| `nerdos.svg`, `nerdos-white.svg` | Supplied NerdOS identity kit, `derived/quickshell/` | October 2026 | Unmodified pixel-hinted glasses; off-white/lime and white paints with identical geometry. See `assets/brand/USAGE.md`. | + +Simple Icons is distributed under CC0 1.0 and Fusebox under the Unlicense. +Tabler Icons, T3 Code, and the CLI Proxy API Management Center are MIT licensed. The NerdOS glasses are original project artwork under the repository MIT license. +Notices and modification details are retained in `THIRD_PARTY_LICENSES.md`. +Names and logos remain trademarks of their respective owners regardless of +the vector files' copyright licences. diff --git a/roles/desktop/files/quickshell/assets/THIRD_PARTY_LICENSES.md b/desktop/quickshell/assets/THIRD_PARTY_LICENSES.md similarity index 82% rename from roles/desktop/files/quickshell/assets/THIRD_PARTY_LICENSES.md rename to desktop/quickshell/assets/THIRD_PARTY_LICENSES.md index e666613a..e1602d11 100644 --- a/roles/desktop/files/quickshell/assets/THIRD_PARTY_LICENSES.md +++ b/desktop/quickshell/assets/THIRD_PARTY_LICENSES.md @@ -84,18 +84,9 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -## Fedora Project mark +## NerdOS glasses -The Fedora mark is adapted from `fedora_default-horizontal.svg` by the Fedora -Design Team, revision `e7ee4e88ac5b43a1acf2ab39157b63c80e8093f2`: -https://gitlab.com/fedora/design/team/logos/fedora-project-logos - -The source repository states that its contents are licensed under -[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), except that -Fedora and other trademarks remain the property of their respective trademark -holders. Local changes crop out the wordmark and redundant SVG metadata; -`fedora-white.svg` changes only the fill from Fedora blue to white. - -Fedora®, the Fedora word design, and the Fedora logo are trademarks of Red -Hat, Inc. Use remains subject to the -[Fedora trademark guidelines](https://docs.fedoraproject.org/en-US/legal/trademarks/). +The NerdOS glasses are original project artwork supplied in October 2026 under +the repository MIT license. The canonical and white variants are unmodified +files from the identity kit. This code license does not grant rights in other +depicted brands. See `assets/brand/USAGE.md` for identity usage rules. diff --git a/roles/desktop/files/quickshell/assets/claude-white.svg b/desktop/quickshell/assets/claude-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/claude-white.svg rename to desktop/quickshell/assets/claude-white.svg diff --git a/roles/desktop/files/quickshell/assets/claude.svg b/desktop/quickshell/assets/claude.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/claude.svg rename to desktop/quickshell/assets/claude.svg diff --git a/roles/desktop/files/quickshell/assets/fusebox-white.svg b/desktop/quickshell/assets/fusebox-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/fusebox-white.svg rename to desktop/quickshell/assets/fusebox-white.svg diff --git a/roles/desktop/files/quickshell/assets/fusebox.svg b/desktop/quickshell/assets/fusebox.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/fusebox.svg rename to desktop/quickshell/assets/fusebox.svg diff --git a/roles/desktop/files/quickshell/assets/gemini-white.svg b/desktop/quickshell/assets/gemini-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/gemini-white.svg rename to desktop/quickshell/assets/gemini-white.svg diff --git a/roles/desktop/files/quickshell/assets/gemini.svg b/desktop/quickshell/assets/gemini.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/gemini.svg rename to desktop/quickshell/assets/gemini.svg diff --git a/roles/desktop/files/quickshell/assets/github-white.svg b/desktop/quickshell/assets/github-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/github-white.svg rename to desktop/quickshell/assets/github-white.svg diff --git a/roles/desktop/files/quickshell/assets/github.svg b/desktop/quickshell/assets/github.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/github.svg rename to desktop/quickshell/assets/github.svg diff --git a/roles/desktop/files/quickshell/assets/grok-white.svg b/desktop/quickshell/assets/grok-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/grok-white.svg rename to desktop/quickshell/assets/grok-white.svg diff --git a/roles/desktop/files/quickshell/assets/grok.svg b/desktop/quickshell/assets/grok.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/grok.svg rename to desktop/quickshell/assets/grok.svg diff --git a/roles/desktop/files/quickshell/assets/kimi-white.svg b/desktop/quickshell/assets/kimi-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/kimi-white.svg rename to desktop/quickshell/assets/kimi-white.svg diff --git a/roles/desktop/files/quickshell/assets/kimi.svg b/desktop/quickshell/assets/kimi.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/kimi.svg rename to desktop/quickshell/assets/kimi.svg diff --git a/desktop/quickshell/assets/nerdos-white.svg b/desktop/quickshell/assets/nerdos-white.svg new file mode 100644 index 00000000..950028b5 --- /dev/null +++ b/desktop/quickshell/assets/nerdos-white.svg @@ -0,0 +1 @@ +NerdOS diff --git a/desktop/quickshell/assets/nerdos.svg b/desktop/quickshell/assets/nerdos.svg new file mode 100644 index 00000000..442b2677 --- /dev/null +++ b/desktop/quickshell/assets/nerdos.svg @@ -0,0 +1 @@ +NerdOS diff --git a/roles/desktop/files/quickshell/assets/openai-white.svg b/desktop/quickshell/assets/openai-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/openai-white.svg rename to desktop/quickshell/assets/openai-white.svg diff --git a/roles/desktop/files/quickshell/assets/openai.svg b/desktop/quickshell/assets/openai.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/openai.svg rename to desktop/quickshell/assets/openai.svg diff --git a/roles/desktop/files/quickshell/assets/slack-white.svg b/desktop/quickshell/assets/slack-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/slack-white.svg rename to desktop/quickshell/assets/slack-white.svg diff --git a/roles/desktop/files/quickshell/assets/slack.svg b/desktop/quickshell/assets/slack.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/slack.svg rename to desktop/quickshell/assets/slack.svg diff --git a/roles/desktop/files/quickshell/assets/t3.svg b/desktop/quickshell/assets/t3.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/t3.svg rename to desktop/quickshell/assets/t3.svg diff --git a/roles/desktop/files/quickshell/assets/tabler/LICENSE b/desktop/quickshell/assets/tabler/LICENSE similarity index 100% rename from roles/desktop/files/quickshell/assets/tabler/LICENSE rename to desktop/quickshell/assets/tabler/LICENSE diff --git a/desktop/quickshell/assets/tabler/README.md b/desktop/quickshell/assets/tabler/README.md new file mode 100644 index 00000000..f56e4a11 --- /dev/null +++ b/desktop/quickshell/assets/tabler/README.md @@ -0,0 +1,50 @@ +# Tabler interface icons + +Pinned source: [`@tabler/icons-webfont` 3.48.0](https://www.npmjs.com/package/@tabler/icons-webfont/v/3.48.0), +[upstream repository](https://github.com/tabler/tabler-icons), MIT (see LICENSE). +Archive SHA-256: `0525656a825b56735c2e87e83d9906ad1e3c42f400918c5ae245217909cffd42`. + +`outline.ttf` is a subset of Tabler's standard 2px stroke on its 24px design +grid, with unchanged outlines and metrics. Its internal family is renamed +`NerdOS Tabler Outline` to avoid collisions with installed Tabler fonts. +`Common/TablerIcons.qml` loads it locally once; no system font installation is +required. All built-in interface icons, including selected states and playback, +use outlines. We do not bundle the filled font or synthesize bold strokes. + +Controls communicate selection through their existing colour, background, +border, checkmark, label or switch position. Pinned thread actions use accent +ink; favourite stars retain their amber tint and stronger opacity. Icon colour +fades, button press motion, spinners and panel transitions remain in place. +`Sym` draws `loader-2` (also named `progress_activity`) from its original arc +geometry, centered in the icon slot, so font baseline rounding cannot make +the spinner wobble during rotation. Its outline and stroke match Tabler. +The old `fill`, `animateFill`, `glyphFill` and `symbolFill` component properties +remain accepted as inert compatibility inputs for existing plugins. + +This policy covers interface symbols. Product artwork and application/tray +icons keep their original artwork, and functional UI shapes (switch tracks, +progress meters, radio indicators and status dots) still express their state. + +`aliases.json` maps the shell's existing semantic names to canonical Tabler +names, preserving existing built-ins and plugin callers. New code may use any +canonical name included in the generated registry. To add an icon, add a +mapping here (a canonical name can map to itself), then regenerate. Unknown +names render help-circle; empty names render nothing. Weather conditions use +the nearest upstream symbol, so partly cloudy day/night share the cloud icon. + +Regenerate from the repository root using a disposable Python environment: + +```bash +work=$(mktemp -d /tmp/nerdos-tabler.XXXXXX) +trap 'rm -rf -- "$work"' EXIT +python3 -m venv "$work/venv" +"$work/venv/bin/pip" install fonttools==4.65.0 +"$work/venv/bin/python" scripts/update-tabler-icons +node --test tests/quickshell/tabler-icons.test.cjs +``` + +The generator verifies the archive checksum, subsets the outline font, renames its +family, and writes `Common/TablerGlyphs.js`. Commit those generated files with +changes to the mapping. FontTools is a maintenance dependency only. The tests +read the bundled TTF cmap tables directly, including supplementary codepoints; +they do not depend on system fonts or skip missing font coverage. diff --git a/roles/desktop/files/quickshell/assets/tabler/aliases.json b/desktop/quickshell/assets/tabler/aliases.json similarity index 100% rename from roles/desktop/files/quickshell/assets/tabler/aliases.json rename to desktop/quickshell/assets/tabler/aliases.json diff --git a/roles/desktop/files/quickshell/assets/tabler/outline.ttf b/desktop/quickshell/assets/tabler/outline.ttf similarity index 100% rename from roles/desktop/files/quickshell/assets/tabler/outline.ttf rename to desktop/quickshell/assets/tabler/outline.ttf diff --git a/roles/desktop/files/quickshell/assets/tailscale-white.svg b/desktop/quickshell/assets/tailscale-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/tailscale-white.svg rename to desktop/quickshell/assets/tailscale-white.svg diff --git a/roles/desktop/files/quickshell/assets/tailscale.svg b/desktop/quickshell/assets/tailscale.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/tailscale.svg rename to desktop/quickshell/assets/tailscale.svg diff --git a/roles/desktop/files/quickshell/assets/whatsapp-white.svg b/desktop/quickshell/assets/whatsapp-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/whatsapp-white.svg rename to desktop/quickshell/assets/whatsapp-white.svg diff --git a/roles/desktop/files/quickshell/assets/whatsapp.svg b/desktop/quickshell/assets/whatsapp.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/whatsapp.svg rename to desktop/quickshell/assets/whatsapp.svg diff --git a/roles/desktop/files/quickshell/assets/youtube-white.svg b/desktop/quickshell/assets/youtube-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/youtube-white.svg rename to desktop/quickshell/assets/youtube-white.svg diff --git a/roles/desktop/files/quickshell/assets/youtube.svg b/desktop/quickshell/assets/youtube.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/youtube.svg rename to desktop/quickshell/assets/youtube.svg diff --git a/roles/desktop/files/quickshell/compat/omarchy/LICENSE b/desktop/quickshell/compat/omarchy/LICENSE similarity index 100% rename from roles/desktop/files/quickshell/compat/omarchy/LICENSE rename to desktop/quickshell/compat/omarchy/LICENSE diff --git a/desktop/quickshell/compat/omarchy/README.md b/desktop/quickshell/compat/omarchy/README.md new file mode 100644 index 00000000..cacdd77f --- /dev/null +++ b/desktop/quickshell/compat/omarchy/README.md @@ -0,0 +1,26 @@ +# Omarchy compatibility sources + +All 35 `Ui/*.qml` files, `Commons/{Border.qml,BorderGeometry.js,Util.qml,Style.qml}`, +and `bin/{omarchy-notification-send,omarchy-shell}` derive from omacom/omarchy, +commit 60663faf8764253646f1d6166e864b608d4a0fa1 (MIT). The accompanying LICENSE +preserves the upstream notice. `Commons/Color.qml` bridges upstream color roles +and helpers to NerdOS's palette; it does not watch Omarchy theme files. +The pinned upstream `loadColors`, `parseShell` and `applyShellValues` functions +are retained for explicit session theme updates through IPC. + +Style retains upstream tokens/functions with shared shell typography, density, +persistent plugin overrides, accessibility scaling, and panel radius values, and +omits Omarchy config watchers and Hyprland polling. UI changes qualify delegate +references and use bound component contexts for strict linting. Dynamic host +properties use `var` for standalone Qt linting. Popup anchors access the +existing grouped property instead of declaring an invalid grouped-property ID. +Controls have accessibility labels/roles; dismissal surfaces declare their +arrow cursor. BorderGeometry omits its state-free library pragma for the +repository's JavaScript syntax gate. The notification helper quotes its D-Bus +signature for ShellCheck. The IPC helper resolves the bundled NerdOS runtime +and queries that existing shell rather than `$OMARCHY_PATH/shell`. + +The local `Common/Omarchy*.qml` adapter implements all six plugin kinds with +explicit limits described in docs/omarchy-plugin-compatibility.md. It does not +implement the full Omarchy distribution. Byte-for-byte plugin fixtures in +tests/omarchy-plugins have separate provenance and notices. diff --git a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-notification-send b/desktop/quickshell/compat/omarchy/bin/omarchy-notification-send similarity index 99% rename from roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-notification-send rename to desktop/quickshell/compat/omarchy/bin/omarchy-notification-send index 845e3145..63e4d6b1 100755 --- a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-notification-send +++ b/desktop/quickshell/compat/omarchy/bin/omarchy-notification-send @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # omarchy:summary=Send an Omarchy desktop notification # omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [args...]] diff --git a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-shell b/desktop/quickshell/compat/omarchy/bin/omarchy-shell similarity index 89% rename from roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-shell rename to desktop/quickshell/compat/omarchy/bin/omarchy-shell index 1d733888..f075e3ae 100755 --- a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-shell +++ b/desktop/quickshell/compat/omarchy/bin/omarchy-shell @@ -1,6 +1,6 @@ -#!/bin/bash +#!/usr/bin/env bash -# omarchy:summary=Send an IPC call to the running Cybex shell +# omarchy:summary=Send an IPC call to the running NerdOS shell # omarchy:args=[-q] [args...] # omarchy:examples=omarchy shell shell ping | omarchy-shell shell toggle omarchy.menu '{"menu":"root"}' @@ -20,7 +20,7 @@ if (( $# == 0 )) || [[ $1 == "-h" || $1 == "--help" ]]; then cat < [args...] -Forwards an IPC call to the running Cybex shell. The shell is expected +Forwards an IPC call to the running NerdOS shell. The shell is expected to already be running; this command does not start it. Options: @@ -39,8 +39,8 @@ fi (( $# >= 2 )) || fail "Usage: omarchy-shell [args...]" # Resolve this bundled runtime, including development-mode source selection. # IPC only: never start a second shell when the service is unavailable. -shell_path=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd -P) || fail "Cybex runtime not found" -[[ -f $shell_path/shell.qml ]] || fail "Cybex shell config not found" +shell_path=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd -P) || fail "NerdOS runtime not found" +[[ -f $shell_path/shell.qml ]] || fail "NerdOS shell config not found" if [[ $1 == "shell" && ( $2 == "summon" || $2 == "toggle" ) ]] && (( $# == 3 )); then set -- "$1" "$2" "$3" "{}" diff --git a/roles/desktop/files/quickshell/launcher-actions.json b/desktop/quickshell/launcher-actions.json similarity index 100% rename from roles/desktop/files/quickshell/launcher-actions.json rename to desktop/quickshell/launcher-actions.json diff --git a/desktop/quickshell/qmldir b/desktop/quickshell/qmldir new file mode 100644 index 00000000..0a8858fa --- /dev/null +++ b/desktop/quickshell/qmldir @@ -0,0 +1,21 @@ +# Sibling-type resolution at the shell root relies on this explicit index; +# a directory carrying a qmldir is no longer implicitly scanned, so every +# root-level type must be listed. qmldir.test.cjs enforces that. +# +# shell.qml is deliberately absent: it is the entry point Quickshell loads +# by path, and a lowercase basename cannot be a QML type name. +LauncherView LauncherView.qml +LauncherWindow LauncherWindow.qml +NotificationToasts NotificationToasts.qml +NetworkOverlayWindow NetworkOverlayWindow.qml +OsdWindow OsdWindow.qml +ShortcutsOverlay ShortcutsOverlay.qml +SettingsWindow SettingsWindow.qml +PolkitPrompt PolkitPrompt.qml +PolkitWindow PolkitWindow.qml +OverviewWindow OverviewWindow.qml +OverviewView OverviewView.qml +DockWindow DockWindow.qml +WebcamOverlay WebcamOverlay.qml +SoftwareWindow SoftwareWindow.qml +AssistantWindow AssistantWindow.qml diff --git a/desktop/quickshell/safe-mode/shell.qml b/desktop/quickshell/safe-mode/shell.qml new file mode 100644 index 00000000..e6cdd776 --- /dev/null +++ b/desktop/quickshell/safe-mode/shell.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Wayland +import "../Common" + +// Quickshell private PostReloadHook is absent from installed type metadata. +// qmllint disable import + +// Deliberately standalone: no settings, theme, plugins or connected services +// are constructed. A broken personal configuration stays untouched on disk. +ShellRoot { + id: root + // Recovery tokens cannot depend on a possibly broken Theme/Settings tree. + readonly property var typography: ({ control: 13, body: 14, caption: 12 }) + + function terminal() { Quickshell.execDetached(["nerdos-apps", "terminal"]); } + function recover() { Quickshell.execDetached(["nerdos-runtime", "shell", "recover"]); } + + GlobalShortcut { + appid: "quickshell" + name: "launcherToggle" + description: I18n.tr("Open a recovery terminal") + onPressed: root.terminal() + } + IpcHandler { + target: "recovery" + function status(): string { return "safe"; } + function terminal(): void { root.terminal(); } + function retry(): void { root.recover(); } + } + // Existing keybindings remain usable in the recovery session. + IpcHandler { + target: "launcher" + function toggle(): void { root.terminal(); } + } + SystemClock { id: clock; precision: SystemClock.Minutes } + + component Action: Rectangle { + id: action + required property string label + signal triggered() + implicitWidth: caption.implicitWidth + 24 + implicitHeight: 30 + radius: 5 + color: activeFocus || pointer.containsMouse ? "#4c4b40" : "#35342f" + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: label + Accessible.onPressAction: triggered() + Keys.onReturnPressed: triggered() + Keys.onSpacePressed: triggered() + Text { id: caption; anchors.centerIn: parent; text: action.label; color: "#ffffff"; font.pixelSize: root.typography.control } + MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: action.triggered() } + } + + Variants { + model: Quickshell.screens + PanelWindow { + id: panel + required property var modelData + screen: modelData + anchors { top: true; left: true; right: true } + implicitHeight: 46 + color: "#23221e" + WlrLayershell.namespace: "qs-recovery" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + Row { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + Text { text: "NerdOS recovery"; color: "#e1df9a"; font.pixelSize: root.typography.body; height: 30; verticalAlignment: Text.AlignVCenter } + Action { label: "Terminal"; onTriggered: root.terminal() } + Action { label: "Retry desktop"; onTriggered: root.recover() } + Text { + visible: panel.width > 900 + text: I18n.tr("Widgets paused · Your settings are preserved · nerd shell status") + color: "#c9c7bd"; font.pixelSize: root.typography.caption; height: 30; verticalAlignment: Text.AlignVCenter + } + } + Text { + anchors.right: parent.right; anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(clock.date, "HH:mm") + color: "#ffffff"; font.pixelSize: root.typography.body + } + } + } +} diff --git a/roles/desktop/files/quickshell/scripts/audio-route b/desktop/quickshell/scripts/audio-route similarity index 100% rename from roles/desktop/files/quickshell/scripts/audio-route rename to desktop/quickshell/scripts/audio-route diff --git a/roles/desktop/files/quickshell/scripts/audio-source-route b/desktop/quickshell/scripts/audio-source-route similarity index 100% rename from roles/desktop/files/quickshell/scripts/audio-source-route rename to desktop/quickshell/scripts/audio-source-route diff --git a/roles/desktop/files/quickshell/scripts/battery-health b/desktop/quickshell/scripts/battery-health similarity index 100% rename from roles/desktop/files/quickshell/scripts/battery-health rename to desktop/quickshell/scripts/battery-health diff --git a/desktop/quickshell/scripts/bluetooth-tool.py b/desktop/quickshell/scripts/bluetooth-tool.py new file mode 100644 index 00000000..4b37bc0d --- /dev/null +++ b/desktop/quickshell/scripts/bluetooth-tool.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Drawer-scoped BlueZ actions and KeyboardDisplay agent; JSON lines over stdio. + +Uses the application agent contract (never replaces the desktop default agent): +https://bluez.readthedocs.io/en/latest/agent-api/ +The private bus connection owns discovery, so exit also releases its scan session. +""" +import json +import os +import re +import signal +import sys + +# The drawer reads JSON lines only; a traceback on stderr would leave it +# waiting for `ready` with nothing on screen. Report a missing binding as the +# same error event every other failure uses. +try: + from gi.repository import Gio, GLib +except Exception as exc: # ImportError, or a broken/mismatched typelib. + print(json.dumps({'error': f'Bluetooth support is unavailable: {exc}'}), flush=True) + sys.exit(1) + +AGENT_PATH = '/org/nerd/BluetoothAgent' +DEVICE_IFACE = 'org.bluez.Device1' +ADAPTER_IFACE = 'org.bluez.Adapter1' +AGENT_XML = ''' + + + + + + + + + +''' + + +def reply_value(kind, value): + if kind == 'RequestPinCode': + if not isinstance(value, str) or not 1 <= len(value) <= 16: + raise ValueError('Enter a PIN of 1–16 characters.') + return GLib.Variant('(s)', (value,)) + if kind == 'RequestPasskey': + if not isinstance(value, str) or not re.fullmatch(r'[0-9]{1,6}', value): + raise ValueError('Enter a numeric passkey of up to six digits.') + return GLib.Variant('(u)', (int(value),)) + return GLib.Variant('()', ()) + + +class Session: + def __init__(self, adapter): + if not re.fullmatch(r'/org/bluez/hci[0-9]+', adapter): + raise ValueError('Invalid Bluetooth adapter.') + self.adapter = adapter + self.loop = GLib.MainLoop() + self.pending = None + self.prompt_kind = '' + self.device = '' + self.operation = '' + self.canceled = False + self.scan = False + self.scan_busy = False + self.scan_timer = 0 + self.buffer = b'' + self.closed = False + self.bus = Gio.DBusConnection.new_for_address_sync( + Gio.dbus_address_get_for_bus_sync(Gio.BusType.SYSTEM, None), + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT + | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None) + self.owner = self.bus.call_sync( + 'org.freedesktop.DBus', '/org/freedesktop/DBus', + 'org.freedesktop.DBus', 'GetNameOwner', + GLib.Variant('(s)', ('org.bluez',)), None, + Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] + self.bus.register_object(AGENT_PATH, + Gio.DBusNodeInfo.new_for_xml(AGENT_XML).interfaces[0], + self.agent, None, None) + self.bus.call_sync('org.bluez', '/org/bluez', 'org.bluez.AgentManager1', + 'RegisterAgent', GLib.Variant('(os)', (AGENT_PATH, 'KeyboardDisplay')), + None, Gio.DBusCallFlags.NONE, 5000, None) + + def emit(self, **event): + if not self.closed: + try: + print(json.dumps(event), flush=True) + except BrokenPipeError: + self.close() + + def call(self, path, interface, method, params=None, done=None, timeout=30000): + def finished(bus, result): + try: + bus.call_finish(result) + error = '' + except GLib.Error as exc: + error = exc.message + if not self.closed and done: + done(error) + self.bus.call('org.bluez', path, interface, method, params, None, + Gio.DBusCallFlags.NONE, timeout, None, finished) + + def clear_prompt(self): + if self.pending: + self.pending.return_dbus_error('org.bluez.Error.Canceled', 'Pairing canceled') + self.pending = None + self.prompt_kind = '' + self.emit(prompt=None) + + def agent(self, connection, sender, path, interface, method, parameters, invocation): + args = parameters.unpack() + if sender != self.owner: + invocation.return_dbus_error('org.bluez.Error.Rejected', 'Unknown caller') + return + if method in ('Release', 'Cancel'): + self.clear_prompt() + invocation.return_value(None) + if method == 'Release': + self.close() + return + if not self.device or args[0] != self.device: + invocation.return_dbus_error('org.bluez.Error.Rejected', 'No matching user action') + return + display = method in ('DisplayPinCode', 'DisplayPasskey') + if not display and self.pending: + invocation.return_dbus_error('org.bluez.Error.Rejected', 'Another prompt is pending') + return + code = str(args[1]) if method == 'DisplayPinCode' else ( + f'{args[1]:06d}' if method in ('DisplayPasskey', 'RequestConfirmation') else '') + if display: + invocation.return_value(None) + else: + self.pending = invocation + self.prompt_kind = method + self.emit(prompt={'kind': method, 'code': code, + 'entered': args[2] if method == 'DisplayPasskey' else 0, + 'service': args[1] if method == 'AuthorizeService' else ''}) + + def finish(self, error=''): + self.clear_prompt() + self.device = '' + self.operation = '' + self.emit(busy='', error=error) + + def cancel(self): + self.canceled = True + self.clear_prompt() + if self.operation == 'pair' and self.device: + self.call(self.device, DEVICE_IFACE, 'CancelPairing') + elif self.operation == 'connect' and self.device: + self.call(self.device, DEVICE_IFACE, 'Disconnect') + # Keep the operation locked until its original D-Bus reply arrives. + + def discovery(self, enabled): + if self.scan_busy or enabled == self.scan: + return + self.scan_busy = True + self.emit(scanBusy=True) + def complete(error): + self.scan_busy = False + if not error: + self.scan = enabled + if self.scan_timer: + GLib.source_remove(self.scan_timer) + self.scan_timer = 0 + if enabled: + self.scan_timer = GLib.timeout_add_seconds(60, self.scan_expired) + self.emit(scanning=self.scan, scanBusy=False, error=error) + self.call(self.adapter, ADAPTER_IFACE, + 'StartDiscovery' if enabled else 'StopDiscovery', done=complete) + + def scan_expired(self): + self.scan_timer = 0 + self.discovery(False) + return False + + def command(self, message): + action = message.get('action') + if action == 'scan': + self.discovery(message.get('enabled') is True) + elif action == 'cancel': + self.cancel() + elif action == 'reply': + if not self.pending: + return + if message.get('accept') is not True: + self.cancel() + return + value = reply_value(self.prompt_kind, message.get('value', '')) + self.pending.return_value(value) + self.pending = None + self.prompt_kind = '' + self.emit(prompt=None, error='') + elif action in ('pair', 'connect', 'disconnect'): + if self.device: + raise ValueError('Wait for the current Bluetooth action to finish.') + device = message.get('device', '') + if not re.fullmatch(re.escape(self.adapter) + r'/dev_(?:[0-9A-F]{2}_){5}[0-9A-F]{2}', device): + raise ValueError('Invalid Bluetooth device.') + self.canceled = False + self.device, self.operation = device, action + self.emit(busy=device, error='') + if action == 'pair': + self.call(device, DEVICE_IFACE, 'Pair', done=self.paired, timeout=120000) + else: + self.call(device, DEVICE_IFACE, action.title(), done=self.finish) + else: + raise ValueError('Unknown Bluetooth action.') + + def paired(self, error): + if self.canceled: + self.finish('Pairing canceled') + return + if error: + # A local D-Bus timeout does not cancel the remote Pair operation. + self.call(self.device, DEVICE_IFACE, 'CancelPairing') + self.finish(error) + return + self.clear_prompt() + device = self.device + def trusted(error): + if self.canceled: + self.finish('Pairing canceled') + elif error: + self.finish(error) + else: + self.operation = 'connect' + self.call(device, DEVICE_IFACE, 'Connect', done=self.finish) + self.call(device, 'org.freedesktop.DBus.Properties', 'Set', + GLib.Variant('(ssv)', (DEVICE_IFACE, 'Trusted', GLib.Variant('b', True))), + done=trusted) + + def read(self, fd, condition): + data = os.read(fd, 65536) + if not data: + self.close() + return False + self.buffer += data + if len(self.buffer) > 65536: + self.close() + return False + while b'\n' in self.buffer: + line, self.buffer = self.buffer.split(b'\n', 1) + try: + message = json.loads(line) + if not isinstance(message, dict): + raise ValueError('Invalid Bluetooth request.') + self.command(message) + except (ValueError, TypeError, GLib.Error) as exc: + self.emit(error=str(exc)) + except Exception as exc: + # An exception escaping a GLib watch callback removes the + # watch, and the helper would then ignore stdin forever. + self.emit(error=f'Bluetooth request failed: {exc}') + return True + + def close(self): + if self.closed: + return False + self.closed = True + self.clear_prompt() + if self.device and self.operation == 'pair': + try: + self.bus.call_sync('org.bluez', self.device, DEVICE_IFACE, 'CancelPairing', + None, None, Gio.DBusCallFlags.NONE, 1000, None) + except GLib.Error: + pass + # Closing this private connection unregisters the agent and releases + # only our discovery session, including an in-flight StartDiscovery. + self.bus.close_sync(None) + self.loop.quit() + return False + + def run(self): + GLib.io_add_watch(sys.stdin.fileno(), GLib.IO_IN | GLib.IO_HUP, self.read) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, self.close) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self.close) + self.emit(ready=True) + try: + self.loop.run() + finally: + self.close() + + +def main(): + try: + Session(sys.argv[1]).run() + except (GLib.Error, ValueError, IndexError) as exc: + print(json.dumps({'error': str(exc)}), flush=True) + return 1 + except Exception as exc: + print(json.dumps({'error': f'Bluetooth controls failed: {exc}'}), flush=True) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/roles/desktop/files/quickshell/scripts/calendar-events.py b/desktop/quickshell/scripts/calendar-events.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/calendar-events.py rename to desktop/quickshell/scripts/calendar-events.py diff --git a/desktop/quickshell/scripts/clipboard-tool.py b/desktop/quickshell/scripts/clipboard-tool.py new file mode 100644 index 00000000..7da0f742 --- /dev/null +++ b/desktop/quickshell/scripts/clipboard-tool.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""Pinned clipboard entries and image previews for the launcher's History tab. + + clipboard-tool.py previews [LINE...] thumbnails for cliphist image lines + clipboard-tool.py pin [--notify] LINE keep a cliphist entry as a pin + clipboard-tool.py unpin [--notify] ID drop a pin + clipboard-tool.py copy ID put a pin back on the clipboard + clipboard-tool.py pins print the pin index + +`previews` prints one JSON object mapping each line it could decode to a file +URL, and deletes every cached preview not named this time: the cache only +ever holds what the current history shows. It lives in $XDG_RUNTIME_DIR, so +a logout clears it as well. + +Pins are copies of the entry's bytes under ~/.local/share/nerdos/clipboard, +because cliphist prunes its database to the newest 500 entries and `cliphist +wipe` empties it. The store is bounded in count and size and refuses a new +pin rather than evicting one the user chose to keep. The launcher reads +pins.json directly; this helper is the only writer. +""" + +from __future__ import annotations + +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import select +import shutil +import subprocess +import sys +import tempfile +import time + +MAX_PINS = 50 +MAX_TEXT_BYTES = 256 * 1024 +MAX_IMAGE_BYTES = 10 * 1024 * 1024 +MAX_PIN_STORE_BYTES = 64 * 1024 * 1024 +PREVIEW_LIMIT = 48 +MAX_DECODE_BYTES = 32 * 1024 * 1024 +MAX_CACHE_FILES = 64 +MAX_CACHE_BYTES = 48 * 1024 * 1024 +THUMBNAIL = "512x512>" +DECODE_TIMEOUT = 10 +MAGICK_TIMEOUT = 30 +RAW_PREVIEW_LIMIT = 4 * 1024 * 1024 + +# Formats Qt and ImageMagick both read, recognized by their own bytes rather +# than by anything the clipboard owner claimed. The coder prefix pins +# ImageMagick to that decoder instead of letting it guess from the content. +FORMATS = ( + (b"\x89PNG\r\n\x1a\n", "png", "image/png"), + (b"\xff\xd8\xff", "jpg", "image/jpeg"), + (b"GIF87a", "gif", "image/gif"), + (b"GIF89a", "gif", "image/gif"), + (b"BM", "bmp", "image/bmp"), +) +CODERS = {"png": "png", "jpg": "jpeg", "gif": "gif", "bmp": "bmp", "webp": "webp"} +MIME = {"png": "image/png", "jpg": "image/jpeg", "gif": "image/gif", "bmp": "image/bmp", + "webp": "image/webp", "txt": "text/plain;charset=utf-8"} + + +class ToolError(Exception): + """A refusal the user should read, such as a full pin store.""" + + +def image_format(data: bytes) -> str: + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + for magic, extension, _mime in FORMATS: + if data.startswith(magic): + return extension + return "" + + +def display_of(line: str) -> str: + _identifier, _tab, display = line.partition("\t") + return " ".join((display if _tab else line).split()) + + +def is_image_line(line: str) -> bool: + return "binary data" in display_of(line).lower() + + +def data_home() -> Path: + base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share") + return Path(base) + + +def store_root() -> Path: + return data_home() / "nerdos/clipboard" + + +def preview_root() -> Path: + runtime = os.environ.get("XDG_RUNTIME_DIR") + if runtime and Path(runtime).is_dir(): + return Path(runtime) / "nerdos/clipboard-previews" + cache = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(cache) / "nerdos/clipboard-previews" + + +def private_directory(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True, mode=0o700) + if path.is_symlink() or not path.is_dir(): + raise ToolError(f"{path} is not a directory") + return path + + +def read_limited(argv: list[str], payload: bytes, limit: int) -> bytes: + """Run argv with payload on stdin and return at most `limit` bytes of output.""" + with tempfile.TemporaryFile() as source: + source.write(payload) + source.seek(0) + process = subprocess.Popen(argv, stdin=source, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + deadline = time.monotonic() + DECODE_TIMEOUT + chunks, size = [], 0 + try: + while True: + remaining = deadline - time.monotonic() + ready = select.select([process.stdout], [], [], max(0, remaining))[0] if remaining > 0 else [] + if not ready: + raise ToolError("Decoding the clipboard entry took too long") + chunk = os.read(process.stdout.fileno(), 65536) + if not chunk: + break + size += len(chunk) + if size > limit: + raise ToolError("The clipboard entry is too large") + chunks.append(chunk) + if process.wait(timeout=max(0.1, deadline - time.monotonic())) != 0: + raise ToolError("The clipboard entry could not be decoded") + except subprocess.TimeoutExpired as error: + raise ToolError("Decoding the clipboard entry took too long") from error + finally: + if process.poll() is None: + process.kill() + process.wait() + process.stdout.close() + return b"".join(chunks) + + +def decode(line: str, limit: int) -> bytes: + if shutil.which("cliphist") is None: + raise ToolError("cliphist is not installed") + return read_limited(["cliphist", "decode"], line.encode("utf-8", "surrogateescape"), limit) + + +def write_atomic(path: Path, data: bytes, mode: int = 0o600) -> None: + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as output: + output.write(data) + output.flush() + os.fchmod(output.fileno(), mode) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def thumbnail(data: bytes, extension: str, output: Path) -> bool: + """Write a PNG thumbnail of an image with ImageMagick under hard limits.""" + if shutil.which("magick") is None: + return False + descriptor, temporary = tempfile.mkstemp(prefix=f".{output.name}.", suffix=".png", + dir=output.parent) + os.close(descriptor) + try: + result = subprocess.run( + ["magick", "-limit", "memory", "128MiB", "-limit", "map", "256MiB", + "-limit", "width", "16KP", "-limit", "height", "16KP", + "-limit", "area", "128MP", "-limit", "time", "20", + f"{CODERS[extension]}:-[0]", "-auto-orient", "-thumbnail", THUMBNAIL, + "-strip", f"png:{temporary}"], + input=data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=MAGICK_TIMEOUT, check=False) + if result.returncode != 0 or os.path.getsize(temporary) == 0: + return False + os.chmod(temporary, 0o600) + os.replace(temporary, output) + return True + except (OSError, subprocess.SubprocessError): + return False + finally: + Path(temporary).unlink(missing_ok=True) + + +# ---- Previews ------------------------------------------------------------- + +def preview_key(line: str) -> str: + return hashlib.sha256(line.encode("utf-8", "surrogateescape")).hexdigest()[:24] + + +def cached_preview(directory: Path, key: str) -> Path | None: + for candidate in sorted(directory.glob(key + ".*")): + if candidate.is_file() and not candidate.is_symlink(): + return candidate + return None + + +def make_preview(directory: Path, line: str) -> Path | None: + key = preview_key(line) + existing = cached_preview(directory, key) + if existing: + with contextlib.suppress(OSError): + os.utime(existing) + return existing + try: + data = decode(line, MAX_DECODE_BYTES) + except (ToolError, OSError): + return None + extension = image_format(data) + if not extension: + return None + output = directory / f"{key}.png" + if thumbnail(data, extension, output): + return output + # Without ImageMagick a small picture is still worth showing as it is. + if len(data) <= RAW_PREVIEW_LIMIT: + output = directory / f"{key}.{extension}" + write_atomic(output, data) + return output + return None + + +def prune_previews(directory: Path, keep: set[str]) -> None: + entries = [] + for entry in directory.iterdir(): + if not entry.is_file() or entry.is_symlink(): + continue + if entry.name.startswith(".") or entry.name.split(".", 1)[0] not in keep: + entry.unlink(missing_ok=True) + continue + entries.append(entry) + entries.sort(key=lambda entry: entry.stat().st_mtime, reverse=True) + total = 0 + for index, entry in enumerate(entries): + total += entry.stat().st_size + if index >= MAX_CACHE_FILES or total > MAX_CACHE_BYTES: + entry.unlink(missing_ok=True) + + +def previews(lines: list[str]) -> dict[str, str]: + directory = private_directory(preview_root()) + wanted = [line for line in lines if is_image_line(line)][:PREVIEW_LIMIT] + made = {} + for line in wanted: + path = make_preview(directory, line) + if path is not None: + made[line] = path + prune_previews(directory, {preview_key(line) for line in wanted}) + # The size bound may have dropped the oldest of this run's own previews. + return {line: path.resolve().as_uri() for line, path in made.items() if path.exists()} + + +# ---- Pins ----------------------------------------------------------------- + +@contextlib.contextmanager +def locked_store(): + root = private_directory(store_root()) + private_directory(root / "pins") + descriptor = os.open(root / ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield root + + +# The same names the launcher accepts (LauncherProviders.js parsePins). +PIN_NAME = re.compile(r"[0-9a-f]{16}(?:-thumb)?\.(?:txt|png|jpg|gif|webp|bmp)") + + +def valid_name(name: object) -> bool: + return isinstance(name, str) and PIN_NAME.fullmatch(name) is not None + + +def read_index(root: Path) -> list[dict]: + try: + value = json.loads((root / "pins.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + pins = value.get("pins") if isinstance(value, dict) else None + result = [] + for pin in pins if isinstance(pins, list) else []: + if not isinstance(pin, dict) or pin.get("kind") not in ("text", "image"): + continue + if not valid_name(pin.get("file")) or not (root / "pins" / pin["file"]).is_file(): + continue + if pin.get("thumb") and not valid_name(pin["thumb"]): + pin["thumb"] = "" + result.append(pin) + return result[:MAX_PINS] + + +def write_index(root: Path, pins: list[dict]) -> None: + payload = json.dumps({"version": 1, "pins": pins}, ensure_ascii=False, indent=1) + write_atomic(root / "pins.json", payload.encode("utf-8") + b"\n") + # Files no pin names any more (an interrupted pin, an unpin) go too. + referenced = {pin["file"] for pin in pins} | {pin.get("thumb") for pin in pins} + for entry in (root / "pins").iterdir(): + if entry.name not in referenced and (entry.is_file() or entry.is_symlink()): + entry.unlink(missing_ok=True) + + +def preview_text(data: bytes) -> str: + text = data[:4096].decode("utf-8", "replace") + return " ".join(text.split())[:200] + + +def pin(line: str) -> dict: + if "\t" not in line: + raise ToolError("This is not a clipboard history entry") + image = is_image_line(line) + data = decode(line, MAX_IMAGE_BYTES if image else MAX_TEXT_BYTES) + if not data: + raise ToolError("The clipboard entry is empty") + extension = image_format(data) if image else "txt" + if not extension: + raise ToolError("This kind of clipboard entry cannot be pinned") + identifier = hashlib.sha256(data).hexdigest()[:16] + with locked_store() as root: + pins = read_index(root) + existing = next((entry for entry in pins if entry["id"] == identifier), None) + if existing: + # Pinning the same content again only moves it to the top. + pins.remove(existing) + existing["source"] = line[:400] + pins.insert(0, existing) + write_index(root, pins) + return existing + if len(pins) >= MAX_PINS: + raise ToolError(f"Up to {MAX_PINS} entries can be pinned. Unpin one first.") + used = sum((root / "pins" / entry["file"]).stat().st_size for entry in pins) + if used + len(data) > MAX_PIN_STORE_BYTES: + raise ToolError("Pinned entries are using all of their space. Unpin one first.") + name = f"{identifier}.{extension}" + write_atomic(root / "pins" / name, data) + thumb = "" + if image and thumbnail(data, extension, root / "pins" / f"{identifier}-thumb.png"): + thumb = f"{identifier}-thumb.png" + record = { + "id": identifier, + "kind": "image" if image else "text", + "file": name, + "thumb": thumb, + "preview": "" if image else preview_text(data), + "source": line[:400], + "created": int(time.time()), + } + pins.insert(0, record) + write_index(root, pins) + return record + + +def unpin(identifier: str) -> bool: + with locked_store() as root: + pins = read_index(root) + kept = [entry for entry in pins if entry["id"] != identifier] + write_index(root, kept) + return len(kept) != len(pins) + + +def copy(identifier: str) -> None: + root = store_root() + entry = next((pin for pin in read_index(root) if pin["id"] == identifier), None) + if entry is None: + raise ToolError("That pinned entry no longer exists") + path = root / "pins" / entry["file"] + mime = MIME[path.suffix.lstrip(".")] + with path.open("rb") as source: + # wl-copy keeps serving the selection from a forked child after it + # returns; that child must not hold this process's output open. + result = subprocess.run(["wl-copy", "--type", mime], stdin=source, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=DECODE_TIMEOUT, check=False) + if result.returncode != 0: + raise ToolError("The pinned entry could not be copied") + + +def notify(message: str) -> None: + if shutil.which("notify-send"): + with contextlib.suppress(OSError, subprocess.SubprocessError): + subprocess.run(["notify-send", "--app-name=NerdOS", "Clipboard", message], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=3, check=False) + + +def main(argv: list[str]) -> int: + if not argv: + print(__doc__.strip(), file=sys.stderr) + return 2 + command, arguments = argv[0], argv[1:] + announce = "--notify" in arguments + arguments = [argument for argument in arguments if argument != "--notify"] + try: + if command == "previews": + print(json.dumps(previews(arguments))) + elif command == "pins": + print(json.dumps({"version": 1, "pins": read_index(store_root())})) + elif command == "pin" and len(arguments) == 1: + print(json.dumps(pin(arguments[0]))) + elif command == "unpin" and len(arguments) == 1: + if not unpin(arguments[0]): + raise ToolError("That pinned entry no longer exists") + elif command == "copy" and len(arguments) == 1: + copy(arguments[0]) + else: + print(__doc__.strip(), file=sys.stderr) + return 2 + except ToolError as error: + print(f"clipboard-tool: {error}", file=sys.stderr) + if announce: + notify(str(error)) + return 1 + except OSError as error: + print(f"clipboard-tool: {error}", file=sys.stderr) + if announce: + notify("The clipboard store could not be updated") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/roles/desktop/files/quickshell/scripts/display-settings.py b/desktop/quickshell/scripts/display-settings.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/display-settings.py rename to desktop/quickshell/scripts/display-settings.py index 04ee5591..fa5ae154 100644 --- a/roles/desktop/files/quickshell/scripts/display-settings.py +++ b/desktop/quickshell/scripts/display-settings.py @@ -4,10 +4,10 @@ One JSON request per run on stdin; one JSON result on stdout. A change is first applied as an unconfirmed trial: - apply writes the candidate to $XDG_RUNTIME_DIR/cybexos/displays-trial.json, + apply writes the candidate to $XDG_RUNTIME_DIR/nerdos/displays-trial.json, arms a transient systemd timer that restores the previous rules, then asks Hyprland to evaluate displays.lua against the candidate. - confirm saves the candidate as ~/.config/cybexos/displays.json and disarms + confirm saves the candidate as ~/.config/nerdos/displays.json and disarms the timer. rollback (and the timer's `expire`) discards the candidate and reloads Hyprland, which rebuilds every rule from the files on disk. @@ -35,7 +35,7 @@ CONFIRM_GRACE_SECONDS = 3 MAX_BYTES = 262144 MAX_ENTRIES = 64 -UNIT_PREFIX = 'cybexos-display-trial-' +UNIT_PREFIX = 'nerdos-display-trial-' TOKEN = re.compile(r'^[0-9a-f]{16}$') CONNECTOR = re.compile(r'^[A-Za-z0-9_.-]{1,64}$') CONTROL = re.compile(r'[\x00-\x1f\x7f]') @@ -43,14 +43,14 @@ def config_path(): base = os.environ.get('XDG_CONFIG_HOME') or str(Path.home() / '.config') - return Path(base) / 'cybexos' / 'displays.json' + return Path(base) / 'nerdos' / 'displays.json' def runtime_dir(): base = os.environ.get('XDG_RUNTIME_DIR') if not base: raise ValueError('XDG_RUNTIME_DIR is not set; display changes need a desktop session.') - path = Path(base) / 'cybexos' + path = Path(base) / 'nerdos' path.mkdir(mode=0o700, parents=True, exist_ok=True) return path diff --git a/desktop/quickshell/scripts/fusebox.py b/desktop/quickshell/scripts/fusebox.py new file mode 100644 index 00000000..7f88bfaa --- /dev/null +++ b/desktop/quickshell/scripts/fusebox.py @@ -0,0 +1,1165 @@ +#!/usr/bin/env python3 +"""Fusebox widget transport. + +`live` holds one authenticated WebSocket to Fusebox's management API and +prints newline-delimited JSON for Common/Fusebox.qml: a snapshot of the +server, its accounts and its faults on every connect, then each change, and +the connected clients and their budgets every few seconds. `action` and +`activity` are one-shot requests for the dashboard's breaker buttons and +account details; `banked`, `banked-refresh` and `banked-action` read and +spend banked subscription resets. `store-key`, `forget-key` and `key-status` +own the private management key file. + +The key is read from that file, sent only in an Authorization header and +never printed, logged, or put in argv or a URL. Only allowlisted fields reach +QML: /api/overview also carries client API keys and server paths, and +/api/config/settings the whole configuration, secrets included; everything +but the named clients' names and limits is dropped here. +""" +from __future__ import annotations + +import argparse +from datetime import datetime +import ipaddress +import json +import math +import os +from pathlib import Path +import re +import stat +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zlib + +MAX_RESPONSE = 2 * 1024 * 1024 +MAX_FRAME = 1024 * 1024 +MAX_ACCOUNTS = 512 +MAX_FAULTS = 128 +MAX_CLIENTS = 64 +MAX_KEY = 8192 +HEARTBEAT = 5 # seconds between heartbeat lines, so QML can tell a stalled helper +# Fusebox 0.7 sends its 5-second tick only when the totals change, so an idle server +# is silent. A quiet socket is pinged; one that doesn't answer is dead. +PING_AFTER = 8 # seconds without a frame before asking the socket itself +STALE = 20 # this long without a frame or a pong is a dead socket +COALESCE = 3 # at most one account refetch this often after `accounts` events +IDLE_REFRESH = 60 # quota and token state also change without an event +DERIVE_EVERY = 5 # older servers: re-derive faults so expired pauses clear +SETUP_RECHECK = 5 # waiting for a key: look for it this often +# Fusebox locks an address out after five wrong management keys, for longer with +# each one after. A rejected key is tried again only once it changes, or hourly: +# Fusebox forgets an address's wrong keys after an hour, so that never adds up. +AUTH_RETRY = 3600 +RESTART_GRACE = 90 # after Fusebox says it is restarting, failed dials read as the restart +CLIENTS_EVERY = 15 # connected clients and budget use +LIMITS_EVERY = 60 # named clients' limits, from the configuration +REQUEST_LIMIT = 40 # the newest requests a fresh view needs + +ACCOUNT_KINDS = ("oauth", "api-key", "service-account") +FAULT_KINDS = ("signin", "quota", "rate_limit", "error", "failures", "provider", "pace", "storage") +SOURCE_VIAS = ("direct", "proxy_protocol", "forwarded", "unknown") +# The dashboard's SIGNIN_ERR (ui/app.js) and push/faults.rs signin_error(). +SIGNIN_ERROR = re.compile(r"invalid_grant|refresh token|sign in again|re-?authenticat|unauthori[sz]ed" + r"|\b401\b|token (?:has )?expired|expired token|revoked", re.I) +IDENT = re.compile(r"[a-z0-9][a-z0-9_.:-]{0,95}") +WINDOW = re.compile(r"[a-z0-9]{1,16}") +CONTROL = re.compile(r"[\x00-\x1f\x7f]") + + +class Failure(Exception): + """A user-presentable failure. Never carries exception text, headers or the key.""" + + def __init__(self, state: str, message: str, status: int | None = None, retry: int | None = None): + super().__init__(message) + self.state = state + self.message = message + self.status = status + self.retry = retry + + +# ---------------------------------------------------------------- the key + + +def key_path() -> Path: + config = os.environ.get("XDG_CONFIG_HOME", "") + base = Path(config) if config.startswith("/") else Path.home() / ".config" + return base / "nerdos" / "fusebox" / "management.key" + + +def valid_key(text: str) -> bool: + return 0 < len(text) <= MAX_KEY and all(32 < ord(char) < 127 for char in text) + + +def read_key(path: Path | None = None) -> str: + path = path or key_path() + try: + # NONBLOCK keeps a misplaced FIFO from stalling the widget. + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + raise Failure("setup", "Enter the management key in Fusebox settings.") from None + except OSError: + raise Failure("setup", "The saved management key can't be read. Enter it again in Fusebox settings.") from None + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Failure("setup", "The management key file must be a private file you own. Enter the key again.") + raw = os.read(fd, MAX_KEY + 2) + finally: + os.close(fd) + try: + key = raw.decode("ascii").strip() + except UnicodeDecodeError: + key = "" + if not valid_key(key): + raise Failure("setup", "The saved management key is not valid. Enter it again in Fusebox settings.") + return key + + +def private_directory(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + try: + os.mkdir(path, 0o700) + except FileExistsError: + pass + info = os.lstat(path) + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid(): + raise Failure("setup", "The Fusebox settings folder is not a folder you own.") + if info.st_mode & 0o077: + os.chmod(path, 0o700) + + +def store_key(text: str, path: Path | None = None) -> None: + path = path or key_path() + key = text.strip() + if not valid_key(key): + raise Failure("setup", "A management key is one line of printable characters without spaces.") + private_directory(path.parent) + fd, temporary = tempfile.mkstemp(prefix=".management.", dir=path.parent) # created 0600 + try: + with os.fdopen(fd, "w", encoding="ascii") as stream: + stream.write(key + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except BaseException: + try: + os.unlink(temporary) + except OSError: + pass + raise + + +def forget_key(path: Path | None = None) -> None: + path = path or key_path() + try: + info = os.lstat(path) + except FileNotFoundError: + return + if stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + os.unlink(path) + + +# ---------------------------------------------------------------- HTTP + + +def normalize_url(address: str) -> str: + """The server's base URL. Dashboard links are accepted; credentials and queries are not.""" + text = (address or "").strip() + if not text: + raise Failure("setup", "Enter the Fusebox server URL in Fusebox settings.") + try: + if CONTROL.search(text): + raise ValueError + parts = urllib.parse.urlsplit(text) + parts.port # Validates the port. + if (parts.scheme not in ("http", "https") or not parts.hostname + or parts.username is not None or parts.password is not None or parts.query): + raise ValueError + path = parts.path.rstrip("/") + if path.endswith("/api"): + path = path[:-4] + if any(segment in (".", "..") for segment in urllib.parse.unquote(path).split("/")): + raise ValueError + return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", "")) + except ValueError: + raise Failure("setup", "The Fusebox URL must be an http(s) address without a user name, password or query.") from None + + +def websocket_url(base: str) -> str: + parts = urllib.parse.urlsplit(base) + return urllib.parse.urlunsplit(("wss" if parts.scheme == "https" else "ws", parts.netloc, + parts.path + "/api/live", "", "")) + + +class NoRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + # The key must never follow a redirect to another origin. + return None + + +def server_message(body: bytes) -> str: + try: + value = json.loads(body).get("error") + except (ValueError, AttributeError, UnicodeDecodeError, RecursionError): + return "" + return clean(value, 300) or "" + + +class Client: + def __init__(self, base: str, key: str): + self.base = base + self.key = key + self.opener = urllib.request.build_opener(NoRedirects(), urllib.request.ProxyHandler({})) + + def request(self, path: str, *, method: str = "GET", payload: dict | None = None, timeout: float = 10): + # Fusebox gzips its API answers; the request log shrinks about twentyfold. + headers = {"Authorization": "Bearer " + self.key, "Accept": "application/json", "Accept-Encoding": "gzip"} + data = None + if payload is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(payload).encode() + request = urllib.request.Request(self.base + "/api/" + path, data=data, headers=headers, method=method) + try: + with self.opener.open(request, timeout=timeout) as response: + raw = response.read(MAX_RESPONSE + 1) + encoding = response.headers.get("Content-Encoding") + except urllib.error.HTTPError as error: + body = error.read(65536) if error.fp else b"" + encoding = error.headers.get("Content-Encoding") if error.headers else None + retry = retry_after(error.headers.get("Retry-After")) if error.headers else None + error.close() + try: + body = decoded(body, encoding) + except Failure: + body = b"" + raise http_failure(error.code, server_message(body), retry) from None + except (TimeoutError, urllib.error.URLError, OSError): + raise Failure("offline", "Can't reach Fusebox. Check the URL, the network and its TLS certificate.") from None + if len(raw) > MAX_RESPONSE: + raise Failure("offline", "Fusebox sent an unexpectedly large response.") + raw = decoded(raw, encoding) + try: + return json.loads(raw) + except (ValueError, UnicodeDecodeError, RecursionError): + raise Failure("offline", "Fusebox sent a response the widget can't read.") from None + + +def decoded(raw: bytes, encoding) -> bytes: + """A gzipped body, inflated no further than the size limit.""" + encoding = (encoding or "").strip().lower() + if encoding in ("", "identity"): + return raw + if encoding != "gzip": + raise Failure("offline", "Fusebox sent a response the widget can't read.") + inflater = zlib.decompressobj(16 + zlib.MAX_WBITS) + try: + body = inflater.decompress(raw, MAX_RESPONSE + 1) + except zlib.error: + raise Failure("offline", "Fusebox sent a response the widget can't read.") from None + if len(body) > MAX_RESPONSE or inflater.unconsumed_tail: + raise Failure("offline", "Fusebox sent an unexpectedly large response.") + return body + + +def retry_after(value): + """Seconds from a Retry-After header; Fusebox sends whole seconds.""" + try: + seconds = int(str(value).strip()) + except (TypeError, ValueError): + return None + return seconds if 0 < seconds <= 86400 else None + + +def wait_text(seconds: int) -> str: + minutes = math.ceil(seconds / 60) + return "a minute" if minutes <= 1 else f"{minutes} minutes" + + +def http_failure(status: int, message: str = "", retry: int | None = None) -> Failure: + if status == 401: + return Failure("auth", "Fusebox rejected the management key.", status) + if status == 403: + return Failure("auth", "Fusebox refused remote management. Set management-key on the server.", status) + if status == 429: + # Management routes answer 429 only to an address that sent too many wrong keys. + wait = retry or 60 + return Failure("locked", "Fusebox turned this computer away after too many wrong management keys. " + f"It accepts it again in about {wait_text(wait)}.", status, wait) + if 300 <= status < 400: + return Failure("offline", "Fusebox redirected the request. Enter the server's own URL.", status) + if status == 404: + return Failure("missing", message or "Fusebox doesn't have this feature.", status) + return Failure("offline", message or f"Fusebox answered HTTP {status}.", status) + + +# ---------------------------------------------------------------- normalizing + + +def clean(value, limit: int): + if not isinstance(value, str): + return None + text = CONTROL.sub(" ", value).strip() + return text[:limit] if text else None + + +def mapping(value) -> dict: + return value if isinstance(value, dict) else {} + + +def count(value) -> int: + return value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value < 2 ** 53 else 0 + + +def signed(value, limit: int = 1000) -> int: + return value if isinstance(value, int) and not isinstance(value, bool) and -limit <= value <= limit else 0 + + +def points(value) -> float: + """A share of a quota window, in percentage points.""" + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + return 0.0 + return round(max(0.0, min(100.0, float(value))), 2) + + +def money(value) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0: + return 0.0 + return round(min(float(value), 1e9), 2) + + +def source(value): + """Where a request came from: an address and how Fusebox knows it.""" + if not isinstance(value, dict) or value.get("via") not in SOURCE_VIAS or not isinstance(value.get("ip"), str): + return None + try: + address = str(ipaddress.ip_address(value["ip"].strip())) + except ValueError: + return None + return {"ip": address, "via": value["via"], "login": clean(value.get("login"), 120)} + + +def epoch_ms(value): + """RFC 3339 to epoch milliseconds. Fusebox sends nanoseconds, which Python 3.9 can't parse.""" + if not isinstance(value, str) or len(value) > 64: + return None + text = value.strip() + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + match = re.fullmatch(r"(\d{4}-\d\d-\d\d[T ]\d\d:\d\d:\d\d)(?:\.(\d{1,9}))?([+-]\d\d:\d\d)", text) + if not match: + return None + fraction = (match.group(2) or "")[:6].ljust(6, "0") + try: + moment = datetime.fromisoformat(f"{match.group(1)}.{fraction}{match.group(3)}") + except ValueError: + return None + return int(moment.timestamp() * 1000) + + +def window(row): + if not isinstance(row, dict) or not isinstance(row.get("name"), str) or not WINDOW.fullmatch(row["name"]): + return None + used = row.get("used") + if isinstance(used, bool) or not isinstance(used, (int, float)) or not math.isfinite(used): + return None + return {"name": row["name"], "used": max(0.0, min(100.0, float(used))), + "resetsAt": epoch_ms(row.get("resets_at")), "model": clean(row.get("model"), 160)} + + +def account(row): + if (not isinstance(row, dict) or not isinstance(row.get("id"), str) or not 0 < len(row["id"]) <= 512 + or CONTROL.search(row["id"]) or not isinstance(row.get("provider"), str) + or not IDENT.fullmatch(row["provider"]) or row.get("kind") not in ACCOUNT_KINDS + or not isinstance(row.get("disabled"), bool)): + return None + kinds = mapping(row.get("cooldown_kinds")) + cooldowns = [] + for model, until in list(mapping(row.get("cooldowns")).items())[:64]: + at, name, kind = epoch_ms(until), clean(model, 160), kinds.get(model) + if at is not None and name: + # As in the dashboard, a pause of unknown kind is a rate limit. + known = isinstance(kind, str) and re.fullmatch(r"[a-z_]{1,24}", kind) + cooldowns.append({"model": name, "until": at, "kind": kind if known else "rate_limit"}) + quota = mapping(row.get("quota")) + rows = quota.get("windows") if isinstance(quota.get("windows"), list) else [] + windows = [w for w in map(window, rows[:16]) if w] + counters = mapping(row.get("counters")) + banked = mapping(row.get("banked_resets")) + inventory = mapping(banked.get("inventory")) + operation = banked.get("operation") + return { + "id": row["id"], + "provider": row["provider"], + "group": clean(row.get("group"), 64), + "label": clean(row.get("label"), 200) or row["id"][:200], + "email": clean(row.get("email"), 320), + "kind": row["kind"], + "disabled": row["disabled"], + "cooldowns": cooldowns, + "lastError": clean(row.get("last_error"), 300), + "lastUsed": epoch_ms(row.get("last_used")), + "expiresAt": epoch_ms(row.get("expires_at")), + "requests": count(counters.get("requests")), + "failures": count(counters.get("failures")), + "plan": clean(quota.get("plan"), 32), + # ChatGPT's plan alone doesn't tell Pro 100, 200 and 500 apart. + "subscriptionPlan": clean(quota.get("subscription_plan"), 48), + "quotaAt": epoch_ms(quota.get("updated_at")), + "windows": windows, + "priority": signed(row.get("priority")), + "banked": count(inventory.get("available")), + # A spend whose outcome is unresolved blocks further resets until reviewed. + "bankedReview": isinstance(operation, dict) and operation_status(operation.get("status")) in UNRESOLVED, + "bankedPlan": banked_plan(row.get("banked_reset_plan")), + "attribution": attribution(row.get("quota_attribution")), + "models": len(row["models"]) if isinstance(row.get("models"), list) else 0, + } + + +def banked_plan(value): + """When the soonest banked reset expires, and whether Fusebox redeems it by itself.""" + if not isinstance(value, dict): + return None + expires = epoch_ms(value.get("expires_at")) + if expires is None: + return None + return {"expiresAt": expires, "redeemAt": epoch_ms(value.get("expiry_redeem_at")), + "prioritized": value.get("prioritized") is True, "auto": value.get("auto_redeem") is True, + "due": value.get("due") is True} + + +def share_clients(rows, limit: int): + out = [] + for c in rows[:limit] if isinstance(rows, list) else []: + if isinstance(c, dict) and points(c.get("points")) > 0: + out.append({"id": clean(c.get("id"), 120) or "", "label": clean(c.get("label"), 100) or "", + "points": points(c.get("points"))}) + return out + + +def share(row): + if not isinstance(row, dict) or not isinstance(row.get("name"), str) or not WINDOW.fullmatch(row["name"]): + return None + return {"key": clean(row.get("key"), 200) or row["name"], "name": row["name"], "model": clean(row.get("model"), 160), + "resetsAt": epoch_ms(row.get("resets_at")), "used": points(row.get("used")), + "before": points(row.get("before")), "outside": points(row.get("outside")), + "byTokens": points(row.get("by_tokens")), "clients": share_clients(row.get("clients"), 12)} + + +def attribution(value): + """Which clients used each current subscription window: Fusebox's estimate, in points of the window.""" + if not isinstance(value, dict): + return None + current = value.get("current") if isinstance(value.get("current"), list) else [] + return {"current": [s for s in map(share, current[:8]) if s]} + + +def accounts(payload): + if not isinstance(payload, list) or len(payload) > MAX_ACCOUNTS: + raise Failure("offline", "Fusebox sent an account list the widget can't read.") + seen, out = set(), [] + for row in payload: + entry = account(row) + if entry and entry["id"] not in seen: + seen.add(entry["id"]) + out.append(entry) + return out + + +def fault(row): + if (not isinstance(row, dict) or row.get("kind") not in FAULT_KINDS or row.get("level") not in ("err", "warn") + or not clean(row.get("key"), 600) or not clean(row.get("title"), 200)): + return None + path = clean(row.get("path"), 600) or "" + return { + "key": clean(row["key"], 600), + "kind": row["kind"], + "level": row["level"], + "provider": clean(row.get("provider"), 96) or "", + "providerName": clean(row.get("provider_name"), 64) or "", + "accountId": clean(row.get("account_id"), 512), + "label": clean(row.get("label"), 200), + "title": clean(row["title"], 200), + "detail": clean(row.get("detail"), 300), + "until": epoch_ms(row.get("until")), + "path": path if path.startswith("#/") else "", + # A pace warning can hold for days and asks for attention, not action: + # Fusebox leaves it out of its badge, and so does the menubar. + "badge": row.get("badge") is not False and row["kind"] != "pace", + } + + +def faults(payload): + if not isinstance(payload, list): + raise Failure("offline", "Fusebox sent a fault list the widget can't read.") + out = [f for f in map(fault, payload[:MAX_FAULTS]) if f] + return sorted(out, key=lambda f: f["level"] != "err") + + +PROVIDER_NAMES = {"claude": "Claude", "codex": "Codex", "gemini": "Gemini", "vertex": "Vertex AI", + "antigravity": "Antigravity", "kimi": "Kimi", "xai": "Grok", "meta": "Meta", + "devin": "Devin", "openai-compat": "Compatible"} + + +def provider_name(entry) -> str: + if entry["provider"] == "openai-compat": + return entry["group"] or "Compatible" + if entry["kind"] == "api-key" and entry["provider"] in ("xai", "codex"): + return "xAI" if entry["provider"] == "xai" else "OpenAI" + return PROVIDER_NAMES.get(entry["provider"], entry["provider"]) + + +def window_title(name: str) -> str: + if name == "5h": + return "5-hour" + if name == "day": + return "Daily" + return "Short" if re.fullmatch(r"\d+h", name) else "Weekly" + + +def spent_window(windows, now_ms): + live = [w for w in windows if not w["model"] and (w["resetsAt"] is None or w["resetsAt"] > now_ms)] + for short in (True, False): + group = [w for w in live if bool(re.fullmatch(r"\d+h", w["name"])) == short] + if group: + top = max(group, key=lambda w: w["used"]) + if top["used"] >= 100: + return top + return None + + +def derived_faults(entries, now_ms): + """Account faults for Fusebox releases without /api/faults: the same rules as + push/faults.rs, without the request-failure and whole-provider faults, which + need the server's request history.""" + out = [] + for a in entries: + if a["disabled"]: + continue + + def item(kind, level, title, detail=None, until=None): + out.append({"key": f"{'rate' if kind == 'rate_limit' else kind}:{a['id']}", "kind": kind, + "level": level, "provider": a["provider"], "providerName": provider_name(a), + "accountId": a["id"], "label": a["label"], "title": title, "detail": detail, + "until": until, "path": "#/accounts/" + urllib.parse.quote(a["id"], safe=""), + "badge": True}) + + pauses = [c for c in a["cooldowns"] if c["until"] > now_ms] + latest = max(pauses, key=lambda c: c["until"]) if pauses else None + if latest and latest["kind"] == "quota": + spent = spent_window(a["windows"], now_ms) + item("quota", "warn", (window_title(spent["name"]) if spent else "Usage") + " limit used up", + until=(spent and spent["resetsAt"]) or latest["until"]) + elif latest and latest["kind"] == "rate_limit": + what = "Every model" if latest["model"] == "*" else latest["model"] + item("rate_limit", "warn", "Rate limited", f"{what} is paused.", latest["until"]) + if latest is None and a["lastError"]: + if a["kind"] != "api-key" and SIGNIN_ERROR.search(a["lastError"]): + item("signin", "err", "Sign-in expired", "Sign in again to put it back in rotation.") + else: + item("error", "err", "Account error", a["lastError"][:160]) + return sorted(out, key=lambda f: f["level"] != "err") + + +TOTALS = ("requests", "ok", "failed", "cancelled", "input_tokens", "output_tokens", "cache_tokens") +BUCKET = ("requests", "failed", "cancelled", "input_tokens", "output_tokens", "cache_tokens") + + +def totals(value): + value = value if isinstance(value, dict) else {} + return {key: count(value.get(key)) for key in TOTALS} + + +def overview(payload): + if not isinstance(payload, dict): + raise Failure("offline", "Fusebox sent a summary the widget can't read.") + series = [] + for bucket in (payload.get("series") or [])[-120:] if isinstance(payload.get("series"), list) else []: + if isinstance(bucket, dict) and isinstance(bucket.get("minute"), int) and not isinstance(bucket["minute"], bool): + series.append({"minute": bucket["minute"], **{key: count(bucket.get(key)) for key in BUCKET}}) + summary = payload.get("accounts") if isinstance(payload.get("accounts"), dict) else {} + # Deliberately not copied: client_keys, base_url, config_path, auth_dir. + return { + "version": clean(payload.get("version"), 64), + "startedAt": epoch_ms(payload.get("started_at")), + "routing": clean(payload.get("routing"), 32), + "sessionAffinity": payload.get("session_affinity") is True, + "bankedResets": payload.get("banked_resets") is True, + "totals": totals(payload.get("totals")), + "active": count(payload.get("active")), + "series": series, + "models": count(payload.get("models")), + "accounts": {key: count(summary.get(key)) for key in ("total", "active", "cooling", "disabled")}, + } + + +def request(row): + if not isinstance(row, dict) or not isinstance(row.get("id"), int) or isinstance(row["id"], bool): + return None + at = epoch_ms(row.get("ts")) + status = row.get("status") + if at is None or not isinstance(status, int) or isinstance(status, bool) or not 0 <= status < 1000: + return None + completeness = row.get("usage_completeness") + tokens = [count(row.get(key)) for key in ("input_tokens", "output_tokens", "cache_tokens")] + if completeness not in ("complete", "partial", "missing"): + completeness = "partial" if any(tokens) else "missing" + ttft = row.get("ttft_ms") + return { + "id": count(row["id"]), "at": at, "status": status, + "client": clean(row.get("client"), 32) or "", "clientApp": clean(row.get("client_app"), 48), + "provider": clean(row.get("provider"), 64) or "", "model": clean(row.get("model"), 160) or "", + "account": clean(row.get("account"), 200) or "", "accountId": clean(row.get("account_id"), 512) or "", + "latency": count(row.get("latency_ms")), "ttft": count(ttft) if ttft is not None else None, + "input": tokens[0], "output": tokens[1], "cached": tokens[2], "usage": completeness, + "stream": row.get("stream") is True, "error": clean(row.get("error"), 200), + "transport": row.get("transport") if row.get("transport") in ("http", "ws", "images", "video") else "http", + "attempts": max(1, min(count(row.get("attempts")), 99)), + # The named client key it came with, and where from. + "keyLabel": clean(row.get("key_label"), 100), "source": source(row.get("source")), + } + + +def recent(payload, limit=REQUEST_LIMIT): + """The newest finished requests, newest first, as Fusebox lists them.""" + rows = payload if isinstance(payload, list) else [] + return [entry for entry in map(request, rows[:limit * 2]) if entry][:limit] + + +def load(payload): + """Requests in flight and sessions per account. Sessions are the ones seen in the + last five minutes, the count Fusebox's dashboard shows. Its half-hour + `ongoing_sessions` count is left alone: it can't tell a session waiting on its + user from one that has ended, so a closed session would linger for 30 minutes.""" + out = {} + if isinstance(payload, dict): + for identity, value in list(payload.items())[:4096]: + if isinstance(identity, str) and 0 < len(identity) <= 512 and isinstance(value, dict): + out[identity] = {"inFlight": count(value.get("in_flight")), "sessions": count(value.get("sessions"))} + return out + + +def activity(payload): + rows = payload.get("sessions") if isinstance(payload, dict) else None + sessions = [] + for row in (rows or [])[:50] if isinstance(rows, list) else []: + if isinstance(row, dict): + quota = {} + for key, value in list(mapping(row.get("quota")).items())[:8]: + if clean(key, 200) and points(value) > 0: + quota[clean(key, 200)] = points(value) + sessions.append({"lastSeen": epoch_ms(row.get("last_seen")), "since": epoch_ms(row.get("since")), + "active": row.get("active") is True, "client": clean(row.get("client_app"), 48) + or clean(row.get("client"), 32), "model": clean(row.get("model"), 160), + "requests": count(row.get("requests")), "quota": quota, + "keyLabel": clean(row.get("key_label"), 100), "source": source(row.get("source"))}) + # The account's own minutes of the last hour, for its load chart. + buckets = payload.get("series") if isinstance(payload, dict) else None + series = [{"minute": b["minute"], "requests": count(b.get("requests")), "failed": count(b.get("failed"))} + for b in (buckets or [])[-120:] if isinstance(buckets, list) + if isinstance(b, dict) and isinstance(b.get("minute"), int) and not isinstance(b["minute"], bool)] + return {"sessions": sessions, "series": series} + + +def positive(value, kind=int): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: + return None + return kind(min(value, 2 ** 53)) + + +def named_limits(payload): + """Each named client's name and limits, from /api/config/settings. That answer is + the whole configuration, provider and client keys included: only these fields + are read, and nothing else leaves this function.""" + values = mapping(mapping(payload).get("values")) + rows = values.get("named-clients") if isinstance(values.get("named-clients"), list) else [] + out = {} + for row in rows[:MAX_CLIENTS]: + if not isinstance(row, dict) or not clean(row.get("id"), 120): + continue + expires = row.get("expires") + out[clean(row["id"], 120)] = { + "label": clean(row.get("label"), 100), + "requestsPerMinute": positive(row.get("requests-per-minute")), + "tokensPerDay": positive(row.get("tokens-per-day")), + "usdPerMonth": positive(row.get("usd-per-month"), float), + "expires": expires if isinstance(expires, str) and re.fullmatch(r"\d{4}-\d\d-\d\d", expires) else None, + } + return out + + +def clients_view(usage, limits): + """Which keys and addresses are using Fusebox now, and what each named client + used today and this month beside its limits.""" + usage = mapping(usage) + limits = limits or {} + spend = mapping(usage.get("clients")) + last_used = mapping(usage.get("last_used")) + named = [] + for identity in list(dict.fromkeys(list(limits) + list(spend)))[:MAX_CLIENTS]: + name = clean(identity, 120) + if not name: + continue + used, limit = mapping(spend.get(identity)), limits.get(identity, {}) + named.append({"id": name, "label": limit.get("label") or name, + "tokensToday": count(used.get("tokens_today")), "usdMonth": money(used.get("usd_month")), + "tokensPerDay": limit.get("tokensPerDay"), "usdPerMonth": limit.get("usdPerMonth"), + "requestsPerMinute": limit.get("requestsPerMinute"), "expires": limit.get("expires"), + "lastUsed": epoch_ms(last_used.get(identity))}) + connected = [] + rows = usage.get("connected") if isinstance(usage.get("connected"), list) else [] + for row in rows[:MAX_CLIENTS]: + where = source(row) + if not isinstance(row, dict) or not where: + continue + key = clean(row.get("key"), 120) or "" + connected.append({ + # Only ids people gave their clients, or a program's name; shared keys go by label. + "id": key if key in limits or key in spend or key.startswith("app:") else "", + "label": clean(row.get("label"), 100) or "Other requests", **where, + "inFlight": count(row.get("in_flight")), "sockets": count(row.get("sockets")), + "last": epoch_ms(row.get("last"))}) + return {"named": named, "connected": connected, "estimates": usage.get("estimates") is True} + + +class Clients: + """Polls /api/clients/usage, and the named clients' limits less often. Releases + without it answer 401 through client-key authentication, so a refusal right after + /api/accounts accepted the key means the server doesn't have it.""" + + def __init__(self, client: Client, out: Output, clock): + self.client, self.out, self.clock = client, out, clock + self.supported = True + self.at = self.limits_at = -math.inf + self.limits = None + self.sent = None + + def poll(self): + if not self.supported or self.clock() - self.at < CLIENTS_EVERY: + return + self.at = self.clock() + try: + usage = self.client.request("clients/usage") + except Failure as failure: + if failure.state == "locked": + raise + if failure.status in (401, 403, 404, 405): + self.supported = False + self.out.emit("clients", data=None) + # Otherwise try again next round: clients never take the live view down. + return + if mapping(usage).get("clients") and self.clock() - self.limits_at >= LIMITS_EVERY: + self.limits_at = self.clock() + try: + self.limits = named_limits(self.client.request("config/settings")) + except Failure as failure: + if failure.state == "locked": + raise + # Use without limits is still worth showing. + view = clients_view(usage, self.limits) + if view != self.sent: + self.sent = view + self.out.emit("clients", data=view) + + +# ---------------------------------------------------------------- live stream + + +class Output: + def __init__(self, stream=None, clock=time.monotonic): + self.stream = stream or sys.stdout + self.clock = clock + self.last = -math.inf + + def emit(self, kind: str, **fields): + self.stream.write(json.dumps({"type": kind, **fields}, separators=(",", ":"), allow_nan=False) + "\n") + self.stream.flush() + self.last = self.clock() + + def heartbeat(self): + if self.clock() - self.last >= HEARTBEAT: + self.emit("heartbeat") + + +def snapshot(client: Client, out: Output, now_ms: int): + """Everything a fresh view needs. Returns the accounts, and the faults derived + from them when the server has no /api/faults (None when it has).""" + try: + summary = overview(client.request("overview")) + except Failure as failure: + if failure.status in (404, 405): + raise Failure("offline", "No Fusebox management API answers at this URL.", failure.status) from None + raise + out.emit("overview", data=summary) + entries = accounts(client.request("accounts")) + out.emit("accounts", data=entries) + # The server keeps its latest few hundred requests; a fresh view needs only the newest. + out.emit("requests", data=recent(client.request(f"requests?limit={REQUEST_LIMIT}"))) + try: + out.emit("faults", data=faults(client.request("faults")), source="server") + return entries, None + except Failure as failure: + # The key was just accepted for /api/accounts. Releases without /api/faults + # pass unknown /api paths to client-key authentication, which answers 401. + if failure.status not in (401, 403, 404, 405): + raise + derived = derived_faults(entries, now_ms) + out.emit("faults", data=derived, source="accounts") + return entries, derived + + +def socket_failure(error) -> Failure: + response = getattr(error, "response", None) + status = getattr(response, "status_code", None) + if isinstance(status, int): + headers = getattr(response, "headers", None) + return http_failure(status, retry=retry_after(headers.get("Retry-After")) if headers is not None else None) + # Fusebox closes its live sockets with 1001 (going away) when it restarts. + if getattr(getattr(error, "rcvd", None), "code", None) == 1001: + return Failure("restarting", "Fusebox is restarting. Reconnecting…") + if isinstance(error, (TimeoutError, OSError)): + return Failure("offline", "Can't reach Fusebox. Check the URL, the network and its TLS certificate.") + return Failure("offline", "The live connection to Fusebox closed. Reconnecting…") + + +def stream(base: str, out: Output, connect, clock=time.monotonic, wall=time.time): + """One connection, until it fails: always raises the reason.""" + client = Client(base, read_key()) + out.emit("state", state="connecting") + try: + # The key travels only in the upgrade request's header, never the URL. + connection = connect(websocket_url(base), additional_headers={"Authorization": "Bearer " + client.key}, + open_timeout=8, close_timeout=1, max_size=MAX_FRAME, max_queue=64, + ping_interval=None, proxy=None) + except Exception as error: # websockets raises several types; never echo their text. + raise socket_failure(error) from None + with connection as socket: + entries, derived = snapshot(client, out, int(wall() * 1000)) + server_faults = derived is None + out.emit("state", state="live") + heard = fetched = derived_at = clock() + refetch_at = None + pong = None + clients = Clients(client, out, clock) + while True: + now = clock() + if pong is not None and pong.is_set(): + heard, pong = now, None + if now - heard > STALE: + raise Failure("offline", "Fusebox stopped answering. Reconnecting…") + if pong is None and now - heard >= PING_AFTER: + try: + pong = socket.ping() + except Exception as error: + raise socket_failure(error) from None + if (refetch_at is not None and now >= refetch_at) or now - fetched >= IDLE_REFRESH: + entries = accounts(client.request("accounts")) + out.emit("accounts", data=entries) + fetched, refetch_at, derived_at = clock(), None, -math.inf + clients.poll() + if not server_faults and now - derived_at >= DERIVE_EVERY: + current = derived_faults(entries, int(wall() * 1000)) + if current != derived: + out.emit("faults", data=current, source="accounts") + derived = current + derived_at = now + out.heartbeat() + try: + raw = socket.recv(timeout=1) + except TimeoutError: + continue + except Exception as error: + raise socket_failure(error) from None + if not isinstance(raw, str) or len(raw) > MAX_FRAME: + raise Failure("offline", "Fusebox sent a live update the widget can't read.") + try: + message = json.loads(raw) + except (ValueError, RecursionError): + raise Failure("offline", "Fusebox sent a live update the widget can't read.") from None + if not isinstance(message, dict): + continue + heard = clock() + kind, data = message.get("type"), message.get("data") + if kind == "load": + out.emit("load", data=load(data)) + elif kind == "faults" and server_faults: + out.emit("faults", data=faults(data), source="server") + elif kind == "request": + entry = request(data) + if entry: + out.emit("request", data=entry) + elif kind == "tick" and isinstance(data, dict): + out.emit("tick", active=count(data.get("active")), totals=totals(data.get("totals"))) + elif kind == "accounts" and refetch_at is None: + refetch_at = max(heard, fetched + COALESCE) + + +def key_signature(): + """Changes whenever the key file is replaced or removed.""" + try: + info = os.stat(key_path()) + except OSError: + return None + return info.st_ino, info.st_mtime_ns, info.st_size + + +def live(address: str, out: Output, connect, clock=time.monotonic, sleep=time.sleep, wall=time.time): + try: + base = normalize_url(address) + except Failure as failure: + out.emit("state", state=failure.state, message=failure.message) + return 2 + if connect is None: + out.emit("state", state="unsupported", message="Install python3-websockets for live Fusebox updates.") + return 3 + delay = 2 + restarted = None # when Fusebox last said it was restarting + while True: + started = clock() + watch = None + try: + stream(base, out, connect, clock=clock, wall=wall) + except Failure as failure: + state, message, extra = failure.state, failure.message, {} + if clock() - started >= 30: + delay, restarted = 2, None + if state == "restarting": + restarted = clock() + elif state == "offline" and restarted is not None and clock() - restarted < RESTART_GRACE: + # Dials fail until the new process listens; that is still the restart. + state, message = "restarting", "Fusebox is restarting. Reconnecting…" + if state == "setup": + pause = SETUP_RECHECK + elif state == "auth": + # Every try with a wrong key brings a lockout closer, for every + # program on this computer: wait for a new key instead. + pause, watch = AUTH_RETRY, key_signature() + elif state == "locked": + pause = (failure.retry or 60) + 1 + extra["until"] = int((wall() + pause) * 1000) + elif state == "restarting": + pause = 1 if failure.state == "restarting" else min(delay, 5) + delay = 2 if failure.state == "restarting" else min(60, delay * 2) + else: + pause = delay + delay = min(60, delay * 2) + out.emit("state", state=state, message=message, **extra) + deadline = clock() + pause + while clock() < deadline: + out.heartbeat() + if watch is not None and key_signature() != watch: + break + sleep(min(1, max(0, deadline - clock()))) + + +# ---------------------------------------------------------------- banked resets +# Spending a banked reset can't be undone. Fusebox journals each spend before it +# dispatches it, locks the subscription, binds the confirmation to the inventory +# that was shown (the quote) and never retries. This client keeps those rules: +# each POST is sent once, the quote is the one the user confirmed, and a request +# that may have reached Fusebox without a clear answer is reported as uncertain. + +TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:~-]{0,255}") +OPERATION_STATES = ("pending", "unknown", "applied", "already_used", "refused", "reconciled_used", "reconciled_unused") +UNRESOLVED = ("pending", "unknown") +BANKED_ACTIONS = ("redeem", "retry", "resolve-used", "resolve-unused") +SPEND_TIMEOUT = 90 # Fusebox refreshes usage after a spend; give it time, then never resend +UNCERTAIN = ("The reset request didn't finish, so a reset may have been used. " + "Refresh the status before doing anything else.") + + +def token(value): + return value if isinstance(value, str) and TOKEN.fullmatch(value) else None + + +def operation_status(value) -> str: + # A state this client doesn't know fails closed, as one needing review. + return value if value in OPERATION_STATES else "unknown" + + +def optional_count(value): + return None if value is None else count(value) + + +def banked_view(payload): + if not isinstance(payload, dict) or not isinstance(payload.get("checked_at"), str): + raise Failure("offline", "Fusebox sent reset status the widget can't read.") + inventory = None + if isinstance(payload.get("inventory"), dict): + raw = payload["inventory"] + grants = [] + for g in raw["grants"][:32] if isinstance(raw.get("grants"), list) else []: + if not isinstance(g, dict) or not token(g.get("id")): + continue + clears = g.get("clears") if isinstance(g.get("clears"), list) else [] + grants.append({"id": g["id"], "label": clean(g.get("label"), 120) or "Subscription reset", + "remaining": count(g.get("remaining")), "expiresAt": epoch_ms(g.get("expires_at")), + "startsAt": epoch_ms(g.get("starts_at")), + "clears": [c for c in (clean(x, 40) for x in clears[:8]) if c], + "usable": g.get("usable") is True, "reason": clean(g.get("reason"), 200)}) + inventory = {"available": optional_count(raw.get("available")), + "applicable": optional_count(raw.get("applicable")), + "eligible": raw.get("eligible") is True, "reason": clean(raw.get("reason"), 200), + "selectedGrant": token(raw.get("selected_grant")), "grants": grants} + operation = None + if isinstance(payload.get("operation"), dict): + raw = payload["operation"] + operation = {"requestId": token(raw.get("request_id")), "status": operation_status(raw.get("status")), + "message": clean(raw.get("message"), 200), "createdAt": epoch_ms(raw.get("created_at")), + "retryUntil": epoch_ms(raw.get("retry_until"))} + return {"checkedAt": epoch_ms(payload["checked_at"]), "inventory": inventory, + "error": clean(payload.get("error"), 300), "quote": token(payload.get("quote")), + "operation": operation, "retryable": payload.get("retryable") is True} + + +def run_banked(address: str, identity: str): + """Reads fresh reset status. Fusebox asks the provider, but nothing is spent.""" + client = Client(normalize_url(address), read_key()) + return {"ok": True, "view": banked_view(client.request(account_path(identity) + "/banked-resets", timeout=45))} + + +def run_banked_refresh(address: str, identity: str): + """Fusebox's quota refresh: usage and reset status, without spending.""" + client = Client(normalize_url(address), read_key()) + body = client.request(account_path(identity) + "/quota/refresh", method="POST", timeout=60) + if not isinstance(body, dict) or "checked_at" not in body: + raise Failure("missing", "Banked resets are turned off on this Fusebox.") + return {"ok": True, "view": banked_view(body)} + + +def run_banked_action(address: str, identity: str, action: str, request_id: str, grant_id: str = ""): + if action not in BANKED_ACTIONS or not token(request_id) or (grant_id and not token(grant_id)): + raise Failure("invalid", "That reset request isn't valid. Refresh the status and try again.") + client = Client(normalize_url(address), read_key()) + payload = {"action": action, "request_id": request_id, "grant_id": grant_id, "confirmed": True} + try: + # Sent once. Fusebox has journalled the spend before it answers, so a lost + # answer must never be repeated here. + body = client.request(account_path(identity) + "/banked-resets", method="POST", payload=payload, + timeout=SPEND_TIMEOUT) + except Failure as failure: + if failure.status is None or failure.status >= 500: + raise Failure("uncertain", UNCERTAIN, failure.status) from None + raise + try: + return {"ok": True, "view": banked_view(body)} + except Failure: + raise Failure("uncertain", UNCERTAIN) from None + + +# ---------------------------------------------------------------- one-shot + + +ACTIONS = {"toggle": "toggle", "refresh": "refresh", "reset": "reset", "quota": "quota/refresh", + "priority": "priority"} +# New sessions go to higher priorities first: the dashboard's First, Normal and Last. +PRIORITIES = (10, 0, -10) + + +def account_path(identity: str) -> str: + if not isinstance(identity, str) or not 0 < len(identity) <= 512 or CONTROL.search(identity): + raise Failure("invalid", "Unknown account.") + return "accounts/" + urllib.parse.quote(identity, safe="") + + +def run_action(address: str, name: str, identity: str, disabled: bool | None = None, priority: int | None = None): + if name == "priority" and priority not in PRIORITIES: + raise Failure("invalid", "That priority isn't one Fusebox offers.") + client = Client(normalize_url(address), read_key()) + payload = {"disabled": bool(disabled)} if name == "toggle" else {"priority": priority} if name == "priority" else None + # Refreshing a sign-in also polls the provider's quota, which can take a while. + client.request(account_path(identity) + "/" + ACTIONS[name], method="POST", payload=payload, timeout=45) + return {"ok": True} + + +def run_activity(address: str, identity: str): + client = Client(normalize_url(address), read_key()) + return activity(client.request(account_path(identity) + "/activity")) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + commands = parser.add_subparsers(dest="command", required=True) + live_parser = commands.add_parser("live") + live_parser.add_argument("--url", required=True) + action_parser = commands.add_parser("action") + action_parser.add_argument("--url", required=True) + action_parser.add_argument("name", choices=sorted(ACTIONS)) + action_parser.add_argument("account") + action_parser.add_argument("--disabled", choices=("true", "false")) + action_parser.add_argument("--priority", type=int, choices=PRIORITIES) + activity_parser = commands.add_parser("activity") + activity_parser.add_argument("--url", required=True) + activity_parser.add_argument("account") + for name in ("banked", "banked-refresh"): + banked_parser = commands.add_parser(name) + banked_parser.add_argument("--url", required=True) + banked_parser.add_argument("account") + spend_parser = commands.add_parser("banked-action") + spend_parser.add_argument("--url", required=True) + spend_parser.add_argument("--action", required=True, choices=BANKED_ACTIONS) + spend_parser.add_argument("--request", required=True) + spend_parser.add_argument("--grant", default="") + spend_parser.add_argument("account") + commands.add_parser("store-key", help="read the key from stdin") + commands.add_parser("forget-key") + commands.add_parser("key-status") + args = parser.parse_args(argv) + + def reply(value) -> None: + print(json.dumps(value, separators=(",", ":")), flush=True) + + try: + if args.command == "live": + try: + from websockets.sync.client import connect + except ImportError: + connect = None + return live(args.url, Output(), connect) + try: + if args.command == "action": + if args.name == "toggle" and args.disabled is None: + parser.error("toggle needs --disabled") + if args.name == "priority" and args.priority is None: + parser.error("priority needs --priority") + reply(run_action(args.url, args.name, args.account, args.disabled == "true", args.priority)) + elif args.command == "activity": + reply(run_activity(args.url, args.account)) + elif args.command == "banked": + reply(run_banked(args.url, args.account)) + elif args.command == "banked-refresh": + reply(run_banked_refresh(args.url, args.account)) + elif args.command == "banked-action": + reply(run_banked_action(args.url, args.account, args.action, args.request, args.grant)) + elif args.command == "store-key": + store_key(sys.stdin.readline(MAX_KEY + 2)) + reply({"saved": True}) + elif args.command == "forget-key": + forget_key() + reply({"saved": False}) + else: + try: + read_key() + reply({"saved": True}) + except Failure as failure: + reply({"saved": False, "error": failure.message} if os.path.lexists(key_path()) + else {"saved": False}) + return 0 + except Failure as failure: + reply({"ok": False, "state": failure.state, "error": failure.message}) + except BrokenPipeError: + raise + except OSError: + reply({"ok": False, "state": "setup", "error": "The Fusebox key file couldn't be saved."}) + return 1 + except (BrokenPipeError, KeyboardInterrupt): + # The shell went away; nothing is left to tell. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/roles/desktop/files/quickshell/scripts/hypridle-config.py b/desktop/quickshell/scripts/hypridle-config.py similarity index 97% rename from roles/desktop/files/quickshell/scripts/hypridle-config.py rename to desktop/quickshell/scripts/hypridle-config.py index 51a815da..a023f3c7 100644 --- a/roles/desktop/files/quickshell/scripts/hypridle-config.py +++ b/desktop/quickshell/scripts/hypridle-config.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Render the hypridle configuration from the shell's idle settings. -cybexos-runtime runs this when hypridle starts without a user-owned +nerdos-runtime runs this when hypridle starts without a user-owned hypridle.conf. With default settings the output is byte-for-byte the vendor hypridle.conf, which remains the fallback if this script fails. @@ -25,7 +25,7 @@ "idleSuspendMins": (0, 15, 30, 60, 120), } -LOCK = "systemctl --user start cybexos-session-lock.service" +LOCK = "systemctl --user start nerdos-session-lock.service" DPMS_OFF = "hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = \"off\" }))'" DPMS_ON = "hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = \"on\" }))'" # hypridle runs condition_cmd first and, when it fails, skips on-timeout and diff --git a/roles/desktop/files/quickshell/scripts/network-speedtest.py b/desktop/quickshell/scripts/network-speedtest.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/network-speedtest.py rename to desktop/quickshell/scripts/network-speedtest.py diff --git a/roles/desktop/files/quickshell/scripts/network-tool.py b/desktop/quickshell/scripts/network-tool.py similarity index 99% rename from roles/desktop/files/quickshell/scripts/network-tool.py rename to desktop/quickshell/scripts/network-tool.py index 2922548f..0fff3d57 100644 --- a/roles/desktop/files/quickshell/scripts/network-tool.py +++ b/desktop/quickshell/scripts/network-tool.py @@ -70,10 +70,10 @@ # profiles. The name is the fallback match if NetworkManager ever assigns a # different UUID. HOTSPOT_UUID = "6f1c2d9a-3b7e-4c5a-9e21-c7b0f4a8d153" -HOTSPOT_NAME = "CybexOS hotspot" +HOTSPOT_NAME = "NerdOS hotspot" # Shared mode runs NetworkManager's DHCP and DNS for the clients; the # nm-shared firewalld zone admits exactly those and forwards their traffic. -# The CybexOS zone drops them, so the hotspot never joins it. WPA2-only +# The NerdOS zone drops them, so the hotspot never joins it. WPA2-only # (CCMP) with PMF off works with the widest range of clients and access-point # drivers; 2.4 GHz avoids the no-IR limits many adapters have on 5 GHz. HOTSPOT_SETTINGS = ( @@ -881,7 +881,10 @@ def parse_added_uuid(output: str) -> str: def inherit_dns_for_new_profile(profile_uuid: str, previous_profiles: list[dict[str, Any]]) -> None: - state = aggregate_dns_state(previous_profiles) + # The first Wi-Fi connection also uses Google on Wi-Fi-only hardware. + # Once a person has chosen a provider, including Automatic, inherit it. + targets = [profile for profile in previous_profiles if target_profile(profile)] + state = aggregate_dns_state(targets) if targets else {"provider": "Google", "servers": [], "mixed": False} if state["mixed"]: return configuration = provider_configuration(state["provider"], state["servers"]) @@ -1270,7 +1273,7 @@ def vpn_import(request: dict[str, Any]) -> dict[str, Any]: or path.suffix != ".conf"): # A symlink under a valid name, not a copy: the private key is # never duplicated into a temporary file. - scratch = Path(tempfile.mkdtemp(prefix="cybexos-vpn-import-")) + scratch = Path(tempfile.mkdtemp(prefix="nerdos-vpn-import-")) target = scratch / f"{wireguard_interface(path.stem)}.conf" target.symlink_to(path) result = run(["nmcli", "connection", "import", "type", kind, "file", str(target)], @@ -1358,7 +1361,7 @@ def generate_password() -> str: def default_hotspot_ssid() -> str: host = socket.gethostname().split(".", 1)[0].strip() if not host or host.lower() in ("localhost", "localhost-live"): - return "CybexOS hotspot" + return "NerdOS hotspot" name = f"{host} hotspot" while len(name.encode("utf-8")) > 32: host = host[:-1] diff --git a/roles/desktop/files/quickshell/scripts/network_vpn_agent.py b/desktop/quickshell/scripts/network_vpn_agent.py similarity index 99% rename from roles/desktop/files/quickshell/scripts/network_vpn_agent.py rename to desktop/quickshell/scripts/network_vpn_agent.py index d921d723..f220b0ac 100644 --- a/roles/desktop/files/quickshell/scripts/network_vpn_agent.py +++ b/desktop/quickshell/scripts/network_vpn_agent.py @@ -1,6 +1,6 @@ """A short-lived NetworkManager secret agent for one VPN activation. -CybexOS runs no network applet, so when a VPN needs a password +NerdOS runs no network applet, so when a VPN needs a password NetworkManager finds no secret agent to ask and fails the activation with "no secrets". For the length of one user-requested activation this module registers a secret agent on the system bus and activates the profile from @@ -306,7 +306,7 @@ def run(self) -> None: self.on_method, None, None) try: self.call(AGENT_MANAGER_PATH, NM_BUS + ".AgentManager", "RegisterWithCapabilities", - "(su)", (f"org.cybexos.network.vpn-{os.getpid()}", CAPABILITY_VPN_HINTS)) + "(su)", (f"org.nerdos.network.vpn-{os.getpid()}", CAPABILITY_VPN_HINTS)) self.registered = True except GLib.Error as error: raise AgentError("agent_failed", "NetworkManager did not accept the VPN sign-in helper") from error diff --git a/roles/desktop/files/quickshell/scripts/note-title.py b/desktop/quickshell/scripts/note-title.py similarity index 99% rename from roles/desktop/files/quickshell/scripts/note-title.py rename to desktop/quickshell/scripts/note-title.py index 51ccd843..18c1b81c 100644 --- a/roles/desktop/files/quickshell/scripts/note-title.py +++ b/desktop/quickshell/scripts/note-title.py @@ -124,7 +124,7 @@ def command_for(provider: str, model: str, effort: str) -> list[str]: def timeout_seconds() -> float: # The override exists only so the source suite can exercise the real # process-group timeout path without making every run wait 30 seconds. - raw = os.environ.get("CYBEXOS_NOTE_TITLE_TEST_TIMEOUT", "") + raw = os.environ.get("NERDOS_NOTE_TITLE_TEST_TIMEOUT", "") if not raw: return TIMEOUT_SECONDS try: diff --git a/roles/desktop/files/quickshell/scripts/notification-history.py b/desktop/quickshell/scripts/notification-history.py similarity index 99% rename from roles/desktop/files/quickshell/scripts/notification-history.py rename to desktop/quickshell/scripts/notification-history.py index 22ed9480..efe3c085 100644 --- a/roles/desktop/files/quickshell/scripts/notification-history.py +++ b/desktop/quickshell/scripts/notification-history.py @@ -158,7 +158,7 @@ def state_dir(create): # The XDG root is user configured; owned descendants may never be symlinks. fd = os.open(base, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) try: - for part in ("cybexos", "notifications"): + for part in ("nerdos", "notifications"): if create: try: os.mkdir(part, mode=0o700, dir_fd=fd) diff --git a/roles/desktop/files/quickshell/scripts/notification-icon.py b/desktop/quickshell/scripts/notification-icon.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/notification-icon.py rename to desktop/quickshell/scripts/notification-icon.py diff --git a/roles/desktop/files/quickshell/scripts/plugin_packages.py b/desktop/quickshell/scripts/plugin_packages.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/plugin_packages.py rename to desktop/quickshell/scripts/plugin_packages.py diff --git a/roles/desktop/files/quickshell/scripts/processes b/desktop/quickshell/scripts/processes similarity index 100% rename from roles/desktop/files/quickshell/scripts/processes rename to desktop/quickshell/scripts/processes diff --git a/roles/desktop/files/quickshell/scripts/remote-server.py b/desktop/quickshell/scripts/remote-server.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/remote-server.py rename to desktop/quickshell/scripts/remote-server.py diff --git a/roles/desktop/files/quickshell/scripts/remote_server_probe.py b/desktop/quickshell/scripts/remote_server_probe.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/remote_server_probe.py rename to desktop/quickshell/scripts/remote_server_probe.py diff --git a/roles/desktop/files/quickshell/scripts/settings-store b/desktop/quickshell/scripts/settings-store similarity index 100% rename from roles/desktop/files/quickshell/scripts/settings-store rename to desktop/quickshell/scripts/settings-store diff --git a/roles/desktop/files/quickshell/scripts/shell-health.py b/desktop/quickshell/scripts/shell-health.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/shell-health.py rename to desktop/quickshell/scripts/shell-health.py diff --git a/desktop/quickshell/scripts/shell-recovery.py b/desktop/quickshell/scripts/shell-recovery.py new file mode 100644 index 00000000..6bb63a45 --- /dev/null +++ b/desktop/quickshell/scripts/shell-recovery.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Crash-loop recovery for the managed shell, without modifying user settings. + +The launcher execs qs so systemd's MainPID remains the only shell PID. +ExecStopPost records only failed invocations. Three short failures in two +minutes select a separate minimal configuration until the user retries. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import fcntl +import json +import os +from pathlib import Path +import sys +import tempfile +import time +from typing import Any, Iterator + +FAILURE_WINDOW = 120.0 +FAILURE_LIMIT = 3 + + +def state_path() -> Path: + return Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "nerdos/shell-recovery.json" + + +def boot_id() -> str: + return Path("/proc/sys/kernel/random/boot_id").read_text().strip() + + +def read_state(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text()) + if not isinstance(value, dict) or value.get("version") != 1: + raise ValueError("invalid recovery state") + return value + except FileNotFoundError: + return {"version": 1, "safe": False, "failures": []} + except (ValueError, OSError): + # A damaged recovery record must not strand the desktop. It contains + # only disposable lifecycle data, never user settings or plugin data. + return {"version": 1, "safe": True, "failures": [], "reason": "Recovery state could not be read"} + + +def write_state(path: Path, value: dict[str, Any]) -> None: + fd, temporary = tempfile.mkstemp(prefix=".shell-recovery-", dir=path.parent) + try: + with os.fdopen(fd, "w") as stream: + json.dump(value, stream, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +@contextmanager +def state_lock(path: Path) -> Iterator[None]: + path.parent.mkdir(parents=True, exist_ok=True) + with (path.parent / ".shell-recovery.lock").open("a") as lock: + os.chmod(lock.name, 0o600) + fcntl.flock(lock, fcntl.LOCK_EX) + yield + + +def prepare(state: dict[str, Any], now: float, boot: str, invocation: str) -> dict[str, Any]: + state = dict(state) + if state.get("boot") != boot: + state["failures"] = [] + state.update(boot=boot, invocation=invocation, started=now, active=True) + return state + + +def record_stop(state: dict[str, Any], now: float, boot: str, invocation: str, + result: str, exit_code: str, exit_status: str) -> dict[str, Any]: + state = dict(state) + if state.get("boot") != boot or state.get("invocation") != invocation or not state.get("active"): + return state + state["active"] = False + state["lastExit"] = {"result": result, "code": exit_code, "status": exit_status} + elapsed = max(0.0, now - float(state.get("started", now))) + failures = [value for value in state.get("failures", []) + if isinstance(value, (int, float)) and 0 <= now - value <= FAILURE_WINDOW] + if result == "success" or elapsed > FAILURE_WINDOW: + failures = [] + elif result in {"exit-code", "signal", "core-dump", "timeout", "watchdog", "oom-kill"}: + failures.append(now) + state["failures"] = failures[-FAILURE_LIMIT:] + if len(failures) >= FAILURE_LIMIT: + state["safe"] = True + state["reason"] = "The desktop failed three times within two minutes" + return state + + +def selected_path(runtime: Path, state: dict[str, Any]) -> Path: + fallback = runtime / "safe-mode" + return fallback if state.get("safe") and (fallback / "shell.qml").is_file() else runtime + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["run", "record-stop", "status", "path", "safe", "recover"]) + parser.add_argument("runtime", type=Path) + args = parser.parse_args(argv) + path = state_path() + with state_lock(path): + state = read_state(path) + if args.action == "run": + state = prepare(state, time.monotonic(), boot_id(), os.environ.get("INVOCATION_ID", "")) + elif args.action == "record-stop": + state = record_stop(state, time.monotonic(), boot_id(), os.environ.get("INVOCATION_ID", ""), + os.environ.get("SERVICE_RESULT", ""), os.environ.get("EXIT_CODE", ""), + os.environ.get("EXIT_STATUS", "")) + elif args.action in {"safe", "recover"}: + # Invalidate the old invocation: its ExecStopPost must not undo + # this deliberate recovery choice during the following restart. + state.update(safe=args.action == "safe", failures=[], active=False, + reason="Safe mode requested" if args.action == "safe" else "") + if args.action not in {"status", "path"}: + write_state(path, state) + if args.action == "status": + print(json.dumps({**state, "path": str(path), "runtime": str(selected_path(args.runtime, state))}, sort_keys=True)) + elif args.action == "path": + print(selected_path(args.runtime, state)) + elif args.action == "run": + selected = selected_path(args.runtime, state) + if selected != args.runtime: + os.environ["NERDOS_SHELL_SAFE_MODE"] = "1" + print("nerdos: starting the recovery desktop; use nerd shell recover to retry", file=sys.stderr, flush=True) + os.execvp("qs", ["qs", "-p", str(selected)]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roles/desktop/files/quickshell/scripts/system-settings.py b/desktop/quickshell/scripts/system-settings.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system-settings.py rename to desktop/quickshell/scripts/system-settings.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_accounts.py b/desktop/quickshell/scripts/system_settings_accounts.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system_settings_accounts.py rename to desktop/quickshell/scripts/system_settings_accounts.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_audio.py b/desktop/quickshell/scripts/system_settings_audio.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system_settings_audio.py rename to desktop/quickshell/scripts/system_settings_audio.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_input.py b/desktop/quickshell/scripts/system_settings_input.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/system_settings_input.py rename to desktop/quickshell/scripts/system_settings_input.py index 5d486934..c3f414ff 100644 --- a/roles/desktop/files/quickshell/scripts/system_settings_input.py +++ b/desktop/quickshell/scripts/system_settings_input.py @@ -78,7 +78,7 @@ def catalog(path=Path('/usr/share/X11/xkb/rules/evdev.xml')): class InputSettings: def __init__(self): - self.path = Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'cybexos/input.json' + self.path = Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'nerdos/input.json' def read(self): if self.path.is_symlink(): @@ -193,7 +193,7 @@ def dispatch(self, request): raise ValueError('Hyprland rejected the input configuration') # The loader contains errors to keep the compositor alive; # reload's acknowledgement alone therefore cannot prove success. - output = run(['hyprctl', 'eval', 'assert(__cybexos_input_error == nil, __cybexos_input_error)']) + output = run(['hyprctl', 'eval', 'assert(__nerdos_input_error == nil, __nerdos_input_error)']) if output.lower() != 'ok': raise ValueError('Hyprland rejected the saved input preferences') except Exception as error: diff --git a/roles/desktop/files/quickshell/scripts/system_settings_network.py b/desktop/quickshell/scripts/system_settings_network.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system_settings_network.py rename to desktop/quickshell/scripts/system_settings_network.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_region.py b/desktop/quickshell/scripts/system_settings_region.py similarity index 78% rename from roles/desktop/files/quickshell/scripts/system_settings_region.py rename to desktop/quickshell/scripts/system_settings_region.py index fd5c4bcb..ddc2fb5f 100644 --- a/roles/desktop/files/quickshell/scripts/system_settings_region.py +++ b/desktop/quickshell/scripts/system_settings_region.py @@ -1,5 +1,6 @@ -"""System region settings through timedated/localed's normal Polkit policy.""" +"""Read region services; stage persistent NixOS declarations for changes.""" import os +import json import subprocess TIME = 'org.freedesktop.timedate1' @@ -58,10 +59,7 @@ def dispatch(self, request): raise ValueError('Choose an installed timezone') if request.get('previous') != self.properties(TIME).get('Timezone', ''): raise ValueError('The timezone changed elsewhere. Discard the pending change and refresh before applying.') - self.call(TIME, TIME, 'SetTimezone', '(sb)', (value, True), interactive=True) - if self.properties(TIME).get('Timezone') != value: - raise ValueError('The timezone was not retained. Refresh before retrying.') - return {'message': 'System timezone updated'} + return self.stage('timezone', value, request['previous']) if action == 'locale': value = request.get('value') if not isinstance(value, str) or value not in choices(['localectl', 'list-locales', '--no-pager']): @@ -69,11 +67,12 @@ def dispatch(self, request): previous = self.properties(LOCALE).get('Locale', []) if request.get('previous') != previous: raise ValueError('System language settings changed elsewhere. Discard the pending change and refresh before applying.') - # SetLocale replaces the whole array. Keep LC_TIME, LC_NUMERIC and - # every other explicit category; only change the requested LANG. - values = [entry for entry in previous if not entry.startswith('LANG=')] + ['LANG=' + value] - self.call(LOCALE, LOCALE, 'SetLocale', '(asb)', (values, True), interactive=True) - if sorted(self.properties(LOCALE).get('Locale', [])) != sorted(values): - raise ValueError('The language settings were not retained. Refresh before retrying.') - return {'message': 'System language updated. Sign out and back in for applications to use it.'} + return self.stage('locale', value, previous) raise ValueError('Unknown region operation') + + def stage(self, field, value, previous): + result = subprocess.run(['nerd', 'config', 'region', field, value, json.dumps(previous)], + capture_output=True, text=True, timeout=50) + if result.returncode: + raise ValueError('Regional preview could not start. Check authorization and nerd update status.') + return {'staged': True, 'message': 'Preview started. Open Updates, review the complete build and apply the generation to save this setting.'} diff --git a/roles/desktop/files/quickshell/scripts/system_settings_security.py b/desktop/quickshell/scripts/system_settings_security.py similarity index 88% rename from roles/desktop/files/quickshell/scripts/system_settings_security.py rename to desktop/quickshell/scripts/system_settings_security.py index 7a3ad832..fb0d7c87 100644 --- a/roles/desktop/files/quickshell/scripts/system_settings_security.py +++ b/desktop/quickshell/scripts/system_settings_security.py @@ -2,8 +2,8 @@ No secret crosses this API. fprintd keeps prints in its own root-only store, and a security-key registration is an opaque key handle and a public key. -Changes go through their owners' Polkit checks: fprintd's own enrol action for -fingerprints, and pkexec for the authselect switch (cybexos-auth-policy). +Enrolment goes through fprintd's own Polkit checks. Authentication policy is +declared in personal NixOS modules and applied through the staged lifecycle. `python3 system_settings_security.py enroll FINGER` streams one enrolment as JSON lines from fprintd's EnrollStatus signal; SIGTERM cancels it. @@ -28,13 +28,11 @@ FINGERS = ('right-index-finger', 'left-index-finger', 'right-thumb', 'left-thumb', 'right-middle-finger', 'left-middle-finger', 'right-ring-finger', 'left-ring-finger', 'right-little-finger', 'left-little-finger') -# Must match roles/base/files/cybexos-auth-policy: credentials registered for -# another relying party never verify with the CybexOS PAM steps. -ORIGIN = 'pam://cybexos' -AUTHSELECT_CONF = Path('/etc/authselect/authselect.conf') -PAM_FILES = (Path('/etc/pam.d/sudo'), Path('/etc/pam.d/polkit-1'), Path('/etc/pam.d/cybexos-lock')) -# The checkout installs the policy helper under /usr/local, the ISO under /usr. -POLICY_HELPERS = (Path('/usr/local/libexec/cybexos-auth-policy'), Path('/usr/libexec/cybexos-auth-policy')) +# Must match nix/modules/security.nix: credentials registered for +# another relying party never verify with the NerdOS PAM steps. +ORIGIN = 'pam://nerdos' +FINGERPRINT_POLICY = Path('/etc/nerdos/fingerprint-enabled') +PAM_FILES = (Path('/etc/pam.d/sudo'), Path('/etc/pam.d/polkit-1'), Path('/etc/pam.d/nerdos-lock')) # KeyHandle,UserKey,CoseType,Options as pamu2fcfg writes them; '*' is the key # handle of a resident credential. CREDENTIAL = re.compile(r'^(?:\*|[A-Za-z0-9+/=_-]{8,1024}),[A-Za-z0-9+/=_-]{8,1024},[a-z0-9]{2,16},[+a-z]{0,64}$') @@ -152,15 +150,14 @@ def render_mapping(user, credentials, others): class SecuritySettings: - def __init__(self, fprintd=None, account=None, runner=run, authselect=AUTHSELECT_CONF, - pam_files=PAM_FILES, helpers=POLICY_HELPERS, which=shutil.which): + def __init__(self, fprintd=None, account=None, runner=run, nixos_policy=FINGERPRINT_POLICY, + pam_files=PAM_FILES, which=shutil.which): self._fprintd = fprintd account = account or pwd.getpwuid(os.getuid()) self.user, self.home = account.pw_name, Path(account.pw_dir) self.run = runner - self.authselect = Path(authselect) + self.nixos_policy = Path(nixos_policy) self.pam_files = tuple(Path(path) for path in pam_files) - self.helpers = tuple(Path(path) for path in helpers) self.which = which @property @@ -193,10 +190,10 @@ def reader(self): def features(self): try: - words = self.authselect.read_text().split() - except OSError: - return None - return words[1:] if words else None + enabled = self.nixos_policy.read_text().strip() == 'enabled' + except FileNotFoundError: + enabled = False + return ['with-fingerprint'] if enabled else [] def fingerprint_snapshot(self): reader, path = self.reader() @@ -244,27 +241,12 @@ def set_fingerprint_auth(self, request): enabled = request.get('enabled') if not isinstance(enabled, bool): raise ValueError('Invalid fingerprint setting') - helper = next((path for path in self.helpers if os.access(path, os.X_OK)), None) - if helper is None: - raise ValueError('The sign-in policy helper is missing. Run cybex update, then try again.') - code, output, _ = self.run(['pkexec', str(helper), 'fingerprint', 'on' if enabled else 'off'], 50) - if code in (126, 127) and not output.strip(): - raise ValueError('Authorization was cancelled or denied. Retry and approve the system prompt.') - try: - result = json.loads(output.strip().splitlines()[-1]) - except (ValueError, IndexError): - result = {} - if result.get('success') is not True: - raise ValueError(result.get('error') if isinstance(result.get('error'), str) - else 'Fingerprint sign-in was not changed. Refresh and try again.') - return {'message': 'Fingerprint sign-in turned ' + ('on' if enabled else 'off')} + raise ValueError('Set nerdos.authentication.fingerprint in /etc/nixos/local, then preview and apply with nerd update stage local. Enrolment remains available here.') # ── security keys ─────────────────────────────────────────────────── def key_state(self, features): if not self.which('pamu2fcfg'): return 'missing' - if features and any(feature in features for feature in ('with-pam-u2f', 'with-pam-u2f-2fa')): - return 'authselect' for path in self.pam_files: try: if 'origin=' + ORIGIN in path.read_text(): @@ -317,9 +299,6 @@ def save(self, credentials, others): os.replace(temporary, self.mapping) finally: Path(temporary).unlink(missing_ok=True) - # Fedora's policy labels this folder for PAM; a folder made here - # otherwise inherits ~/.config's label and pam_u2f may not read it. - self.run(['restorecon', '-R', str(directory)], 10) def add_key(self): state = self.key_state(self.features()) @@ -339,7 +318,7 @@ def add_key(self): credential = output.strip() credential = credential[1:] if credential.startswith(':') else credential.split(':', 1)[-1] if not CREDENTIAL.match(credential): - raise ValueError('The key returned a registration CybexOS does not recognise.') + raise ValueError('The key returned a registration NerdOS does not recognise.') credentials, others = self.credentials() if credential not in credentials: credentials.append(credential) diff --git a/roles/desktop/files/quickshell/scripts/t3-cloud.mjs b/desktop/quickshell/scripts/t3-cloud.mjs similarity index 100% rename from roles/desktop/files/quickshell/scripts/t3-cloud.mjs rename to desktop/quickshell/scripts/t3-cloud.mjs diff --git a/roles/desktop/files/quickshell/scripts/theme-apply.py b/desktop/quickshell/scripts/theme-apply.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/theme-apply.py rename to desktop/quickshell/scripts/theme-apply.py index ee47557c..45d7cb02 100644 --- a/roles/desktop/files/quickshell/scripts/theme-apply.py +++ b/desktop/quickshell/scripts/theme-apply.py @@ -3,7 +3,7 @@ Common/SystemTheme.qml pipes its resolved tokens (theme_tokens.py) to `theme-apply.py apply` whenever they change. Each target module renders its -files into ~/.local/state/cybexos/theme and, only when one of them changed, +files into ~/.local/state/nerdos/theme and, only when one of them changed, reloads its application. `--force` reloads every target regardless, for the `theme apply` IPC call. `render` rewrites the files from the saved tokens without reloading anything. diff --git a/roles/desktop/files/quickshell/scripts/theme-packs.py b/desktop/quickshell/scripts/theme-packs.py similarity index 93% rename from roles/desktop/files/quickshell/scripts/theme-packs.py rename to desktop/quickshell/scripts/theme-packs.py index cb140e89..52cc9c4d 100644 --- a/roles/desktop/files/quickshell/scripts/theme-packs.py +++ b/desktop/quickshell/scripts/theme-packs.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """List, apply, install, export and remove named appearance themes. -`cybex theme` (assets/scripts/cybexos-theme) runs this from the active shell +`nerd theme` (assets/scripts/nerdos-theme) runs this from the active shell runtime, and Settings → Appearance (Common/Themes.qml) uses the same commands, so both validate themes and map them to settings identically (scripts/theme_packs.py). @@ -37,7 +37,7 @@ def ipc(*args): """Calls the running shell; None when there is none to answer.""" - helper = os.environ.get('CYBEXOS_RUNTIME_HELPER') or shutil.which('cybexos-runtime') + helper = os.environ.get('NERDOS_RUNTIME_HELPER') or shutil.which('nerdos-runtime') if not helper: return None try: @@ -74,7 +74,7 @@ def command_list(args): for row in [('ID', 'NAME', 'MODE', 'SOURCE')] + rows: print(' '.join(value.ljust(width) for value, width in zip(row, widths)) + ' ' + row[3]) for entry in listing['invalid']: - print(f'cybex theme: skipped {entry["id"]}: {entry["error"]}', file=sys.stderr) + print(f'nerd theme: skipped {entry["id"]}: {entry["error"]}', file=sys.stderr) return 0 @@ -105,7 +105,7 @@ def command_apply(args): def command_install(args): theme = theme_packs.install(args.source, replace=args.replace) ipc('theme', 'refresh') - print(f'Installed {theme["name"]} as {theme["id"]}. Apply it with `cybex theme apply {theme["id"]}`.') + print(f'Installed {theme["name"]} as {theme["id"]}. Apply it with `nerd theme apply {theme["id"]}`.') return 0 @@ -120,7 +120,7 @@ def command_export(args): raise ThemeError('--name only applies to CURRENT') theme = theme_packs.find(args.id) written = theme_packs.export(theme, args.directory, wallpaper) - print(f'Exported {theme["name"]} to {written}. Install it with `cybex theme install {written}`.') + print(f'Exported {theme["name"]} to {written}. Install it with `nerd theme install {written}`.') return 0 @@ -147,7 +147,7 @@ def command_save(args): def parser(): - root = argparse.ArgumentParser(prog='cybex theme', description='Named appearance themes.') + root = argparse.ArgumentParser(prog='nerd theme', description='Named appearance themes.') commands = root.add_subparsers(dest='command', required=True) listing = commands.add_parser('list', help='list built-in and user themes') listing.add_argument('--json', action='store_true') @@ -185,7 +185,7 @@ def main(argv): message = str(error) if isinstance(error, ThemeError) else (error.strerror or str(error)) if args.command in ('resolve', 'save'): print(json.dumps({'error': message})) - print(f'cybex theme: {message}', file=sys.stderr) + print(f'nerd theme: {message}', file=sys.stderr) return 1 diff --git a/roles/desktop/files/quickshell/scripts/theme_gtk.py b/desktop/quickshell/scripts/theme_gtk.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/theme_gtk.py rename to desktop/quickshell/scripts/theme_gtk.py index 676fcd0b..e83c10c1 100644 --- a/roles/desktop/files/quickshell/scripts/theme_gtk.py +++ b/desktop/quickshell/scripts/theme_gtk.py @@ -148,7 +148,7 @@ def owns_text_scale(current, applied): def reload(tokens, directory): if shutil.which('gsettings') is None: - # glib2 ships gsettings on every Fedora install, so a PATH without it + # glib2 ships gsettings on every NerdOS install, so a PATH without it # is a bare environment (the driver tests run with an empty one), not # a desktop with GTK applications to follow it. return diff --git a/roles/desktop/files/quickshell/scripts/theme_hyprland.py b/desktop/quickshell/scripts/theme_hyprland.py similarity index 93% rename from roles/desktop/files/quickshell/scripts/theme_hyprland.py rename to desktop/quickshell/scripts/theme_hyprland.py index d9fd6602..8ecac7e1 100644 --- a/roles/desktop/files/quickshell/scripts/theme_hyprland.py +++ b/desktop/quickshell/scripts/theme_hyprland.py @@ -3,7 +3,7 @@ Renders hyprland.lua, a data-only table of hl.config values: window and group borders, the groupbar and the window shadow. looknfeel.lua applies it over its vendor colours when the compositor loads its configuration, and exposes -cybexos_system_theme(path), which reload() calls through `hyprctl eval` so a +nerdos_system_theme(path), which reload() calls through `hyprctl eval` so a theme change never needs a full config reload (that would also re-read glass and display state the shell changes at runtime). A key that user.lua (or anything else) has set since the theme last applied it is left alone until the @@ -23,9 +23,9 @@ EVAL_TIMEOUT = 10 HEADER = """\ --- Generated by CybexOS from the shell's appearance (theme-apply.py). Every +-- Generated by NerdOS from the shell's appearance (theme-apply.py). Every -- theme change replaces this file; set colours of your own in --- ~/.config/cybexos/hypr/user.lua instead. Data only: looknfeel.lua loads it +-- ~/.config/nerdos/hypr/user.lua instead. Data only: looknfeel.lua loads it -- in an empty environment and uses nothing but the colours below. """ @@ -106,7 +106,7 @@ def reload(tokens, directory): # compositor start reads the file. if not os.environ.get('HYPRLAND_INSTANCE_SIGNATURE'): return - code = f'cybexos_system_theme({lua_string(os.path.join(directory, FILE))})' + code = f'nerdos_system_theme({lua_string(os.path.join(directory, FILE))})' try: result = subprocess.run(['hyprctl', 'eval', code], capture_output=True, text=True, timeout=EVAL_TIMEOUT, check=False) diff --git a/roles/desktop/files/quickshell/scripts/theme_hyprlock.py b/desktop/quickshell/scripts/theme_hyprlock.py similarity index 94% rename from roles/desktop/files/quickshell/scripts/theme_hyprlock.py rename to desktop/quickshell/scripts/theme_hyprlock.py index ba557473..eb168bdc 100644 --- a/roles/desktop/files/quickshell/scripts/theme_hyprlock.py +++ b/desktop/quickshell/scripts/theme_hyprlock.py @@ -5,8 +5,8 @@ wallpaper. The clock, the date and the field grow with the shell's text (theme_tokens.text_scale), and so do the gaps between them, so a larger text size never makes them overlap. -`cybexos-runtime exec hyprlock` prefers this file to the vendor one unless the -user has their own ~/.config/cybexos/hypr/hyprlock.conf, and only while its +`nerdos-runtime exec hyprlock` prefers this file to the vendor one unless the +user has their own ~/.config/nerdos/hypr/hyprlock.conf, and only while its first line is HEADER, so a file this module did not write is never used. The wallpaper is blurred and covered by a scrim of the base colour. The blur @@ -23,8 +23,8 @@ from theme_tokens import contrast, ensure_contrast, mix, rgba, scaled NAME = 'hyprlock' -# The resolver compares the first line verbatim (assets/scripts/cybexos-runtime). -HEADER = '# CybexOS lock screen, rendered by the system theme (theme_hyprlock.py).' +# The resolver compares the first line verbatim (assets/scripts/nerdos-runtime). +HEADER = '# NerdOS lock screen, rendered by the system theme (theme_hyprlock.py).' # Scrim opacity: the least in SCRIM_STEPS that keeps the body text at 4.5:1 # over a pure white or black wallpaper. The floor keeps a busy wallpaper calm. @@ -105,7 +105,7 @@ def size(value): lines = [ HEADER, - '# Edits are overwritten; ~/.config/cybexos/hypr/hyprlock.conf replaces it.', + '# Edits are overwritten; ~/.config/nerdos/hypr/hyprlock.conf replaces it.', f'$font = {tokens["font"]["ui"]}', '', 'general {', @@ -172,7 +172,7 @@ def size(value): '}', '', # The fingerprint prompt: empty, and so invisible, unless - # cybexos-runtime turned on hyprlock's fprintd check and a reader + # nerdos-runtime turned on hyprlock's fprintd check and a reader # with an enrolled finger is ready (docs/security.md). 'label {', ' monitor =', diff --git a/roles/desktop/files/quickshell/scripts/theme_kitty.py b/desktop/quickshell/scripts/theme_kitty.py similarity index 95% rename from roles/desktop/files/quickshell/scripts/theme_kitty.py rename to desktop/quickshell/scripts/theme_kitty.py index 6d1e8132..887c0b1e 100644 --- a/roles/desktop/files/quickshell/scripts/theme_kitty.py +++ b/desktop/quickshell/scripts/theme_kitty.py @@ -1,8 +1,8 @@ """System theme target: kitty. Renders kitty.conf, the terminal's font size and colours, into the theme -state directory. The vendor fragment (roles/dotfiles/files/kitty.conf, -installed as ~/.config/kitty/cybexos.conf) carries the same keys for the +state directory. The vendor fragment (desktop/defaults/kitty.conf, +installed as ~/.config/kitty/nerdos.conf) carries the same keys for the default dark theme as a fallback and includes this file after them, so these values win once the shell has exported its appearance. The font family and opacity stay in the vendor fragment. @@ -21,7 +21,7 @@ HEADER = ( '# Generated by theme-apply.py (scripts/theme_kitty.py) from the shell\'s\n' '# appearance tokens. Rewritten whenever the appearance changes: put colour\n' - '# or font_size overrides in ~/.config/kitty/kitty.conf after the CYBEXOS\n' + '# or font_size overrides in ~/.config/kitty/kitty.conf after the NERDOS\n' '# MANAGED INCLUDE block instead.\n' ) # The vendor fragment's size, in points, which kitty keeps at the shell's @@ -95,7 +95,7 @@ def reload(tokens, directory): GUI_PATTERN], stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=RELOAD_TIMEOUT) except FileNotFoundError: - # procps-ng ships pkill on every Fedora install, so a PATH without it + # procps-ng ships pkill on every NerdOS install, so a PATH without it # is a bare environment (the driver tests run with an empty one), not # a desktop. New kitty windows read the file regardless. return diff --git a/roles/desktop/files/quickshell/scripts/theme_packs.py b/desktop/quickshell/scripts/theme_packs.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/theme_packs.py rename to desktop/quickshell/scripts/theme_packs.py index 22737f4c..cdba43e4 100644 --- a/roles/desktop/files/quickshell/scripts/theme_packs.py +++ b/desktop/quickshell/scripts/theme_packs.py @@ -4,8 +4,8 @@ image it names (PNG, JPEG or WebP). Nothing in a theme is executed or interpreted beyond the schema below. Built-in themes ship read-only in the shell runtime (`themes/` beside `scripts/`); user themes live in the -user-owned theme root, `$CYBEXOS_THEME_ROOT` (default -`~/.local/share/cybexos/themes`), which installs and updates never prune +user-owned theme root, `$NERDOS_THEME_ROOT` (default +`~/.local/share/nerdos/themes`), which installs and updates never prune (docs/architecture/ownership.md). theme.json, schema 1: @@ -27,7 +27,7 @@ Applying a theme is one settings change: settings_patch() maps it to the shell.json keys that Settings → Appearance (Common/Themes.qml) and -`cybex theme apply` set together. That mapping exists only here. +`nerd theme apply` set together. That mapping exists only here. """ from datetime import datetime, timezone import importlib.machinery @@ -345,11 +345,11 @@ def builtin_root(): def user_root(): - override = os.environ.get('CYBEXOS_THEME_ROOT') + override = os.environ.get('NERDOS_THEME_ROOT') if override: return Path(override) data = os.environ.get('XDG_DATA_HOME') or os.path.join(os.path.expanduser('~'), '.local/share') - return Path(data) / 'cybexos/themes' + return Path(data) / 'nerdos/themes' def _read_regular(path, limit): @@ -422,7 +422,7 @@ def catalogue(builtin=None, user=None): except ThemeError as error: invalid.append({'id': entry.name, 'error': str(error)}) builtin_ids = {theme['id'] for theme in themes} - themes.sort(key=lambda theme: (theme['id'] != 'cybexos', theme['name'].lower())) + themes.sort(key=lambda theme: (theme['id'] != 'nerdos', theme['name'].lower())) personal = [] for entry in _theme_dirs(user): if entry.name in builtin_ids: @@ -444,7 +444,7 @@ def find(theme_id, builtin=None, user=None): for entry in listing['invalid']: if entry['id'] == theme_id: raise ThemeError(f'theme {theme_id} is not usable: {entry["error"]}') - raise ThemeError(f'no theme called {theme_id}; see `cybex theme list`') + raise ThemeError(f'no theme called {theme_id}; see `nerd theme list`') def summary(theme): @@ -468,7 +468,7 @@ def expand_home(path): def settings_path(): config = os.environ.get('XDG_CONFIG_HOME') or os.path.join(os.path.expanduser('~'), '.config') - return Path(config) / 'cybexos/shell.json' + return Path(config) / 'nerdos/shell.json' def read_settings(path=None): @@ -547,7 +547,7 @@ def resolve(theme, directory=None): def _settings_store(): path = Path(__file__).resolve().parent / 'settings-store' - loader = importlib.machinery.SourceFileLoader('cybexos_settings_store', str(path)) + loader = importlib.machinery.SourceFileLoader('nerdos_settings_store', str(path)) spec = importlib.util.spec_from_loader(loader.name, loader) module = importlib.util.module_from_spec(spec) loader.exec_module(module) @@ -581,7 +581,7 @@ def persist(patch, path=None): raise ThemeError(f'{path} cannot be read: {error}') from None stored = current.get('v') if isinstance(stored, (int, float)) and not isinstance(stored, bool) and stored > version: - raise ThemeError(f'{path} was saved by a newer CybexOS; apply the theme from Settings instead') + raise ThemeError(f'{path} was saved by a newer NerdOS; apply the theme from Settings instead') # The file keeps its schema version: an older file is still migrated by # the shell, which reads these keys the same way in every schema. candidate = {**current, **patch} diff --git a/roles/desktop/files/quickshell/scripts/theme_tokens.py b/desktop/quickshell/scripts/theme_tokens.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/theme_tokens.py rename to desktop/quickshell/scripts/theme_tokens.py index d953706e..27923f55 100644 --- a/roles/desktop/files/quickshell/scripts/theme_tokens.py +++ b/desktop/quickshell/scripts/theme_tokens.py @@ -138,13 +138,13 @@ def scaled(value, tokens): def state_dir(): - """Where rendered files live: ~/.local/state/cybexos/theme.""" - override = os.environ.get('CYBEXOS_THEME_STATE_DIR') + """Where rendered files live: ~/.local/state/nerdos/theme.""" + override = os.environ.get('NERDOS_THEME_STATE_DIR') if override: return override base = os.environ.get('XDG_STATE_HOME') or os.path.join(os.path.expanduser('~'), '.local/state') - return os.path.join(base, 'cybexos/theme') + return os.path.join(base, 'nerdos/theme') # ---- colour arithmetic ------------------------------------------------------ diff --git a/roles/desktop/files/quickshell/scripts/user-plugins.py b/desktop/quickshell/scripts/user-plugins.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/user-plugins.py rename to desktop/quickshell/scripts/user-plugins.py index 308626b4..5df65feb 100644 --- a/roles/desktop/files/quickshell/scripts/user-plugins.py +++ b/desktop/quickshell/scripts/user-plugins.py @@ -32,14 +32,14 @@ def valid_id(plugin_id: str) -> bool: def roots() -> tuple[Path, Path, Path]: home = Path.home() - config = Path(os.environ.get("CYBEXOS_USER_CONFIG_ROOT") or - Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") / "cybexos") - packages = Path(os.environ.get("CYBEXOS_PLUGIN_ROOT") or + config = Path(os.environ.get("NERDOS_USER_CONFIG_ROOT") or + Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") / "nerdos") + packages = Path(os.environ.get("NERDOS_PLUGIN_ROOT") or Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / - "cybexos/plugins") + "nerdos/plugins") # Persistent plugin data is not disposable updater/diagnostic state. The # uninstaller may remove the latter while retaining user customizations. - state = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / "cybexos/plugin-data" + state = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share") / "nerdos/plugin-data" return config / "plugins.json", packages, state @@ -69,7 +69,7 @@ def preferences(path: Path) -> dict: # second copy of the built-in widget, so it is listed with this reason and # never loaded; its files and preferences are left for the user to remove. BUILT_IN = { - "digitalpals.model-usage": "Model Usage is built into CybexOS. Add it from " + "digitalpals.model-usage": "Model Usage is built into NerdOS. Add it from " "Settings → Bar, then remove this plugin.", } @@ -320,12 +320,12 @@ def apply_layout(registry: dict, layout: object, packages: Path) -> None: raise ValueError("instances must be an object") instances = {} for section, order, item in rows: - name = item.get("__cybexInstance", "") + name = item.get("__nerdosInstance", "") if not name and len(rows) > 1: name = "instance" + str(len(instances) + 1) if name and (not multiple or not valid_id(name) or name in instances): raise ValueError("Invalid or duplicate widget instance name") - settings = {key: data for key, data in item.items() if key not in ("id", "__cybexInstance")} + settings = {key: data for key, data in item.items() if key not in ("id", "__nerdosInstance")} if name: previous = previous_instances.get(name, {}) if not isinstance(previous, dict): @@ -362,7 +362,7 @@ def edit_layout(config: Path, packages: Path, state: Path, action: str, plugin_i for widget in info["widgets"]: if widget["enabled"] and not widget["error"] and widget["format"] == "omarchy": layout[widget["section"]].append({**widget["settings"], "id": widget["id"], - "__cybexInstance": widget.get("instanceName", "")}) + "__nerdosInstance": widget.get("instanceName", "")}) selector = payload.get("selector", {}) if action == "set" else payload if not isinstance(selector, dict): raise ValueError("Invalid selector") @@ -387,7 +387,7 @@ def edit_layout(config: Path, packages: Path, state: Path, action: str, plugin_i if action == "set": if not isinstance(payload.get("key"), str) or "value" not in payload: raise ValueError("Setting mutation needs key and value") - if payload["key"] in ("id", "__cybexInstance"): + if payload["key"] in ("id", "__nerdosInstance"): raise ValueError("Cannot change widget identity") found[2][payload["key"]] = payload["value"] else: @@ -485,7 +485,9 @@ def shell_stamp(pid: str | int) -> str: """The start time of `pid` if it is a running shell, else an empty string.""" try: proc = Path(f"/proc/{int(pid)}") - if proc.joinpath("comm").read_text().strip() not in ("qs", "quickshell"): + # Nixpkgs' Qt wrapper execs .quickshell-wrapped; Linux truncates comm + # to 15 bytes. It is still the upstream Quickshell process. + if proc.joinpath("comm").read_text().strip() not in ("qs", "quickshell", ".quickshell-wra"): return "" return proc.joinpath("stat").read_text().rsplit(")", 1)[1].split()[19] except (OSError, ValueError, IndexError): @@ -594,7 +596,7 @@ def main() -> int: instance.add_argument("--width", type=int) instance.add_argument("--order", type=int) instance.add_argument("--remove", action="store_true") - bar = commands.add_parser("bar", help="Select a replacement bar, or native to restore Cybex") + bar = commands.add_parser("bar", help="Select a replacement bar, or native to restore NerdOS") bar.add_argument("id") bar.add_argument("--position", choices=("top", "bottom", "left", "right")) disable = commands.add_parser("disable", help="Disable a widget, retaining its files and settings") @@ -611,7 +613,7 @@ def main() -> int: with locked(config): result = scan(config, packages, state) if args.live: - cache = Path(os.environ["XDG_RUNTIME_DIR"]) / "cybex-plugin-code" + cache = Path(os.environ["XDG_RUNTIME_DIR"]) / "nerd-plugin-code" cache.mkdir(mode=0o700, exist_ok=True) for old in cache.iterdir(): if not old.is_dir() or old.is_symlink(): diff --git a/desktop/quickshell/scripts/wallpaper-download.py b/desktop/quickshell/scripts/wallpaper-download.py new file mode 100644 index 00000000..c6e2353d --- /dev/null +++ b/desktop/quickshell/scripts/wallpaper-download.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Download one Wallhaven wallpaper into the wallpaper folder. + +usage: wallpaper-download.py URL DIRECTORY EXPECTED_BYTES + +Prints "progress N" (whole percent) while the image arrives, then exactly one +of "done PATH" or "failed CODE"; the reason for a failure goes to stderr. The +codes are the keys of WallhavenHelpers.DOWNLOAD_ERRORS. + +Only https://w.wallhaven.cc/full/ images are fetched, and the saved name comes +from that validated URL. The bytes land in a hidden .part file beside the +destination and are renamed into place only once their length and signature +check out, so the folder never shows a half-written or non-image file, and a +cancelled download (SIGTERM) leaves nothing behind. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import signal +import sys +from typing import Callable +import urllib.error +import urllib.request +from urllib.parse import urlsplit + + +HOST = "w.wallhaven.cc" +PATH = re.compile(r"^/full/([a-z0-9]{2})/(wallhaven-([a-z0-9]{1,16})\.(jpg|png))$") +TYPES = {"jpg": "image/jpeg", "png": "image/png"} +SIGNATURES = {"jpg": b"\xff\xd8\xff", "png": b"\x89PNG\r\n\x1a\n"} +# Wallhaven's upload limit is well under this; it bounds disk use if the +# listing's size is wrong or the server misbehaves. +MAX_BYTES = 64 * 1024 * 1024 +CHUNK = 64 * 1024 +TIMEOUT = 30 +USER_AGENT = "NerdOS-wallpaper-picker/1" + + +class Failure(Exception): + def __init__(self, code: str, detail: str) -> None: + super().__init__(detail) + self.code = code + + +def validate(url: str) -> tuple[str, str]: + """Returns (file name, extension) for a Wallhaven full-size image URL.""" + parts = urlsplit(url) + if (parts.scheme != "https" or parts.netloc != HOST or parts.query + or parts.fragment): + raise Failure("url", f"not a Wallhaven image URL: {url}") + match = PATH.match(parts.path) + if not match or not match.group(3).startswith(match.group(1)): + raise Failure("url", f"not a Wallhaven image path: {parts.path}") + return match.group(2), match.group(4) + + +def emit(line: str) -> None: + print(line, flush=True) + + +def open_url(url: str): + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + return urllib.request.urlopen(request, timeout=TIMEOUT) + + +def download(url: str, directory: Path, expected: int, + opener: Callable[[str], object] = open_url) -> Path: + name, extension = validate(url) + if not 0 < expected <= MAX_BYTES: + raise Failure("size", f"unexpected size {expected}") + try: + directory.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise Failure("write", str(error)) from error + target = directory / name + # A file already saved from an earlier pick is used again as is. + try: + if target.is_file() and target.stat().st_size == expected: + return target + except OSError as error: + raise Failure("write", str(error)) from error + + temporary = directory / f".{name}.{os.getpid()}.part" + try: + # 0o666 leaves the permissions to the caller's umask, as for any + # other file the shell writes. + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666) + except OSError as error: + raise Failure("write", str(error)) from error + try: + with os.fdopen(descriptor, "wb") as output: + try: + response = opener(url) + except urllib.error.HTTPError as error: + raise Failure("http", f"HTTP {error.code}") from error + except (urllib.error.URLError, OSError) as error: + raise Failure("network", str(error)) from error + with response: + status = getattr(response, "status", 200) + if status != 200: + raise Failure("http", f"HTTP {status}") + content_type = (response.headers.get("Content-Type") or "").split(";")[0].strip() + if content_type != TYPES[extension]: + raise Failure("type", f"content type {content_type!r}") + length = response.headers.get("Content-Length") + if length is not None and (not length.isdigit() or int(length) != expected): + raise Failure("size", f"content length {length}, expected {expected}") + received = 0 + reported = -1 + head = b"" + while True: + try: + chunk = response.read(CHUNK) + except OSError as error: + raise Failure("network", str(error)) from error + if not chunk: + break + received += len(chunk) + if received > expected: + raise Failure("size", f"more than {expected} bytes") + if len(head) < 8: + head += chunk[:8 - len(head)] + try: + output.write(chunk) + except OSError as error: + raise Failure("write", str(error)) from error + percent = received * 100 // expected + if percent >= reported + 5 or percent == 100: + reported = percent + emit(f"progress {percent}") + if received != expected: + raise Failure("size", f"received {received} of {expected} bytes") + if not head.startswith(SIGNATURES[extension]): + raise Failure("type", "file signature does not match its type") + try: + output.flush() + os.fsync(output.fileno()) + except OSError as error: + raise Failure("write", str(error)) from error + try: + os.replace(temporary, target) + except OSError as error: + raise Failure("write", str(error)) from error + return target + finally: + temporary.unlink(missing_ok=True) + + +def terminate(signum, frame) -> None: + # Unwinds through download()'s finally, which removes the .part file. + raise Failure("interrupted", f"signal {signum}") + + +def main(argv: list[str]) -> int: + if len(argv) != 4 or not argv[3].isdigit(): + print("usage: wallpaper-download.py URL DIRECTORY EXPECTED_BYTES", file=sys.stderr) + return 2 + signal.signal(signal.SIGTERM, terminate) + try: + target = download(argv[1], Path(argv[2]).expanduser(), int(argv[3])) + except Failure as failure: + print(f"wallpaper download failed: {failure}", file=sys.stderr) + emit(f"failed {failure.code}") + return 1 + emit(f"done {target}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/roles/desktop/files/quickshell/scripts/wallpaper-thumbnail.py b/desktop/quickshell/scripts/wallpaper-thumbnail.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/wallpaper-thumbnail.py rename to desktop/quickshell/scripts/wallpaper-thumbnail.py diff --git a/desktop/quickshell/shell.qml b/desktop/quickshell/shell.qml new file mode 100644 index 00000000..fd31cd9a --- /dev/null +++ b/desktop/quickshell/shell.qml @@ -0,0 +1,441 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Wayland +import "Bar" +import "Common" +import "Common/DockHelpers.js" as DockHelpers + +// The static linter cannot resolve Quickshell's private PostReloadHook base from the +// installed qmltypes. The shell does not instantiate it; importing Quickshell +// nevertheless makes the tool inspect that private base. +// qmllint disable import + +ShellRoot { + id: shell + + SettingsWindow {} + SoftwareWindow {} + + IpcHandler { + target: "software" + function open(): void { Software.show(); } + function close(): void { Software.close(); } + } + + // Hyprland dispatches this in-process. Super+Space no longer waits for a + // new `qs ipc` client process to start and connect before opening. + GlobalShortcut { + appid: "quickshell" + name: "launcherToggle" + description: I18n.tr("Toggle the application launcher") + onPressed: Launcher.toggle() + } + + // Super+Tab, in-process like the launcher. The `overview` IPC target in + // OverviewWindow serves the touchpad gesture and scripts. + GlobalShortcut { + appid: "quickshell" + name: "overviewToggle" + description: I18n.tr("Toggle the window overview") + onPressed: Overview.toggle() + } + + // Super+I, in-process so a held key reports its release: a tap opens the + // assistant's box, holding it records a question that is sent on + // release (docs/assistant.md). + GlobalShortcut { + appid: "quickshell" + name: "assistantTalk" + description: I18n.tr("Ask the NerdOS Assistant; hold to talk") + onPressed: Assistant.shortcutPressed() + onReleased: Assistant.shortcutReleased() + } + + // `nerd assistant show|hide|toggle|ask` and scripts. + IpcHandler { + target: "assistant" + + function show(): void { + Assistant.show(Screens.focused ? Screens.focused.name : ""); + } + + function hide(): void { + Assistant.hide(); + } + + function toggle(): void { + Assistant.toggleShown(Screens.focused ? Screens.focused.name : ""); + } + + // Opens the box with the keyboard, as a tap of Super+I does. + function compose(): void { + Assistant.openComposer(true); + } + + function ask(text: string): void { + Assistant.openComposer(false); + Assistant.send(text); + } + + // Read-only state for diagnostics. + function status(): string { + return JSON.stringify({available: Assistant.available, shown: Assistant.shown, + connected: Assistant.connected, screen: Assistant.screenName, + agent: Assistant.agent, busy: Assistant.busy, + agentReady: Assistant.agentReady, setupRequired: Assistant.setupRequired, + authChecking: Assistant.authChecking, hasDraft: Assistant.draft.trim() !== "", + x: Settings.assistantX, y: Settings.assistantY, size: Theme.scaled(64), + turn: Assistant.turn ? Assistant.turn.status : "", + composing: Assistant.composing, listening: Assistant.listening}); + } + } + + // Popout IPC lives here rather than in Bar: the bar is instantiated + // once per output, and an IpcHandler target may only be registered + // once shell-wide. + IpcHandler { + target: "popouts" + + function toggle(name: string): void { + Popouts.toggle(name, undefined, undefined, + Screens.focused ? Screens.focused.name : ""); + } + + function open(name: string): void { + Popouts.openPanel(name, undefined, undefined, + Screens.focused ? Screens.focused.name : ""); + } + + function close(): void { + Popouts.close(); + } + } + + // Shell-wide because every output has a bar and an IpcHandler target may + // only be registered once. IPC opens on the focused output; pointer opens + // pass their own output through the bar instead. + IpcHandler { + target: "settings" + + function toggle(): void { + Settings.togglePanel(undefined, + Screens.focused ? Screens.focused.name : ""); + } + + function open(page: string): void { + Settings.showPanel(page, + Screens.focused ? Screens.focused.name : ""); + } + + function close(): void { + Settings.closePanel(); + } + + // Read-only effective preferences for same-source installation audits. + function values(): string { return JSON.stringify(Settings.snapshot()); } + + // Read-only lifecycle diagnostics; no device or account metadata. + function status(): string { + const services = {}; + for (const name of ["sound", "network", "accounts", "input", "region"]) { + const service = SystemSettings[name]; + services[name] = {loaded: service.loaded, busy: service.busy, + loading: service.loading, watchers: service.watchers, + watching: service.watching, failed: service.error !== ""}; + } + return JSON.stringify({page: Settings.page, open: Settings.panelOpen, services: services}); + } + } + + // Wallpaper calls for the welcome window: its row of popular Wallhaven + // images (popular, results, pick), the wallpaper folder as the offline + // fallback (list, set), and the Settings Online view (browse). The Online + // singleton is created by the first call, not at startup. + IpcHandler { + target: "wallpaper" + + function browse(): void { + OnlineWallpapers.view = "online"; + Settings.showPanel("wallpaper", + Screens.focused ? Screens.focused.name : ""); + } + + // Starts the default Popular listing unless it is already loaded. + function popular(): void { + OnlineWallpapers.showPopular(); + } + + function results(): string { + return JSON.stringify(OnlineWallpapers.state()); + } + + // Downloads (if needed) and applies a listed result. + function pick(id: string): string { + return OnlineWallpapers.pick(id) ? "ok" : "unknown"; + } + + function list(): string { + return JSON.stringify({dir: Settings.wallDir, current: Settings.wall, + files: Wallpaper.files.map(file => Wallpaper.basename(file))}); + } + + function set(name: string): string { + return Wallpaper.setByName(name) ? "ok" : "unknown"; + } + + // Read-only diagnostics of the Online view's search and download. + function status(): string { + return JSON.stringify({view: OnlineWallpapers.view, + results: OnlineWallpapers.results.length, + page: OnlineWallpapers.page, lastPage: OnlineWallpapers.lastPage, + busy: OnlineWallpapers.busy, error: OnlineWallpapers.error, + downloading: OnlineWallpapers.downloadingId, + downloadError: OnlineWallpapers.downloadError}); + } + } + + // The desktop outside the shell (terminal, compositor, GTK, lock screen) + // follows the shell's appearance on its own; `apply` re-renders and + // reloads every target even when nothing changed, for an application + // that missed a reload or a target file edited by hand. + IpcHandler { + target: "theme" + + function apply(): void { + SystemTheme.apply(true); + } + + function status(): string { + return SystemTheme.status(); + } + + // `nerd theme apply`: a named theme its helper has validated and + // resolved (scripts/theme-packs.py), checked again here before it + // changes anything. Returns "ok" or "error: ". + function use(theme: string): string { + return Themes.use(theme); + } + + // Re-reads the theme folders after `nerd theme install` or `remove`. + function refresh(): void { + Themes.refresh(); + } + } + + // Pinged by brightness-control after brightnessctl runs; volume needs + // no IPC because the OSD watches Pipewire directly. + IpcHandler { + target: "osd" + + function brightness(): void { + Osd.brightnessChanged(); + } + } + + // Timer services call this after delivering a reminder so the indicator + // and an open manager update without waiting for their polling fallback. + IpcHandler { + target: "reminders" + + function refresh(): void { + Reminders.refresh(); + } + } + + // Shell-wide session routes. The compatibility `power` call now targets + // the focused output's Control Panel; direct actions keep their names. + IpcHandler { + target: "session" + + function power(): void { + Session.closeKeys(); + Launcher.close(); + Popouts.toggle("control", undefined, undefined, + Screens.focused ? Screens.focused.name : ""); + } + + function keys(): void { + Session.toggleKeys(); + } + + function lock(): void { + Session.lock(); + } + + function close(): void { + Session.closeAll(); + if (Popouts.open && Popouts.currentName === "control") + Popouts.close(); + } + } + + // The launcher's "Check for updates" and scripts. `check` refreshes every + // source, as the panel's refresh button does (a run in progress is left + // alone); `open` shows the Updates panel on the focused output, which + // also brings a stale count up to date. + IpcHandler { + target: "updates" + + function check(): void { + Updates.check(); + } + + function open(): void { + Popouts.openPanel("updates", undefined, undefined, + Screens.focused ? Screens.focused.name : ""); + } + } + + // A configuration reload rebuilds Hyprland's Lua state; runtime choices + // the compositor cannot read back from a file are sent again. + Connections { + target: Hyprland + + function onRawEvent(event) { + if (event.name === "configreloaded") + Settings.compositorReloaded(); + } + } + + // Wallpaper on the background layer, one per output. The model also + // changes identity when a surviving output moves, forcing the layer + // surface to remap instead of retaining its old global coordinates. + Variants { + model: Screens.layerSurfaceModel + + PanelWindow { + id: wallpaperWindow + + required property var modelData + + screen: modelData.screen + anchors { + top: true + left: true + right: true + bottom: true + } + exclusionMode: ExclusionMode.Ignore + color: "#101116" + + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.namespace: "qs-wallpaper" + + Image { + anchors.fill: parent + source: Wallpaper.current !== "" ? Wallpaper.url(Wallpaper.current) : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + // Keep drawing the outgoing wallpaper until the next one has + // decoded, instead of flashing the window's fallback colour + // for the length of every asynchronous load (Qt 6.8+). + retainWhileLoading: true + cache: true + // screen is briefly null while an output is torn down; + // 0 falls back to the image's own size for that instant. + sourceSize.width: wallpaperWindow.screen + ? Math.ceil(wallpaperWindow.screen.width * wallpaperWindow.screen.devicePixelRatio) : 0 + sourceSize.height: wallpaperWindow.screen + ? Math.ceil(wallpaperWindow.screen.height * wallpaperWindow.screen.devicePixelRatio) : 0 + } + } + } + + // One permanently mapped bar per output. Binding each window to one + // output for its lifetime avoids layer-surface migration during focus + // changes and lets hotplug create or destroy just that output's bar. + Variants { + model: OmarchyPlugins.replacementActive ? [] : Screens.layerSurfaceModel + + Scope { + id: barScope + + required property var modelData + readonly property ShellScreen output: modelData ? modelData.screen : null + property string outputName: "" + + Component.onCompleted: outputName = output ? output.name : "" + Component.onDestruction: { + if (Popouts.open && Popouts.hostScreenName === outputName) + Popouts.close(); + } + + Bar { + id: bar + screen: barScope.output + visible: barScope.output !== null + } + + BarPopoutWindow { + bar: bar + screen: barScope.output + } + } + } + + // Floating workspaces get a dock automatically, independently per output. + // The saved dock preference additionally shows it in other layouts. + // With no eligible output, even the shared Dock model stays unconstructed. + Variants { + model: Screens.layerSurfaceModel.filter(output => { + const monitor = Hyprland.monitorFor(output.screen); + const active = monitor ? monitor.activeWorkspace : null; + return DockHelpers.showOnWorkspace(Settings.dock, active, + DesktopWorkspaces.workspace(active ? active.id : 0)); + }) + + DockWindow {} + } + + LauncherWindow {} + OverviewWindow {} + NetworkOverlayWindow {} + NotificationToasts {} + OsdWindow {} + ShortcutsOverlay {} + PolkitWindow {} + + // The webcam bubble exists only for a --webcam recording: the window, + // the camera and its device list are created when one starts and + // destroyed, releasing the camera, when it stops. + LazyLoader { + active: Recorder.webcamWanted + + WebcamOverlay {} + } + + // The assistant's character and box exist while it is out; its client + // singleton keeps the service connection either way. + LazyLoader { + active: Assistant.shown + + AssistantWindow {} + } + + // Reading a singleton's property is what constructs it. Notifications + // must start collecting, GitHub must start polling — GitHub + // also raises the toasts for watched repositories, which must not wait + // for the popover — and settings must load from session start rather than + // from the first popover open. Constructing them is cheap; the + // non-critical first fetches stagger themselves behind their own + // start-up timers (Reminders restore 8 s, Calendar 12 s, + // Updates 20 s) so a login does not run them all at once. + Component.onCompleted: { + void DesktopWorkspaces.ready; + void OmarchyPlugins.ready; + void Notifs.server; + void GitHub.pollEnabled; + void Calendar.enabled; + void Settings.loaded; + void SystemTheme.settled; + void Updates.total; + void Recorder.active; + void Dictation.state; + void Assistant.available; + void Reminders.count; + // A recovery boot must announce itself without waiting for Settings. + void Recovery.recoveryBoot; + } +} diff --git a/roles/desktop/files/quickshell/themes/catppuccin-latte/theme.json b/desktop/quickshell/themes/catppuccin-latte/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/catppuccin-latte/theme.json rename to desktop/quickshell/themes/catppuccin-latte/theme.json diff --git a/roles/desktop/files/quickshell/themes/catppuccin-mocha/theme.json b/desktop/quickshell/themes/catppuccin-mocha/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/catppuccin-mocha/theme.json rename to desktop/quickshell/themes/catppuccin-mocha/theme.json diff --git a/roles/desktop/files/quickshell/themes/everforest/theme.json b/desktop/quickshell/themes/everforest/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/everforest/theme.json rename to desktop/quickshell/themes/everforest/theme.json diff --git a/roles/desktop/files/quickshell/themes/gruvbox/theme.json b/desktop/quickshell/themes/gruvbox/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/gruvbox/theme.json rename to desktop/quickshell/themes/gruvbox/theme.json diff --git a/roles/desktop/files/quickshell/themes/kanagawa/theme.json b/desktop/quickshell/themes/kanagawa/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/kanagawa/theme.json rename to desktop/quickshell/themes/kanagawa/theme.json diff --git a/desktop/quickshell/themes/nerdos/theme.json b/desktop/quickshell/themes/nerdos/theme.json new file mode 100644 index 00000000..f29293b1 --- /dev/null +++ b/desktop/quickshell/themes/nerdos/theme.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "id": "nerdos", + "name": "NerdOS", + "mode": "dark", + "accent": "#d3d283", + "accentSource": "wallpaper", + "author": "NerdOS", + "license": "MIT", + "source": "https://github.com/DigitalPals/NerdOS", + "description": "The default look: accents from the wallpaper over warm charcoal" +} diff --git a/roles/desktop/files/quickshell/themes/nord/theme.json b/desktop/quickshell/themes/nord/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/nord/theme.json rename to desktop/quickshell/themes/nord/theme.json diff --git a/roles/desktop/files/quickshell/themes/rose-pine/theme.json b/desktop/quickshell/themes/rose-pine/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/rose-pine/theme.json rename to desktop/quickshell/themes/rose-pine/theme.json diff --git a/roles/desktop/files/quickshell/themes/tokyo-night/theme.json b/desktop/quickshell/themes/tokyo-night/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/tokyo-night/theme.json rename to desktop/quickshell/themes/tokyo-night/theme.json diff --git a/desktop/runtime-compatibility.json b/desktop/runtime-compatibility.json new file mode 100644 index 00000000..f0605562 --- /dev/null +++ b/desktop/runtime-compatibility.json @@ -0,0 +1,6 @@ +{ + "schema": 1, + "quickshell": ["0.3"], + "qt": ["6.11"], + "hyprland": ["0.56"] +} diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md index c556be96..113bdfdf 100644 --- a/docs/architecture/ownership.md +++ b/docs/architecture/ownership.md @@ -1,148 +1,36 @@ -# CybexOS ownership boundary - -CybexOS has two supported installation paths. ISO installations receive vendor -files through the `cybexos-desktop` RPM, installed under -`/usr/share/cybexos`; source-checkout installations use versioned releases and -may opt into a writable development checkout. In both paths, user preferences -and personal packages remain in user-owned locations. - -| Owner | Path | Update behavior | -| --- | --- | --- | -| Vendor, ISO | `/usr/share/cybexos/` | Owned by `cybexos-desktop`; replaced by RPM upgrades | -| Vendor, ISO | `~/.local/share/cybexos/runtime` | Compatibility symlink to `/usr/share/cybexos/runtime` | -| Vendor, source checkout | `~/.local/share/cybexos/runtime/` | Reconciled from the selected verified release | -| Vendor, source checkout | `~/.local/share/cybexos/releases/` and `current` | Staged and atomically selected by the source updater | -| User | `~/.config/cybexos/shell.json` | Shell preferences; preserved by package/release updates | -| User | `~/.config/cybexos/hypr/` | Optional `user.lua`, `hypridle.conf`, and `hyprlock.conf` overrides | -| User | `~/.config/cybexos/displays.json` | Settings → Displays choices per physical monitor | -| User | `~/.config/cybexos/fusebox/` | The Fusebox widget's private management key (0600); written only from its settings | -| User | `~/.local/share/cybexos/themes/` | User theme packages (`cybex theme install`, Settings → Appearance → Save current as theme); not reconciled or pruned | -| User | `~/.local/share/cybexos/plugins/` | API 1 widget packages; not reconciled or pruned | -| User | `~/.config/cybexos/plugins.json` | Widget enablement and preferences; not written by Ansible | -| User | `~/.local/share/cybexos/plugin-data/` | Persistent widget data; retained on update and uninstall | -| State | `~/.local/state/cybexos/` | Health, update, migration, and shell runtime state | -| State, machine | `/var/lib/cybexos/` | Reconciliation state, backups, and hardware setup status | - -On the RPM path, package upgrades defer versioned account and machine policy -updates to `cybexos-reconcile.service` and its timer. Reconciliation preserves -existing edits by backing up files before updating them; inspect status with -`sudo /usr/libexec/cybexos-reconcile --status` and request a retry with -`sudo /usr/libexec/cybexos-reconcile --retry`. The user initialization payload -uses versioned defaults, separately from unversioned tool-seed data. Hardware -setup status is recorded at `/var/lib/cybexos/hardware-status.json` as -`pending`, `completed`, or `failed`; a pending camera setup resumes after a -same-kernel reboot. The welcome window displays this status. On both paths the -NVIDIA role records `/var/lib/cybexos/nvidia-status.json` (world-readable) and, -while Secure Boot key enrollment is pending, the one-time password in -`/var/lib/cybexos/nvidia-mok.json` (root and `wheel` only); it owns -`/etc/cybexos/nvidia-session.env` and `/usr/local/libexec/cybexos-nvidia`. - -On the source-checkout path, the session starts Hyprland with the vendor entry -point. Vendor modules load first, then saved Settings → Displays choices, and -`~/.config/cybexos/hypr/user.lua`, when present, loads last. Bindings it adds -with a `Group: Label` description appear in the Super+K cheatsheet -([keyboard shortcuts](../keyboard-shortcuts.md)). The idle and lock services -prefer their same-named user configuration files and otherwise use vendor -defaults. User overrides are not silently replaced by release updates. - -Quickshell starts with an explicit `qs -p` path. The legacy -`~/.config/quickshell` and `~/.config/hypr` trees are not runtime inputs after -the transition. On the first layered convergence, their entries are classified -against the previously active release. Customized and unrecognized entries are -copied into a timestamped migration directory, a JSON report is written, and -the legacy trees themselves are left untouched. No ambiguous QML or Lua is -translated automatically. - -## Development source switch - -`cybex dev enable /absolute/path/to/checkout` selects live Quickshell sources -and static Hyprland modules from a validated, user-owned Git checkout on the -source-checkout path. Rendered machine modules continue to come from the -installed runtime. The command records only the canonical path and reloads -managed desktop components; it never fetches, resets, merges, commits, or -writes inside the checkout. - -The Hyprland startup hook must also work when the development checkout is -selected on an ISO installation. Checkout installs place the ordered session -starter in `/usr/local/libexec`; ISO/RPM installs place it in `/usr/libexec`. -`autostart.lua` resolves an executable helper at login, including when the -checkout is loaded verbatim without the image packager's path rewriting. -Otherwise Hyprland can start with `hyprland-session.target` inactive, leaving -Quickshell, wallpaper, idle handling and desktop portals unavailable. The -session startup fixtures exercise checkout, packaged and ISO development -layouts in both source gates. - -Use `cybex dev status` to show the active source and `cybex dev disable` to -return to the verified vendor runtime. Internet updates continue to stage and -activate releases while development mode is on; they do not modify the selected -checkout or user-owned paths. ISO installations use RPM updates instead of this -release switch. - -## Enforcement rules - -- ISO package upgrades own files under `/usr/share/cybexos`; do not edit those - deployed vendor files in place. -- Source deployment may prune only its vendor-owned runtime root. -- Normal convergence must not copy, template, link, or remove children below - user-owned roots, except to create an absent directory or perform an - explicitly non-overwriting legacy migration. -- Uninstall removes vendor runtime and integration artifacts, not user-owned - Quickshell, Hyprland, theme, or plugin trees. -- Ownership-preservation checks simulate updates with user data sentinels. - -## Customization compatibility - -File ownership and runtime compatibility are separate requirements. The -[user widget API](user-widgets.md) gives personal QML a versioned interface, -independent preferences, and real-engine compatibility fixtures. Agents use -that interface for personal widgets; a source checkout is for changing the -vendor implementation. A future refactor must retain supported API adapters. - -The distro-wide target is vendor defaults followed by explicit user choices. -New defaults apply to settings without an explicit choice; they must not erase -user choices even when those choices equal an old default. Migration must -preserve unknown fields, retain a recoverable original, and avoid downgrading -data on rollback. A new API or schema needs a compatibility plan and -upgrade/rollback fixtures before it is released. - -Shell settings use a sparse schema-29 document (the version authority is -[`SettingsHelpers.js`](../../roles/desktop/files/quickshell/Common/SettingsHelpers.js)): the presence of a known -key records an explicit choice, including a choice equal to the current -default. Reset removes the override; Undo restores its ownership as well as -its value. Legacy stored values are conservatively treated as explicit. Visual -redesigns no longer infer an untouched preference from equality with an old -default. Unknown JSON fields survive edits and resets, and a newer schema is -read-only on an older shell. - -The asynchronous settings writer merges independent external edits, rejects -conflicting writes, and confirms fsync and atomic publication before reporting -success. A rejected edit is retained in a `shell.json.conflict-*` sidecar before -the form reloads the external values. The first schema migration retains the -exact original in `shell.json.before-migration-*`. These files live beside the -user's settings and are retained for recovery; the shell never prunes them. -The production Qt document component has real-engine lifecycle tests for -queued changes, retries and unknown data, alongside filesystem transaction tests. - -Personal application stores (Fastfetch, Voxtype, Oh My Posh, MIME associations, -XDG directories and npm configuration) are seeded only when absent. Shared -Fish, Kitty, Git and SSH fragments use the same ownership ledger on checkout -and ISO paths. A fragment advances only if it still matches its last installed -bytes; conflicting edits, symlinks and explicit deletions survive. Adopted -bytes are backed up before replacement. Unknown legacy fragments remain -user-owned instead of being guessed at from their filename. - -Managed includes follow each application's precedence: Git/Kitty defaults -come first, while SSH fallbacks come last in an explicit `Host *` scope. -Git credential helpers accumulate instead of overriding, so vendor credential -helpers are omitted when personal configuration provides its own chain. The -SSH vendor fragment lives outside `.ssh/config.d` so wildcard includes cannot -accidentally give it priority over a personal host. Moving an old unedited -include retains its original file; edited include blocks are left intact. - -Remaining release coverage should exercise service overrides, optional package -and service additions, and supported previous releases across failure and -rollback, beyond file sentinels. Application ownership is scoped to these -managed fragments; independent application databases remain the application's -responsibility. - -The widget contract does not imply ownership of unrelated application state. +# Ownership and installation contract + +`nix/modules` is authoritative for fresh NixOS installations. The ISO imports +these modules plus a live-only profile; both checkout entry points invoke that +ISO's `nerdos-install`. They do not provision another operating system. + +| Layer | Locations | Update semantics | +|---|---|---| +| Vendor | Nix store; `/etc/nerdos/runtime`; system-profile binaries | Immutable, selected by authenticated NerdOS input | +| Machine/personal declarations | `/etc/nixos/{flake.nix,flake.lock,machine.nix,local/}` | Root-owned working copy; staged separately; vendor defaults use Nix module priorities | +| Personal desktop | `~/.config/nerdos/{shell.json,plugins.json,displays.json,hypr/user.lua}` | Live preferences; never reconciled by vendor updates | +| Personal code | `~/.local/share/nerdos/{plugins,themes}` | API 1 native/Omarchy packages, retained across system generations | +| Persistent state | Home; `/var/lib`; `/etc/NetworkManager/system-connections`; plugin-data | Independent of system generations; requires application-aware backup/migration | +| Secrets | `/var/lib/nerdos/secrets`; keyring; private runtime credentials | Root directory 0700/files 0600; no plaintext in store, source, argv or logs | +| Deployment records | `/var/lib/nerdos/transactions` | Private immutable source/lock snapshots, archive identities and GC roots | + +The machine's flake has one supported stable `nixpkgs` input. NerdOS follows it +and contributes configuration/assets, not a second host package universe. +Lanzaboote is a vendor dependency following the same Nixpkgs. Personal inputs +remain separately locked. Native camera source pins have a documented narrow +hardware reason and a removal/requalification policy. + +A stage copies the complete personal configuration, updates only selected +inputs, verifies lock ownership and publisher identities, archives inputs, +builds the full system and checks the candidate desktop. Apply requires the +reviewed ID/token and rejects intervening personal edits. Boot/login health +confirms a deployment; an interrupted activation restores coherent configuration +and boot selection. Working edits present at rollback are retained privately. + +Nix module list definitions merge; administrator normal definitions override +vendor `mkDefault` values. The primary user's shell uses priority 900 because +NixOS also supplies it with `mkDefault`; a normal administrator definition wins. +Use `mkForce` deliberately, not to hide conflicts. + +No updater changes `system.stateVersion`. Routine updates advance within 26.05; +a major release upgrade requires explicit edits and fresh qualification. diff --git a/docs/architecture/user-widgets.md b/docs/architecture/user-widgets.md index 87e334f3..aea5f0cd 100644 --- a/docs/architecture/user-widgets.md +++ b/docs/architecture/user-widgets.md @@ -25,18 +25,18 @@ Omarchy instances. Moving a widget preserves its settings and enablement. ## Storage and ownership Default paths below follow XDG config and data roots. The runtime -exports `CYBEXOS_PLUGIN_ROOT` and `CYBEXOS_USER_CONFIG_ROOT` for +exports `NERDOS_PLUGIN_ROOT` and `NERDOS_USER_CONFIG_ROOT` for the shell; the command helper honors these when present too. | Contents | Default path | Update/rollback behavior | | --- | --- | --- | -| Package source and assets | `~/.local/share/cybexos/plugins//` | Never reconciled with vendor files | -| Enablement, column, order, width, settings | `~/.config/cybexos/plugins.json` | Never written by deployment or shell-settings migrations | -| Plugin data | `~/.local/share/cybexos/plugin-data//` | Retained; plugin controls its own format | -| Loader and API adapter | `~/.local/share/cybexos/runtime/quickshell/` | Replaced by distro updates | +| Package source and assets | `~/.local/share/nerdos/plugins//` | Never reconciled with vendor files | +| Enablement, column, order, width, settings | `~/.config/nerdos/plugins.json` | Never written by deployment or shell-settings migrations | +| Plugin data | `~/.local/share/nerdos/plugin-data//` | Retained; plugin controls its own format | +| Loader and API adapter | `/etc/nerdos/runtime/quickshell/` | Replaced by distro updates | -Uninstall retains user widget packages and data. Disabling a widget changes -only its enablement flag. Existing releases before API 1 cannot display these +System updates and generation rollback retain user widget packages and data. +Disabling a widget changes only its enablement flag. Existing releases before API 1 cannot display these widgets, but their ownership boundary retains the files for re-enablement on a supporting release. Compatibility claims apply to releases supporting API 1. @@ -85,10 +85,10 @@ registry fields survive command writes. Corrupt registries and unsupported registry versions are reported and preserved; writes refuse to reset them. ```bash -cybex plugin list -cybex plugin enable personal.focus --width 120 --order 0 -cybex plugin set personal.focus label '"Deep work"' -cybex plugin disable personal.focus +nerd plugin list +nerd plugin enable personal.focus --width 120 --order 0 +nerd plugin set personal.focus label '"Deep work"' +nerd plugin disable personal.focus ``` `list` outputs JSON including disabled, missing, and incompatible packages. @@ -130,10 +130,10 @@ column; the default is right. The left and center areas each have 15% of the screen width; the right area has 25%, with room reserved for an overflow count. Excess widgets are hidden with a tooltip listing their names. Placement and width budgets are defined by -[`Bar.qml`](../../roles/desktop/files/quickshell/Bar/Bar.qml) and -[`UserWidgets.qml`](../../roles/desktop/files/quickshell/Bar/UserWidgets.qml); +[`Bar.qml`](../../desktop/quickshell/Bar/Bar.qml) and +[`UserWidgets.qml`](../../desktop/quickshell/Bar/UserWidgets.qml); registry validation lives in -[`user-plugins.py`](../../roles/desktop/files/quickshell/scripts/user-plugins.py). +[`user-plugins.py`](../../desktop/quickshell/scripts/user-plugins.py). Built-in drag ordering and custom shell popouts are not part of this plugin API. Use QtQuick content inside the supplied bounds. @@ -152,7 +152,7 @@ An incompatible plugin is preserved with its enabled preference intact so a future compatible host can load it again. Do not claim that merely retaining its files makes it functional on a host that does not support its API. -`tests/user-plugins.py`, included in `tests/run`, runs a fixed API 1 widget in +`tests/user-plugins.py`, run separately in CI, runs a fixed API 1 widget in the real Quickshell engine with its package outside the runtime. It replaces the runtime tree for simulated N, N+1, and rollback runs, checks package bytes and preferences, tests local QML imports, malformed/incompatible widgets, and @@ -167,7 +167,7 @@ On an installed desktop, follow `tests/lib/quickshell-live` for service/PID reconciliation and current-invocation journal checks. For an agent-ready example, see the installed -[user widget skill reference](../../agent-skills/cybexos/references/user-widgets.md). +[user widget skill reference](../../agent-skills/nerdos/references/user-widgets.md). ### Shared typography diff --git a/docs/assistant.md b/docs/assistant.md new file mode 100644 index 00000000..ed1b7e7e --- /dev/null +++ b/docs/assistant.md @@ -0,0 +1,226 @@ +# NerdOS Assistant + +The assistant is a round character that floats above every window. Ask it +about NerdOS by typing or by holding a key and talking; it answers in a small +box under itself. It can change desktop preferences, create personal widgets, +prepare system changes for review, write small programs and answer general +questions. The work is done by the coding agent the person already uses: +Claude Code or Codex. NerdOS never stores their credentials. + +The assistant is summoned, never proactive. It does not watch the screen, +listen or speak unless asked to for one question. + +## Using it + +- **Connect an account first.** When the selected agent is not signed in, + opening the assistant shows setup with **Sign in to Claude Code** and + **Sign in to Codex**. Each button opens that agent's own sign-in in your + chosen terminal and browser. The box checks for completion automatically; + **Check sign-in** also works after signing in elsewhere. Your draft stays + in the box until you send it. Missing agents link to Software. + Settings → Assistant also shows sign-in status. Automatic selection prefers + an installed, signed-in agent. +- **Show or hide** the character from its menubar button (the sparkles beside + the notifications) or with `nerd assistant toggle`. The button shows `…` + while a question is being worked on, `!` while one needs your answer and a + dot for a reply you have not seen. Drag the character anywhere, including to + another display. It stays where it was left, across sessions, and keeps out + of the way while game mode is on. +- **Ask** by hovering the character, or tap `Super+I`, then type and press + Enter. Hovering opens the box without taking the keyboard; clicking it or + the shortcut does. +- **Talk** by holding `Super+I` (or the microphone button) and releasing it to + send. The words are transcribed locally by Voxtype; no audio leaves the + computer. `Esc` or the cancel button discards a recording. +- **While it works** the character shows a spinning arc and a short line about + the current step. Clicking the character while it works tucks the box away; + a lime dot appears when the reply is ready. +- **Replies** stay under the character until closed. Copy, Reply (continue the + conversation), Speak, Undo (when it changed preferences or files), and + Continue in a terminal (opens the same Claude Code or Codex session in + Kitty, for longer coding work) appear under a reply. +- **From a terminal**, `nerd assistant ask "…"` asks the same assistant and + prints the reply; anything it needs approved still appears on screen. + `nerd assistant status` prints the service's state as JSON. +- **Screen context**, when enabled in Settings → Assistant, adds a screen + button to the box. It attaches one screenshot of the display the character is + on, taken with the character hidden, to that one question. The image is + deleted when the reply finishes. + +## What it may do without asking + +Every tool call the agent makes passes through the assistant's approval policy +(`desktop/assistant/nerdos_assistant/policy.py`): + +| Action | Decision | +| --- | --- | +| Reading files, searching, web search and fetch | Allowed | +| Read-only commands (`ls`, `cat`, `rg`, `jq`, `hyprctl -j …`, `nerd update status`, `nerd plugin list`, `systemctl --user status`, …) | Allowed | +| Editing desktop preferences (`~/.config/nerdos/…`) and personal plugins (`~/.local/share/nerdos/plugins/…`) | Allowed; the reply offers **Undo** | +| Staging a system change (`nerd update stage local`, `nerd software stage …`) | Allowed; administrator authentication is still requested by polkit | +| Applying a staged change (`nerd update apply …`) | A review card with the actual package changes; **Apply…** then asks for the administrator password | +| Vendor files (`/nix/store`, `/etc/nerdos`, `/run/current-system`) | Refused | +| `nixos-rebuild`, `sudo`, rebooting or powering off | Refused | +| Any other command or file edit | An approval card: Allow once, Allow for this conversation, or Deny | + +The person's own agent settings still apply on top: a rule in +`~/.claude/settings.json` that already allows a tool is honored by Claude +Code. Codex runs with a read-only sandbox and asks for every write or +escalation, which reaches the same cards. + +New widgets are created disabled. When a reply created one, **Add to bar** +enables it with `nerd plugin enable`. + +## Settings + +Settings → Assistant, stored in `~/.config/nerdos/shell.json`: + +| Key | Values | Default | +| --- | --- | --- | +| `assistantAgent` | `auto`, `claude`, `codex` | `auto` (the `nerd agent` default when it is Claude Code or Codex, else whichever is installed) | +| `assistantClaudeModel` | `default`, `fable`, `sonnet`, `opus` | `default` | +| `assistantCodexModel` | `default`, `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol` | `default` | +| `assistantSpeak` | spoken replies | `false` | +| `assistantScreenContext` | offer the screen button | `false` | +| `assistantCharacter` | `mascot`, `mark` | `mascot` | +| `assistantShown` | the character is on screen | `false` | +| `assistantScreen`, `assistantX`, `assistantY` | where it was left | primary display, top right | + +### The character + +`mascot` is the NerdOS glasses as a character, with eyes behind the lenses. +`mark` shows the unchanged logo, and only the ring around it shows what the +assistant is doing. + +The brand guide (`assets/brand/USAGE.md` §8) forbids eyes or faces in the +logo's lenses, and it still applies: the mascot is a separate character, not a +variant of the logo, and is used only as the assistant on screen. Its rules: + +- It is drawn from the master symbol's own geometry and colours + (`assets/brand/symbol/svg/nerdos-symbol-on-dark.svg`) on a charcoal disc; the + frames, the tape and its angle never change. +- The eyes are plain off-white pills behind the lenses, cropped by the frames. + They may move, blink, close to a curve when happy or asleep, and widen when + listening. No pupils, mouths, eyebrows, reflections or extra colours. +- Lime stays on the tape and the activity ring; amber marks only a question + waiting for an answer. +- Wherever the logo appears as the logo (the menubar's Control Center button, + the boot splash, documents), it is the unchanged mark. + +## Installation + +`nerdos.features.assistant` (default on) installs the service, Codex, and +Claude Code when proprietary applications are selected too. It does not need +the developer tooling feature. Voxtype, Pipewire and speech-dispatcher are part +of every installation. Turning the feature off removes the menubar button, +the shortcut and the service; nothing in `shell.json` is lost. + +## Architecture + +- `desktop/assistant/nerdos_assistant/` — the `nerdos-assistant` service, a + user unit (`nerdos-assistant.service`). It owns the agent processes so a + Quickshell restart, including one caused by the assistant's own changes, + does not end a turn. Only the standard library is used. + - `server.py` — the socket and the session state. + - `auth.py` — bounded, output-discarding `claude auth status` and + `codex login status` checks. No prompt or model request checks sign-in. + Login runs `claude auth login` or `codex login` in a terminal; + credentials remain with the provider CLI, outside the assistant and store. + - `claude.py` — Claude Code over `--input-format stream-json` with + `--permission-prompt-tool stdio`: every permission prompt arrives as a + `can_use_tool` control request and is answered by the policy or a card. + - `codex.py` — `codex app-server` (JSON-RPC over stdio): threads, turns, + and `item/*/requestApproval` server requests. + - `policy.py` — the table above, and the file snapshots behind Undo. + - `speech.py` — speech-dispatcher's SSIP socket, one sentence per message + so the reply can follow along. + - `prompt.py` — the instructions appended to the agent's own system + prompt. +- `desktop/quickshell/Common/Assistant.qml` — the shell's client: connection, + turn state, voice, screen capture and placement. +- `desktop/quickshell/AssistantWindow.qml` and `Assistant/` — the overlay + surface, the character, the composer, the working pill and the reply card. +- `desktop/quickshell/Bar/Modules/Assistant.qml` — the menubar button. + +State lives in `~/.local/state/nerdos/assistant/state.json` (the current +conversation id per agent and the last finished reply). Screenshots live in +`$XDG_RUNTIME_DIR/nerdos/assistant/` only for the question that used them. + +### Protocol + +`$XDG_RUNTIME_DIR/nerdos/assistant.sock` (directory `0700`) carries JSON-RPC 2.0, +one UTF-8 JSON object per line. Every connected client receives every +notification. Lines are limited to 4 MiB. + +Requests: + +| Method | Params | Result | +| --- | --- | --- | +| `hello` | `{client, protocol: 1}` | `{protocol, agents, turn, conversation, speech}` | +| `auth.status` | `{}` | `{agents}` — refresh installation/sign-in status | +| `auth.login` | `{agent: "claude" \| "codex"}` | `{argv, cwd}` — the agent's own login command for a terminal | +| `turn.start` | `{text, agent, model?, screenshot?, context?}` | `{turn}`; error `-32001` while a turn runs | +| `turn.stop` | `{}` | `{stopped}` | +| `approval.respond` | `{id, decision: "allow" \| "always" \| "deny"}` | `{ok}` | +| `turn.undo` | `{id}` | `{restored: [path], skipped: [path]}` | +| `conversation.reset` | `{}` | `{ok}` | +| `conversation.handoff` | `{}` | `{argv, cwd}` — the terminal command that resumes the session | +| `speech.say` | `{text, language?}` | `{id, sentences}` | +| `speech.stop` | `{}` | `{ok}` | + +`agents` is `{claude: {available, label, auth}, codex: {available, label, auth}}`. +`auth` is `ready`, `required`, `unknown` (the bounded status check failed), or +`missing`. Status output and credentials are never exposed through this +protocol or saved in assistant state. `turn.start` returns `-32004` before +starting a model when the selected installed agent needs sign-in. A CLI that +rejects an existing credential also marks that agent as requiring sign-in. +`context` is `{output, speak, language}`: the display the question was asked +on, whether the reply will be spoken (the agent then keeps it short and +avoids tables and code), and the shell's language. + +Notifications: + +- `turn.updated {turn}` — a complete snapshot after any change except text. +- `agents.updated {agents}` — refreshed installation/sign-in status. +- `turn.delta {id, text}` — reply text appended while streaming. +- `speech.progress {id, index}`, `speech.done {id, status}`. + +A turn snapshot: + +```json +{ + "id": "t-3f2a…", + "agent": "claude", + "prompt": "Make the menubar transparent", + "status": "running | waiting | completed | stopped | failed", + "step": "Editing shell.json", + "text": "…", + "error": "", + "errorKind": "auth | missing | crashed | ''", + "approvals": [{"id": "a-1", "kind": "command | edit | apply | other", + "title": "Run a command", "detail": "…", "command": "…", + "path": "…", "diff": "…", "transaction": "…", "changes": "…", + "ready": true}], + "startedAt": 1761223000000, + "finishedAt": 0, + "undo": ["~/.config/nerdos/shell.json"], + "plugins": [{"id": "personal.cputemp", "name": "CPU temperature"}], + "staged": "8f3a2c…", + "screenshot": false, + "resumable": true +} +``` + +`ready` (apply cards only) is true when the lifecycle status shows that +transaction built and checked. Errors: `-32001` busy, `-32002` no such +pending approval or nothing to undo, `-32003` nothing to resume, `-32602` +invalid params (including a screenshot outside +`$XDG_RUNTIME_DIR/nerdos/assistant/`, which the service creates `0700` and +empties when it starts), `-32601` unknown method. + +The service never replays a request after a reconnect; the shell sends +`hello` and adopts the snapshot it returns. + +Authentication commands follow the official [OpenAI authentication +documentation](https://developers.openai.com/codex/auth/) and [Claude Code CLI +reference](https://code.claude.com/docs/en/cli-reference). diff --git a/docs/audit-implementation-2026-10-21.md b/docs/audit-implementation-2026-10-21.md deleted file mode 100644 index 0b808ae1..00000000 --- a/docs/audit-implementation-2026-10-21.md +++ /dev/null @@ -1,431 +0,0 @@ -# Audit implementation, 2026-10-21 - -This records the engineering changes accepted after the CybexOS repository -audit. The starting point was clean `main` at -`d668048cd95ede9fac357df8cdc42a34c24e04b1`; implementation is on -`codex/implement-audit-20261021`. No release, commit, or public distribution is -implied by this record. Implementation and independent review used GPT-6.1-SOL -agents, with focused scopes and integration in the primary checkout. - -## Accepted changes - -| ID | Result | Main implementation boundary | -| --- | --- | --- | -| F01 | Account-specific, ownership-aware Docker grants and revocation, including explicit legacy opt-outs | Shared `roles/base/tasks/docker-access.yml` and `image/library/cybexos_docker_access.py` | -| F02 | Backups include Steam/Proton saves and readable rootless volumes; partial snapshots are visible failures, with export guidance for inaccessible/database data | `assets/scripts/cybexos-backup`, Backup Settings, shared installation units | -| F03 | Wallpaper preferences seed only when absent; existing sparse, empty, corrupt, future, or concurrently created settings survive | Shared managed-file module and wallpaper tasks | -| F04 | Provisioning has a bounded systemd cgroup lifetime; draining precedes lock release, retry, and recovery | Installed provisioner, reconciler, durable updater | -| F05 | Publication verifies a shared, versioned inventory of completed scenario evidence | `image/qualification_contract.py`, qualification producer and release verifier | -| F06 | The existing encrypted-US upgrade guest also qualifies public-updater interruption, automatic pre-login recovery, another boot, and successful commit | `image/public_update_qualification.py` and release qualification | -| F07 | Required source CI enforces and verifies the same qualified Quickshell series as the product | Shared Fedora CI action and product versionlock policy | -| F08 | Compiler provenance records immutable builder identity, resolved packages/tool versions, and shipped binary digests, exported with SBOM binding | Shared source builder and image/release artifact collection | -| F09 | Hermes authentication returns independently of optional discovery; discovery is single-flight and session-scoped; startup offline failures recover with bounded backoff | Hermes bridge and remote authentication state | -| F10 | GitHub stats cache contains successful results; failed fetches show unavailable/retry rather than permanent zeroes | GitHub singleton and commit popover | -| F11 | Successful unchanged plugin discovery clears its own error; preference failures remain separate; registry lock acquisition expires before mutation | Plugin singleton and existing Python registry helper | -| F12 | Keyboard/touchpad drafts survive Settings search, navigation, and close/reopen; successful Apply advances the exact committed version | Session-owned input draft state and input persistence helper | -| F13 | Compositor targeting uses a stable Settings identity across languages | Settings window and shared compositor rules | -| F14 | The registered notification drawer's On/Focus choices support keyboard and accessible activation with visible focus | Existing accessible button primitive in Drawer Notifications | -| F15 | Settings search matches translated display labels and English aliases | Existing search index and translation catalog | -| F16 | Undocumented bundled wallpapers are replaced by original procedural landscapes; PUBLIC distribution fails closed until actual inventory-bound review | Asset provenance, native/web defaults, distribution inventories and release workflows | -| F17 | Recovery search navigates to the existing recovery section anchor | Settings search destinations and Recovery group | -| F18 | Welcome fits available logical screen space, scrolls, and reveals keyboard focus | Shared Welcome payload and compositor rules | -| F19 | Nonempty launcher queries retain normal text navigation; Ctrl+Tab selects providers | Launcher command-key handler and interaction documentation | - -The ISO remains authoritative. Checkout installation, ISO reconfiguration, and -RPM reconciliation consume the shared policies and desktop sources. Saved -choices, ownership records, stale-write validation, personal settings, and -rollback boundaries remain part of the contract. No migration removes an -installed user's former wallpapers or resets their selection. - -The requested original product artwork remains in source: seven PNGs under -`assets/wallpapers/` total 139,958 bytes; three web variants under -`web/public/wallpapers/` total 91,297 bytes. Their generator and provenance -records accompany them. These replace the undocumented bundled copies while -preserving installed personal files and saved selections. - -## Review corrections - -Independent review found and corrected additional transition failures: - -- Automatic Docker convergence must not grant secondary accounts the primary - account's consent. Installed repair also needs an account-specific intent - boundary; it is not a new installation choice. -- Restic can create a partial snapshot while reporting unreadable subUID-owned - volume contents. Partial backups retain the snapshot and previous successful - timestamp, skip retention, return nonzero for manual runs, and expose bounded - error details. The unreadable count is reported read errors, not an invented - count of omitted child files. -- Input Apply must settle its submitted baseline and exact committed version - together, regardless of snapshot/completion ordering. Cached pre-write - snapshots cannot replace that baseline on reopening or Discard; later - external writes still conflict normally. -- Hermes discovery must restart the selected-session observer when it learns - a different stream path, including after an early default-path 404. Stale - sessions cannot restart old observers. -- An automatically restored baseline has SSH disabled and its firewall closed. - Recovery qualification reuses the authenticated console bootstrap after - recovery's restart; the temporary SSH/firewall changes remain runtime-only. - -## Validation and limits - -The full source runner passed all 17 groups in a disposable Fedora 44 container: -syntax, ShellCheck/Ansible/YAML/Ruff lint, 1,481 JavaScript tests across 152 -files, compositor policy, 357 QML files and four test harnesses, native QML -lifecycle/runtime checks, Quickshell migration, 47 Python helper/repository -fixture suites, hardware/Plymouth/skills/updater/screenshot/brightness/snapshot -and legacy migration checks. Existing qmllint warnings remain: three property -overrides, three missing types, and two incompatible types; no qmllint errors. - -The final refreshed image source gate passed its 30 tooling tests, 649 Python fixtures, and 14 -installer JavaScript checks. Two Python cases skipped as container root were -rerun successfully as UID 1000: writable-ancestor PXE staging and regular-user -installer account seeding. Source/image fixture parity is not an end-to-end -installation result. Final account-intent corrections passed 30 wrapper/module -fixtures, eight application-default checks, and independent caller review; -the refreshed full image gate includes these corrections. - -The pinned Fedora 44 cloud guest, with systemd 259.5 and SELinux enforcing, -executed the real provisioning timeout fixture: a detached writer ran, the -two-second limit drained the complete cgroup, no writes occurred afterward, -and retry retained serialization. Real guest account checks also verified -Docker grant/revoke, explicit legacy opt-out, and unrelated/administrator group -preservation. This checks those mechanisms; it does not qualify a CybexOS ISO. - -That guest exposed a launch failure when `systemd-run --pipe` was used on the -tested Fedora environment. Journal identifiers and bounded finite journal -snapshots replace descriptor forwarding and orphan journal followers. This -alone does not remove the public-bus dependency: systemd 259 also routes -`--wait` through that bus. The verified installer chroot remains the sole -explicit exception to live systemd containment. The regression timeout remains -two seconds. - -Two additional real-build/convergence failures prompted focused corrections. -The candidate build `20261021T200121Z-4cda2099` stopped before producing an ISO: -GitHub's anonymous API allowance was exhausted even though pinned application -payloads were cached. The installer now accepts only a unique, regular cached -asset for an inventory-pinned tag/checksum, verifies a private copy, and installs -from that copy without metadata access. Missing, corrupt, or ambiguous cache -entries retain the existing metadata path; unsafe paths fail closed. - -The first complete checkout VM run made 158 changes, then one change on its -second pass, at `/etc/grub.d`; later assertions and uninstall were not reached. -Fedora's `grub2-common-1:2.12-66.fc44` owns that directory as `0700 root:root`. -Recovery setup now creates only missing hook directories and preserves existing -upstream/admin attributes while managing CybexOS hook files. The later writer -in that failed guest was not identified; the inspected `grub2-mkconfig` did not -contain a permission change. Regression fixtures cover RPM assembly and actual -Ansible second-pass convergence. The refreshed gates and successful build and -convergence runs below include these corrections; the failed attempts are not -qualification. -The retry `20261021T201607Z-ab05186b` passed pinned application installation and -source compilation, then encountered the same exhausted anonymous API allowance -in the intentionally moving T3 Code download. No artifact was produced. Its -response advertised a reset at 2026-10-21 20:34:16 UTC; that external failure did -not justify replacing the moving-input policy or introducing credentials. -After the reset, `20261021T202930Z-ae513a9e` completed both RPMs and reached ISO -composition. Composition failed because the official Fedora OpenH264 repository -returned 404 for the primary metadata referenced by its own `repomd.xml`. -A subsequent direct read of that exact URL returned 200; no repository policy -or dependency was changed in response. No completed ISO or qualification was -produced by that attempt. - -The corrected complete `tests/fedora-vm-convergence` run passed in 12m41s: -157 first-pass changes (`ok 301`), zero second-pass changes (`ok 261`), rendered -configuration/service assertions, uninstall (27 changes, `ok 50`), restoration, -and project-state removal. It used the pinned Fedora 44 base, all tags, and the -unchanged 100-minute bound. Its pre-launch tracked diff SHA-256 was -`da1680a76e45488faef00885d2bc85978d6398fac8aadedeb088b8106fdd507a`. -Existing generic-guest exclusions remained: Docker/optional features, hardware -roles, and live Quickshell. The separate real guest Docker checks above cover -that policy mechanism. This is checkout convergence evidence, not ISO parity or -physical hardware qualification. Its lease, capacity reservation, and artifacts -were released and verified absent. - -Candidate build `20261021T203819Z-03404dcc` completed ISO/RPM assembly and -post-install validation. The ISO SHA-256 is -`8fc6c8f4559aa8d671c4946896efa6240a9ba7636cd44bfb418c30d51e540482`; -its installed source-content identity is -`32291346be09d24db6dfebbb9640de78f5a7e63c0e1fbad6b8f9e44524c5ee09`. -All delivered checksums passed. The independently copied desktop/offline RPMs -under guest qualification matched the final artifacts byte for byte. Compiler -provenance was exported for both source applications, including 193 resolved -builder RPMs, immutable image identity, and the shipped binary digests. -The temporary testing ISO was published through checksum-first PXE staging; -iVentoy refresh, list presence, running PXE status, and service checks passed. -After the fresh-install check completed, that task-owned served ISO and checksum -were removed. The subsequent refresh succeeded, its entry was absent, PXE -remained running, and `iventoy.service` remained active. Prior images were -preserved. - -The candidate's real UEFI `plain-nl` qualification passed all 12 mandatory -fresh-install checks with outbound networking blocked: installer-only session, -graphical installation, plain Btrfs, unchanged guard disk, installed boot -without ISO, password login, desktop/application contract, selected Dutch -locale/timezone/keyboard, live-state cleanup, SELinux enforcing, and -password-required sudo. The report passed the shared evidence validator and -bound the exact ISO and source-content digests above. This run did not request -outcome capture, saved-choice reapplication, or the native Settings lifecycle -extension; those are not established by this guest report. Its guest, disks, -capacity reservation, and temporary browser-path alias were removed, and the -lease was verified closed without remaining resources or cleanup errors. - -The host browser runner used a short task-owned `TMPDIR` symlink into its -managed workspace: Chromium's Unix socket exceeded the pathname limit under -the long lease path. The alias is removed during teardown. Qualification -startup and diagnostic attempts that stopped before the public-updater checks -provide no automatic-recovery evidence. The clean retry records the loaded -module path, SHA-256, and actual launcher command before executing the public -updater phase. - -The encrypted upgrade qualification exposed a host-harness authentication error: -combining `sudo -k` and `-v` validates without retaining a ticket. The launcher -now invalidates separately, then validates through the existing password policy -before executing the ordinary updater. Actual password-required, non-TTY sudo -checks in the disposable Fedora environment confirmed both behaviors. Exit, -timeout, and invalid-JSON diagnostics are bounded and redact the password. -Twelve focused launcher tests, independent review, and both refreshed full -source/image gates passed. This correction -changes the host qualification harness only; the candidate updater, RPMs, and -ISO remain unchanged. - -The upgrade guest uses the retained baseline ISO -`CybexOS-Live-44-20261021T121337Z-87cc6b87.iso`, SHA-256 -`28603ac50e9167f1896d5f0657d171eb8bd3ac429d1a54dbc37540a98b275552`. -The candidate desktop RPM is 2,263,927 bytes, SHA-256 -`25c3fd93c745296893a4da217cc24b6ccaa52aeace92337838bd014024b3795c`; -the offline-apps 1.5.2 RPM is 1,639,393,555 bytes, SHA-256 -`1530c5052112319c856cca17c175f8cdc4fe73360902641c5b313a16a3672366`. -The corrected public-update host helper SHA-256 is -`12b69686b8abd978c0830368e193a355b9241d6795bcad74b6f73855cf333a64`. - -The clean encrypted-US run passed baseline installation, candidate signature -and transaction checks, explicit recovery boot/restore, public-updater -interruption after package application, automatic pre-login rollback/restart, -preservation checks, and a subsequent cold boot. It then failed the second -public update before commit: Fedora's systemd RPM triggers reported transport -disconnects, and the contained reconciliation launch timed out before its unit -started. The updater restored its checkpoint and reported failure. This is a -failed qualification; successful public commit and the final two-second -provisioning check remain unverified in this run. - -PID 1 logged API-bus disconnects at boot seconds 98.211 and 98.582, followed by -a roughly 25-second gap around the failed launch. PID 1 and dbus-broker did not -restart. Full-boot AVC/USER_AVC/SELINUX_ERR searches found no relevant denial. -Later bounded harmless transient-unit probes passed through both the private -manager socket and public system bus, including the updater's `initrc_t` -domain and an outer system-service cgroup. Those probes establish that the -settled manager recovered; they neither reproduce the earlier failure nor -qualify a successful update. The exact disconnect cause remains unresolved; -no security policy was weakened and no update was retried inside the -diagnostic guest. Its lease, capacity reservation, six recorded QMP runtime -directories, processes, temporary path alias, and diagnostic guard were -removed and verified absent. - -Source review supports a bounded synchronization point after the package -transaction. The shared updater now performs one root-private manager reload, -then requires a valid public-manager reply, and checks public readiness again -before commit. Failures use the existing protected rollback path. Reload does -not replay missed RPM-trigger actions. The host qualification therefore also -checks system and user pending service markers, required service states, the -actual public manager bus, and ordinary password-authenticated sudo before -awarding commit evidence. These changes require a rebuilt candidate and a new -complete qualification; the failed report above cannot establish their result. - -The subsequent manager-synchronization build `20261021T215356Z-9bea00da` -completed. Its ISO SHA-256 is -`bdf569e3c72031f8fd7ec0ef0b986837524368c3c582f05eff5f0742267a77f2`, -with source-content identity -`cbfcad17dea46969fb9bcc045fc865cbaf0954bb31fbccd5e32235c5d642ec65`. -The new plain-NL guest passed all 12 standard checks and the report validator. -The wrapper's final whole-checkout guard detected announced subsequent -host-only changes; this does not invalidate the frozen guest artifact result, -but it is not a passing wrapper invocation or qualification of the newer tree. - -An encrypted attempt then exposed another host observation dependency: fresh -SSH/PAM sessions stalled while logind waited for the manager bus during DNF. -The interruption adapter had already written its applied sentinels, but the -host timed out before delivering interruption. The adapter intentionally holds -before returning to the updater, so the product's post-DNF synchronization had -not yet run. Qualification now establishes one ordinary SSH and -password-authenticated root control process before each normal user launch. -Polling, interruption, drain, staging cleanup, and shutdown reuse that process; -EOF or protocol failure fails without reconnecting or relaunching. Final health -still uses fresh ordinary SSH after the controls close. Independent review also -required passive worker verification immediately after commit, before any -timeout test or active cleanup could hide a surviving worker. All 35 public -harness and 25 offline-input fixtures passed, including mutation checks for -the passive leak regression, followed by clean independent review. - -That corrected full run reached 20 of 23 checks. It passed actual after-apply -interruption, automatic pre-login recovery, preservation, and the subsequent -cold boot. The second public update failed reconciliation and rolled back. -Both explicit provisioning bodies completed successfully, including invocation -`dd703fcf90254ec9bdd31c61383d03de`. A later, implicit transient bridge from -`systemctl --user --machine=qualification@.host daemon-reload` is the supported -failing call: the paired transient-launch and daemon-reload errors follow the -successful bodies. At boot second 102.150554, SELinux denied PID 1 write access -to an `initrc_t` pipe; its API bus disconnected immediately afterward. Descriptor -receipt can itself trigger this permission check. The exact pipe endpoint and -kernel error path were not captured, so this is not proof of a PID 1 write -system call or of a provisioning-result collection race. - -Installed reconciliation and feature changes now use the account's local user -bus through `runuser`, with explicit home/runtime/bus environment, a 30-second -deadline, five-second group-kill grace, and an existing-bus guard. Review caught -that killing only `runuser` could leave its client holding the updater's output -pipe; a real stalled-child fixture verifies the external timeout drains that -process group and closes the pipe. Failed reloads remain failures. Checkout -configuration already uses local account user-manager scope. Containment and -post-RPM manager synchronization remain intact. This product correction was -rebuilt and qualified in the final runs below. The failed 20/23 report supplies -no successful-commit, passive post-commit worker, or actual two-second timeout -credit. RPM-trigger bridge errors remain separate: manager reload does not -replay missed trigger work, and final service/marker health remains mandatory. -All failed-run resources were released and verified absent. - -The final product build `20261021T230819Z-ff4d1c80` completed ISO/RPM assembly -and all 18 delivered checksum checks. Its ISO is -`CybexOS-Live-44-20261021T230819Z-ff4d1c80.iso`, 8,059,355,136 bytes, SHA-256 -`a9ac9b820a0c694519085750df77babdc8973368e530c167be06fb505f177c59`. -The built source-content identity is -`6921f61669e9f90fb951af4e2790cb5771ff8b8f9623544399dbcb110a53160c` -across 1,452 files. Its desktop RPM SHA-256 is -`4c9e86666dee64a5b174a8f2eb30355d9f781f052eb2056ee224204cb922515f`; -the offline-apps 1.5.2 RPM SHA-256 is -`df9ce5a556c4717c785e1058f9384cc17b7e94eec664e98fb2d7f1e4901c80f5`. -Candidate copies matched the final delivery. Compiler provenance and shipped -binary/source binding were verified again for this build. - -The final ISO passed all 12 standard UEFI plain-NL fresh-install checks in one -attempt, with outbound networking blocked. Shared validation and both exact -source guards passed without exceptions. The wrapper subsequently exited 1 -because a PXE cleanup API request timed out. Bounded follow-up verification -obtained refresh `success`, confirmed the task's ISO entry absent, PXE running, -and `iventoy.service` active; no guest was rerun or service restarted. All owned -resources were released and independently verified absent. This fresh report -does not include outcome capture, Settings/reapplication extensions, physical -hardware, or Secure Boot. - -Two later encrypted attempts exposed host qualification assertions after the -product had committed. Both remain failed 20/23 reports. First, a raw-source -comparison rejected the RPM's intentional snapshot-helper path rewrite from -`/usr/local/libexec` to `/usr/libexec`. The installed helper matched the canonical -package. Qualification now verifies the exact protected candidate RPM's size -and SHA-256, unique SHA256-manifest entry, installed ownership and content, and -exact installed NEVRA. The packaged helper digest is -`dc9b9d779ca5b634794622bf25df869a0c46b859ac5f26f37c0d696ebb815dba`; -the separately retained raw-source digest is -`1cc456440c28c44bbb012d7a58292b97a06b3d6d3a1842f877c994f5e63d2022`. - -The next attempt completed commit, passive worker observation, and the actual -two-second timeout, but its final fresh health collector passed `-.mount` and -`-.slice` to `systemctl show` without an option terminator. The host collector -now inserts `--` before unit names in both scopes. Nested diagnostics retain -complete stderr up to 2,000 characters; larger stderr is omitted with explicit -size/limit metadata so truncation cannot expose a password suffix before outer -redaction. Literal escaped unit names remain unchanged. Independent review -and mutation regressions cover these corrections. All 43 public-harness and -25 offline-input fixtures passed, followed by the full Fedora gates described -above. Neither correction changes the installed product or weakens health -requirements. - -The final encrypted-US upgrade qualification passed all 23 executed checks, -exit 0, on 2026-10-22. It booted the retained baseline ISO identified above and -used the exact final candidate RPMs plus 82 bound dependency RPMs. It exercised -real after-apply interruption, automatic pre-login rollback/restart, root, -package, vendor and personal-data preservation, and a separate settled cold -boot. Public run `20261022-003645-4061-18130` committed at 00:37:18 UTC. Passive -worker verification followed immediately, before the timeout test or cleanup. -The real two-second detached-writer test verified complete cgroup draining, -no later writes, and retained serialization for retry. - -Both control SSH processes were closed and reaped before final fresh ordinary -SSH health inspection. Public `Manager.Version` returned `259.10-1.fc44`; -432 system and 171 user units had no pending restart/reload markers. All 18 -required system and three user service requirements passed, as did -password-required and password-authenticated sudo. The installed helper matched -the candidate-bound packaged digest. Shared validation and independent root -validation accepted the exact 20 shared checks plus the three additional -executed checks. No retry, extra routine guest probe, security-policy change, -or enlarged timeout was used. - -During that run, host source-content identity -`01cac1bb5713fcb0ca2138acd9814e852fc9ee11d212342d9eccfc6670febfb2` -differed from the built source only in the reviewed public qualification helper -and its tests; all paths and modes matched. All 30 loaded callables, imported -modules, both generated control/health strings, six task scripts, artifact -inputs, and start/end source guards passed unchanged. Documentation and staging -were finalized only after those guards completed. The upgrade report identifies -the baseline ISO it actually booted; final ISO linkage is not a second final-ISO -boot claim. The encrypted task's lease, capacity reservation, six QMP runtime -directories, 12 recorded processes, workspace, cgroup, path alias and mounts -were released and independently verified absent, with no cleanup errors. - -PR #24's Fedora 44 and Fedora 45 source jobs subsequently exposed Hermes -worker starvation on smaller machines. Optional HTTP discovery could occupy -every default executor worker, leaving an authentication probe queued until -those requests finished. Authentication now uses a separate two-worker pool, -closed when the bridge stops. A regression fills a two-worker ordinary pool -and requires status, login and logout to finish while both requests remain -held; restoring shared-worker authentication makes that regression fail. -The discovery fixture that deliberately parks all independent endpoints now -provides explicit worker capacity for those holds, independent of runner CPU -count. Existing authentication deadlines are unchanged. Both installation -paths consume the same bridge sources. This installed Hermes correction -postdates the artifact build above: those exact-artifact guest reports remain -historical evidence, and a release containing this later correction requires -newly rebuilt, same-source artifacts and qualification. - -Real local Restic fixtures cover restoring synthetic saves, readable volumes, -and a SQLite export, plus unreadable-volume partial-result handling. Backing up -a live database directory is still not a consistent database export; stopping -a process does not itself make subordinate-UID-owned files readable. - -The qualified-series policy also selected Quickshell 0.2.1 from a disposable -DNF repository containing both 0.2.1 and 0.3. Native offscreen Welcome checks -exercise small logical screens and focus reveal; 640×480 expanded-error and -bottom-action views were inspected. These do not replace physical display or -assistive-technology checks. - -The browser simulation passed 116 tests, lint, type checking, production build, -and two rendered-output tests. The existing web development service was rebuilt -and restarted; HTTP and health checks passed. Browser inspection confirmed the -new wallpapers load and Settings opens without console errors. This remains a -simulation, not compositor validation. - -No startup or memory speedup is claimed. Existing shared state, lazy views, -guarded polling, source/Cargo caches, portal grace, ownership ledgers, and -deferred reconciliation were retained. The accepted fixes address demonstrated -failure transitions and provenance; compiler provenance alone does not promise -byte-identical rebuilds from moving Fedora packages. - -## Release responsibilities - -Installed payload changes require newly built, same-source ISO and RPM artifacts -and regenerated qualification reports before release. The offline payload -version advances once to 1.5.2. The complete four-scenario installation matrix, -two same-source checkout comparisons, additional prior-image upgrade paths, -physical hardware, and Secure Boot remain distinct release evidence. The final -encrypted run covers only the baseline/candidate pair recorded above; fixture -success must never substitute for those reports. The new public-updater guest -scenario is mandatory in the shared evidence contract. - -Public distribution additionally requires real owner/legal decisions and the -documented protected review environment. `assets/distribution-review.json` -intentionally contains no invented approvals. Proprietary/SDK redistribution, -Steam, codecs/OpenH264, corresponding-source obligations, fonts, firmware, -application notices, artwork/wordmark rights, and trademarks need the recorded -decisions described in [licensing](licensing.md) and the -[redistribution audit](licensing-audit.md). Original replacement wallpapers do -not establish rights to unrelated components or to historical releases. - -Task-owned temporary workers, VM media, screenshots, dependency fixtures, and -failed build attempts were removed through their recorded leases. The final -main validation lease is closed with no remaining resources or cleanup errors; -its Fedora container, network, and approximately 33 GiB workspace are absent. -Bounded review reports, logs, scripts, provenance and cleanup receipts are -retained under `~/.local/state/cybexos/audit-20261021-d668048/`, with a checksum -index and explicit evidence scopes. The source artwork remains a requested -deliverable. Existing operator-owned services, prior ISOs, and unrelated -worktrees are preserved. The web development service remains healthy and -returns HTTP 200. diff --git a/docs/authentication-dialog.md b/docs/authentication-dialog.md deleted file mode 100644 index 2a36fa1d..00000000 --- a/docs/authentication-dialog.md +++ /dev/null @@ -1,82 +0,0 @@ -# Authentication dialog - -CybexOS handles Polkit requests in the managed Quickshell process. The built-in -`PolkitWindow.qml` owns one `Quickshell.Services.Polkit.PolkitAgent`, including -when a plugin replaces the bar. `PolkitPrompt.qml` presents its current flow. -Polkit and the system's PAM configuration still decide which identities may -authenticate and whether their responses succeed; the shell changes no policy. - -The compact card uses `Common/Theme.qml` fonts, density, light/dark colors and -wallpaper palette. It opens on the focused monitor and stays there until the -request ends, with a fallback if that output disappears. Account names wrap, -multiple eligible identities get a selector, and the action ID is available -under Show details. The card scrolls on small outputs or with large text. - -Enter submits the current response, Escape or Cancel aborts the request, and -Tab reaches the controls. Clicking the dimmed background does not dismiss it. -The launcher, drawers and shortcut sheet close during authentication so they -cannot take the password field's keyboard focus. The Network overlay temporarily -hides and releases its focus grab while keeping its requesting helper alive; -it returns when the authentication flow ends. A normal settings window can -remain open behind a request it initiated. - -Passwords start masked, never echo the last character, and can be revealed -with the eye button. PAM prompts that request visible responses are supported, -as are informational messages while waiting for fingerprint or other methods. -When PAM announces a fingerprint (`pam_fprintd`) or security-key (`pam_u2f`) -step and asks for no input, the card says *Touch the fingerprint reader, or -wait to enter your password.* or *Touch your security key, or wait to enter -your password.*, adds *Not recognised.* after a mismatched scan, and shows a -key glyph for the key step. These steps run one after another in Polkit's -stack, so the password field appears when they give up; a stale step message -is not repeated under it. See [Fingerprints and security keys](security.md). -The response is cleared on submission, cancellation, account/prompt changes, -completion and replacement by another request. Closing the window destroys -the input. Responses go directly to the backend; no shell command, IPC method, -log or persistent setting carries them. This does not promise secure erasure -of Qt's or the authentication library's internal memory. - -## Startup and rollback - -`cybexos-runtime exec quickshell` stops a loaded or active -`hyprpolkitagent.service` before registering the integrated agent. Source -deployment removes the old session enablement and managed unit; new images -neither require nor start the standalone agent. Existing RPMs are left -installed. Selecting an older runtime without `PolkitWindow.qml` starts the -legacy agent when its unit is available, including after a deployment rollback. - -`cybexos-runtime ipc polkit status` returns only `registered` and `active`. -There is no diagnostic method to read or submit a response. Registration must -be true before considering a deployment healthy. After switching from an old -installed runtime resolver to development source, use the checkout's resolver -for the managed service too, or stop the old agent before restarting Quickshell. -Do not start a second `qs` instance to test a dialog. - -## Verification - -`tests/run` checks account-label handling, QML, startup integration and icon -coverage. `tests/ownership-layering.py` verifies the agent transition and legacy -fallback with isolated service/executable fixtures. The production-component -lifecycle harness exercises submission, duplicate-submit prevention, retry, -account switching, visible PAM responses, local/remote cancellation, completion -and clearing state between requests. Its Polkit fixture never authenticates -against the host. The real-engine harness runs in CI without an active shell; -on a workstation use `tests/lib/quickshell-live` before and after any controlled -test that stops and restores the service. - -For live acceptance, verify registration, trigger a real Polkit authorization -check, cancel with the keyboard and confirm the requesting process is denied. -Also check details expansion, password visibility, Tab order, background clicks, -blocked competing overlays, large text, both color modes and output removal. -Successful authentication and fingerprint/other PAM methods require an operator -with the relevant credential or hardware; never collect their password through -agent tools. - -On 2026-09-26 the installed Quickshell 0.2.1 build registered successfully and -displayed a real `com.1password.1Password.unlock` authorization check. Escape -returned a dismissed result to `pkcheck`; Tab navigation, visibility, details -and blocking the competing launcher were exercised. The isolated real-engine -conversation fixture and the image's 29 installed-policy/user-parity tests -passed. The managed service finished as the sole `qs` process, with no QML -errors in its current invocation and the old agent inactive. Successful real -authentication, fingerprint hardware and monitor removal were not exercised. diff --git a/docs/cliproxy-rust-model-usage-research.md b/docs/cliproxy-rust-model-usage-research.md deleted file mode 100644 index bc58bafa..00000000 --- a/docs/cliproxy-rust-model-usage-research.md +++ /dev/null @@ -1,394 +0,0 @@ -# CLIProxyAPI Rust support for Model Usage - -Research date: 2026-10-03. Scope: CybexOS's Model Usage widget and the Rust -proxy running at `root@10.10.0.235`. - -The recommended change is a native Rust adapter in the upstream Model Usage -plugin, followed by a vendor update in CybexOS. Rust already exposes the -account inventory, subscription quota windows, and account activity needed -for the main widget. The existing server URL and management key work with -that API. A proxy upgrade is unnecessary for this first stage. - -## Verified deployment and failure - -The active service is `cli-proxy-api.service`, running -`/opt/cli-proxy-api-rust/current/cliproxyapi-rust`. Its current release is -`0.3.2+gui.5862bb7fdbe8`, based on upstream `v0.3.2`, with a local GUI -configuration editor. GitHub's latest official release was also `v0.3.2`. -The backend listens on TLS `127.0.0.1:8318`, behind the existing admission -gate on `:8317`. The configured widget URL, -`https://aiproxy.risk-bull.ts.net`, reaches it through Tailscale Serve. - -The installed RPM and this checkout have identical `usage-fetch.py` files. -Both use the Go implementation's `/v0/management/auth-files` and -`/v0/management/api-call` endpoints. The vendored plugin is version 1.1.1 at -`8266a07495674d2d425f6d76b67833872a69d466`; that was also the upstream plugin's -latest commit at inspection. Re-vendoring the current upstream alone cannot -resolve the incompatibility. - -Authenticated reads through the widget's configured URL produced: - -| Check | Observed result | Implication | -| --- | --- | --- | -| Existing client's account discovery | HTTP 401 and “CLIProxyAPI rejected the management key or remote management access.” | The current error suggests a credential problem. | -| `GET /api/accounts` with the same management key | HTTP 200; three Claude and three Codex OAuth accounts | The saved management key is valid; the API implementation differs. | -| `GET /api/accounts` with a deliberately invalid key | HTTP 401 | Native management authentication is enforced. | -| Native account quota data | Six accounts and nine windows in the feasibility snapshot | Claude had 5-hour and weekly windows; Codex exposed weekly windows at that moment. | -| Native account activity | All six accounts had `last_used` timestamps | Keeper is unnecessary for Rust account activity. | -| `GET /api/overview` | Reported the local Rust version and `round-robin` routing | Do not infer the deployed routing strategy from upstream's `least-used` default. | -| `GET /healthz` | HTTP 200 | The backend was available during inspection. | - -Upstream explicitly documents that `/v0/management` and the Redis usage -queue are unsupported. Unsupported paths can return 401 through the client -authentication middleware; 401 on the legacy path alone does not establish -that the management key is wrong. - -CPA Usage Keeper remains active, but its current logs repeatedly report -failed metadata reads and “management usage queue request returned status -401,” alongside a Redis protocol mismatch. Service activity does not prove -that Keeper is collecting new usage. Preserve its existing database and -historical records; Rust requires a different collection source. - -## Native data and feature limits - -`GET /api/accounts` returns a JSON array of account snapshots. It is an -authenticated read of cached server state, so the widget need not send -OAuth tokens or trigger provider quota calls. Rust normally polls Claude -and Codex quota about every five minutes, checks polling eligibility every -minute, and also observes quota information on model responses. - -| Widget data | Rust field or behavior | Proposed handling | -| --- | --- | --- | -| Provider and account label | `provider`, `email`, `label` | Reuse provider grouping, sanitization, and email hiding. | -| Account identity | `id`; OAuth IDs are `file:`; `file` is also present | Hash a canonical identity. Verify filename mapping against previous Go history and labels before claiming migration continuity. | -| Paused account | `disabled` | Retain it as paused and exclude it from capacity selection. | -| Quota amount | `quota.windows[].used`, a percentage from 0 to 100 | Map to `used` and `remaining = 100 - used`. Validate finite numbers; missing data remains unknown. | -| Window label and scope | `name`, optional `model` | Translate known names such as `5h` and `week`; preserve model scope and unknown names. | -| Reset and freshness | `resets_at`, `quota.updated_at` | Parse timezone-aware timestamps and retain the server's observation time. A successful HTTP read does not make an old quota fresh. | -| Plan | Optional `quota.plan` | Map Codex plan metadata when present. Claude plan metadata was absent in the live response. | -| Last account used | `last_used` | Select the latest account per provider, preserving ties as ambiguous. | -| Credits and banked resets | Absent from account snapshots | Keep values unknown and disable banked-reset actions for Rust. | -| Other provider quotas | Quota polling currently covers Claude and Codex | Show other providers with quota unavailable; preserve inventory and pause state. | - -The API does not guarantee that both 5-hour and weekly windows are present -on every read. Missing windows must not become zero usage or full capacity. -An initial cache can be empty after startup, and failed provider polling can -leave old values in it. A proposed stale policy should allow for the normal -five-minute polling interval and scheduling delay; its threshold still -needs an implementation decision and tests. - -Rust sets `last_used` when a tracked request finishes, including failed -requests. It does not identify an account serving a request still in -progress. Activity and counters reset on process restart. The widget should -describe this as the last completed request, including failures, and return -to unknown when the server has no recorded activity. - -Rust's `POST /api/accounts/{id}/reset` only clears local cooldowns, strikes, -and the last error. It must never substitute for spending a Codex banked -reset. The account API also omits paid credit balances, Claude extra usage, -and Codex code-review/additional quota buckets available to the existing Go -adapter. Full feature parity would require further server API work. - -## Proposed implementation - -Keep `usageSource: cliproxy` and the current connection settings. Detect the -implementation through authenticated account responses, then feed both -implementations into the existing normalized `schemaVersion: 1` contract. -An additive implementation identifier and capability fields can let QML -select activity and reset behavior without a new setup flow. - -1. Extend `scripts/usage-fetch.py` with a Rust client and account normalizer. - Probe `/api/accounts` and the legacy account endpoint within one bounded - deadline, accepting only validated response shapes. In particular, allow - a legacy 401 to be resolved by a successful native probe without treating - every authentication failure as proof of Rust. Preserve redirect refusal, - TLS validation, secret-file protections, response-size limits, and - provider/account bounds. Avoid `/api/overview` for routine discovery: its - response includes client API keys that the widget does not need. -2. Reuse the account grouping, best-capacity selection, quota display, and - history code. Separate fetch time from quota observation time, show stale - or unknown data honestly, and avoid appending unchanged stale observations - as fresh history. Verify canonical account and window identities so - existing private labels and quota history can survive the transition. -3. Adapt `scripts/proxy-activity.py` and `UsageActivityBackend.qml` to read - native `last_used` values for Rust. Remove the Keeper URL requirement for - that implementation and update the current Keeper-only notice. Reuse an - available account snapshot or make a cheap native read for the existing - 15-second activity refresh; do not repeat provider quota checks. -4. Make reset availability depend on implementation capabilities as well as - credit data. Update `ResetBackend.qml` and `scripts/reset-credit.py` so a - Rust connection cannot execute Go-only reset actions. Clear incompatible - cached credits when the detected implementation changes, even if the URL - is unchanged. Missing credits must remain unknown rather than zero. -5. Update the plugin's settings help and documentation to explain Rust - support and its limits. Keep local and T3 transcript cost sources working - independently of the quota adapter. - -Implement this in -[DigitalPals/omarchy-modelusage](https://github.com/DigitalPals/omarchy-modelusage) -first. CybexOS deliberately vendors its runtime files unchanged; local edits -would be overwritten by the next sync. Import the reviewed upstream commit -with `scripts/sync-model-usage `. Update -`Common/SettingsHelpers.js` only if the plugin's settings schema changes. -The shared runtime must ship through both checkout and ISO/RPM paths. - -## Persistent costs are separate work - -Rust has token counts in `GET /api/requests`, aggregate counters in -`/api/overview`, and live events on `/api/live`. These are in memory: -`src/state.rs` retains at most 300 recent request records and 60 minute -buckets. The request log stores an account label rather than a stable -account ID. Polling that ring cannot guarantee complete history across high -traffic, disconnects, or restarts. - -For reliable Rust-backed Keeper costs, add durable events with stable -account IDs, an instance/event identity, replay cursors, and deduplication, -then adapt Keeper ingestion. Preserve historical Go records. That work can -follow the quota/activity adapter; the widget's local/T3 cost collection -does not depend on it. No widget change can recover proxy usage that was -never persisted. - -## Validation required for implementation - -Add native fixtures and HTTP integration coverage for account discovery, -bad keys, implementation detection including the observed legacy 401, -empty inventories, disabled/unknown providers, missing and stale quotas, -model-scoped windows, malformed numbers/timestamps, oversized payloads, -restart behavior, and activity ties. Test private-label/history identity -continuity, capability changes at the same URL, and that Rust polling never -calls `api-call`, account mutation, or banked-reset endpoints. Retain the -existing Go behavior and tests. - -Run `python3 -B tests/model-usage.py`, the source gate `./tests/run`, and -`python3 -B image/check-source`. Add any necessary shared-payload parity -coverage. For live UI testing, use `tests/lib/quickshell-live` and the managed -service, including sole-process and current-invocation journal checks. A -release with changed runtime payload needs a new same-revision RPM/ISO and -installation qualification; fixture tests do not qualify an installation. - -The initial research validated source contracts, authenticated live reads, and an -in-memory normalization experiment. That experiment produced six unique -hashed account identities, nine quota windows, and six activity timestamps -without writing state or calling provider/model/action endpoints. It was -not a production implementation, widget UI test, identity migration test, -or end-to-end installation test. At that stage, only this research document -was added; server, installed desktop, credentials, and preferences were -unchanged. The subsequent implementation and live deployment are recorded -below. - -## Implementation and live verification - -The native adapter was implemented and applied to the workstation on -2026-10-03 at the user's request. Model Usage 1.2.0 is pinned to upstream -commit `d34db479f99ea4afc3edb539cfb8775a50a57433`, published to -`DigitalPals/omarchy-modelusage`'s `main` branch at the user's request. The -CybexOS vendored runtime and test manifest now pin the follow-up commit -`bc771a070ba1a18a3566a7006ecd1ba8c000713c`, which also accepts Rust nanosecond timestamps on -Python 3.10. The original live RPM below was built from `d34db47`; its -Python 3.14 runtime already accepts those timestamps. No proxy-server -change was made. - -The adapter detects a validated native account inventory after an unsupported -legacy endpoint response. Discovery shares one timeout budget. It normalizes -Claude and Codex OAuth quota windows, preserves filename-based account hashes, -and reads native last-completed-request activity without Keeper. Cached quota -observations older than ten minutes are marked stale; missing or expired -windows remain unavailable. Rust credits stay unknown, and banked-reset -actions are blocked in the UI and backend. - -The checkout and RPM packaging now share `prepare_quickshell`, with a -byte-for-byte Model Usage payload regression check. The live installation -received a development `cybexos-desktop` RPM through DNF, preserving the -installed baseline's other files, dependencies, package scripts, and existing -session-file customization. This scoped development build replaces Model -Usage and adds update provenance; it is not a full release build. - -Verification completed: - -- `python3 -B tests/model-usage.py` passed, including 158 upstream Python - tests and JavaScript assertions. Fourteen new Rust tests cover native - discovery, authentication failures, response limits, quota freshness and - identities, activity, and rejection of unsupported actions. -- `./tests/run` passed all 17 stages, including 1,173 unit tests and lint of - 327 QML files. The separate real-engine lifecycle stage was skipped because - the managed live shell was active; QML component runtime checks passed. -- `python3 -B image/check-source` passed image tooling, 276 image unit tests, - and installer JavaScript tests. These are source and payload parity checks, - not an end-to-end ISO installation qualification. -- Authenticated reads through the installed adapter detected Rust, three - Claude accounts, three Codex accounts, and nine fresh quota windows. Native - activity matched the normalized quota identities for both providers. -- The managed live Limits panel rendered both Claude and Codex, each with - three account cards and reset times. The Codex view displayed its plan - metadata and weekly limits. `rpm -V cybexos-desktop` reported no drift after - installation. -- The saved Model Usage preferences, management-key file contents and path, - and private key permissions were unchanged. The restarted - `quickshell.service` was active, its MainPID was the sole `qs` process, and - its current invocation journal contained no QML/runtime errors. Live checks - used `tests/lib/quickshell-live` with cleanup traps. - -After publishing, upstream CI exposed Python 3.10 rejecting native -nanosecond timestamps. The follow-up normalizes fractional seconds to -microsecond precision and adds coverage for one through nine fractional -digits and timezone offsets. All 159 upstream Python tests and JavaScript -assertions passed locally after that correction. - -The development RPM is retained for reinstalling the tested payload in -`/home/john/.local/share/cybexos/images/rust-widget-live.GHVEIEZX/` alongside -`SHA256SUMS`, `provenance.json`, and `rpm-validation.json`. Its filename is -`cybexos-desktop-0.0.0~dev-1.20260926095228.g4054070a4078.hwfix3.fc44.rustwidget1.x86_64.rpm` -and its size is 1,720,360,693 bytes (1.60 GiB). Build intermediates, temporary -test logs, and desktop screenshots were removed after verification. No test -VMs or mounts were created. No ISO was built or qualified; release qualification -remains separate from this live development test. - -## Fusebox live account activity assessment (2026-10-08) - -Adding a live account indicator is feasible at the inspected deployment's -scale. Use one persistent WebSocket connection per configured proxy, shared -by all bars, rather than increasing the frequency of the existing Python -activity subprocess. This assessment does not implement or deploy the feature. - -The service at `root@10.10.0.235` runs -`0.3.2+gui.93d3af0bb855`. Its deployment record identifies clean source commit -`fecb778830c13e80c1cd24e041da2107e9917a51`, also the Fusebox repository's HEAD -at inspection. The deployed README matched that commit byte-for-byte by -SHA-256. There were five configured accounts across Claude and Codex. - -The authenticated `wss://aiproxy.risk-bull.ts.net/api/live` endpoint already -sends `load` events with account IDs and `{in_flight, sessions}` counts. A -connection receives an initial snapshot; the server samples every second -and sends another snapshot only when its serialized contents change. -Bearer authentication in the upgrade headers worked, so a desktop helper -does not need to put the management key in its URL. The existing -`GET /api/accounts` response does not contain these live counts. - -- `in_flight` counts actual account request attempts, including unfinished - streams. The request guard decrements on completion or cancellation. -- `sessions` counts distinct assigned sessions seen in the last five minutes, - plus sessions with a call still in flight, subject to the configured shorter - affinity expiry. It does not mean that every session is generating now. -- `last_used` remains the latest completed request, including failures. Keep - this separate from a new "Serving now" indicator. Concurrent accounts can - all be serving; do not choose one by its quota or last-used timestamp. - -The load computation reads the account pool, atomic request counters, and -the in-memory session registry. It performs no provider quota call or SQLite -query. The full dashboard socket additionally sends request metadata, -account-change notifications, and five-second totals ticks. - -Read-only production measurements, with ordinary model traffic continuing: - -| Measurement | Result | -| --- | --- | -| Account inventory GET | 10.62 ms; approximately 6.7 KB of reserialized JSON | -| WebSocket connection | 11.68 ms; header authentication accepted | -| Load event size | 169 bytes in the observed snapshots | -| 30-second WebSocket observation | 6 load events, 6 ticks, 7 completed-request events; 7,963 payload bytes total (265 bytes/second) | -| Desktop observer CPU | 9.37 ms over 30 seconds, about 0.031% of one core | -| Desktop observer memory | Approximately 33.1 MiB RSS for the whole Python observer; 28 KiB growth during observation | -| Fusebox CPU before connection | 80 ms over 15.24 seconds, about 0.53% of one core | -| Fusebox CPU during connection | 140 ms over approximately 30 seconds, about 0.47% of one core | -| Existing activity helper, three invocations | 87–96 ms elapsed and 75–83 ms child CPU per invocation | - -The desktop observer imported the existing adapter and used the installed -Python `websockets` library; its memory is not a measured incremental -Quickshell allocation. Server CPU and memory include concurrent production -work, so these observations cannot isolate the socket's overhead or prove -performance under a much larger workload. They showed no material CPU -increase at the current scale. No synthetic model requests were generated. - -For implementation, replace Rust activity polling with a bounded persistent -collector and keep the slower quota refresh independent. Publish only changes -to QML, use static indicators, preserve hashed identity matching and hidden -emails, and mark live activity unknown on disconnect. Reconnect with bounded -backoff and stop the collector when its feature is disabled. Python -`websockets` is already included by both the desktop role and RPM dependency -contract. Implement upstream in Model Usage and import through the existing -vendor workflow, including source/image payload parity coverage. - -No Fusebox change is required for one desktop. For wider deployment, an -activity-only subscription would avoid forwarding request metadata and -totals. Compute a shared load snapshot once per second for all subscribers: -the current implementation scans the session registry separately for each -socket while holding its mutex, and the registry can contain up to 10,000 -assignments. Bound slow-client sends and recover with a complete snapshot -after reconnect or lag. These changes would protect scaling without adding -work to every token or provider request. - -Source references at the deployed commit: - -- [Live load snapshots and WebSocket loop](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/src/mgmt.rs#L912). -- [Request guards](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/src/accounts.rs#L366). -- [Recent-session counting](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/src/affinity.rs#L272). -- [Dashboard consumption of load events](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/ui/app.js#L347). - -Temporary source inspection files were removed. No runtime code, -configuration, credentials, service, RPM, or ISO was changed. - -## Sources - -- [Rust v0.3.2 release](https://github.com/IuCC123/CLIProxyAPI-Rust/releases/tag/v0.3.2). -- [Pinned Rust README and compatibility limits](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/README.md#coming-from-cliproxyapi). -- [Management routes and authentication](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/mgmt.rs#L292). -- [Account snapshots and filename identities](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/accounts.rs#L483). -- [Quota fields and polling](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/quota.rs). -- [Activity semantics](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/proxy.rs#L100) and [in-memory request retention](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/state.rs#L195). -- [CybexOS vendoring contract](../roles/desktop/files/quickshell/ModelUsage/README.md), [existing proxy client](../roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py), and [installation parity requirements](installation-parity.md). -- Live observations from `root@10.10.0.235`, its local deployment records, - updater status, authenticated native API, and Keeper error logs on the - research date. No credentials or raw account identities are reproduced. - - -### Implemented session badges - -Model Usage 1.3.1 uses one shared, reference-counted live collector per proxy -URL/key-file connection. Fusebox and its deployment are unchanged. The menubar -shows quota percentages only. A badge at the upper-right of each account card, -aligned with its subscription title, shows Fusebox's recent assigned-session -count. The count is not an unfinished-request count and may stay positive after -requests finish. Tooltip and keyboard-accessible expandable details explain -this. Broken, stale or unsupported streams display “Sessions unknown”. Models -are not displayed or queried. Quota layout, last-used selection, account order, -and saved email privacy are preserved. Obsolete bar-count and separate -recent-session settings are dropped by normalization. - -The collector reads only cached native inventory, coalesces changes to at most -one inventory request per 15 seconds, refreshes idle inventory every 60 seconds, -and throttles stdout heartbeats to five seconds. Frames, queues and QML stdout -are bounded. Initial load and heartbeat expiry prevent false zero counts; -reconnect backoff caps at 60 seconds. Older Rust endpoints retain cached -last-used activity with live activity unavailable. Go/Keeper retains its -existing polling path. No server changes, new connections, per-session queries, -model discovery or extra polling are required for the badge. - -A final 20-second live sample measured 0.1% of one core and 32.3 MiB RSS for the -shared helper; the entire shell consumed 1.2% of one core. The presentation-only -revision retains the existing collector and protocol. This short observation -does not claim a stress benchmark. - -Validation covers real WebSocket authentication, normalization, idle, -disconnect, malformed counts, missing initial snapshots, reconnect backoff and -shared QML lifetime. Badge tests distinguish session counts from unfinished -requests, including zero requests with positive recent sessions. Checkout -settings and ISO desktop payload fixtures share the same settings and transport -dependency. Live verification confirmed quota-only chips and the upper-right -session badge beside “Codex Pro · 20×”. The managed service's current invocation -passed QML journal and sole-managed-`qs` checks. Its development source selection -was preserved. Deployment uses a scoped development RPM preserving the installed -payload's unrelated files, dependencies and scriptlets; this is not a rebuilt or -end-to-end-qualified ISO release. - -### Fusebox naming and repository link - -Model Usage 1.3.2 names the native Rust connection Fusebox in Limits settings, -quota source diagnostics and account notices. Limits settings expose a visible, -keyboard-accessible “Fusebox on GitHub” link to -`https://github.com/DigitalPals/Fusebox`. Before discovery, the source option says -“Fusebox / CLIProxyAPI”; detected Go connections retain the CLIProxyAPI name. -The existing source value, URL/key settings and Rust protocol marker are -unchanged, so saved connections continue to work. This is a presentation change -with no new network calls, polling or processes. The desktop payload equality -fixture covers both checkout and ISO/RPM source packaging. The scoped desktop -RPM is a development update, not an end-to-end-qualified ISO release. diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 00000000..e93f5c94 --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,66 @@ +# Personal configuration and extensions + +Put package, service, hardware and VPN declarations in `/etc/nixos/local/` and +import each from `local/default.nix`. The machine template includes a WireGuard +example with `privateKeyFile`; put the key at a private runtime path. Use the +service's persistent D-Bus API for NetworkManager profiles. Vendor code and +assets in the Nix store remain read-only. Preview/apply changes through the +same lifecycle as updates, using `nerd update stage local`. + +A regular Git repository in `/etc/nixos` keeps its history, index, remotes and +hooks as persistent personal metadata. Selected working configurations reference +the retained original `.git` directory; snapshots and Nix source archives omit +it. The updater never invokes Git or hooks. Do not remove a retained working +directory that is the target of an active `.git` reference. Linked worktrees, +pointer files, unmanaged `.git` symlinks, `core.worktree` overrides and included +Git configuration are refused before application because moving their worktree +would require changing user metadata. Use a regular repository without these +layouts, or manage a separate personal repository and copy declarations into +the supported machine configuration before previewing them. + +Cisco IKEv1/IPsec uses NetworkManager's maintained Libreswan backend. Keep +connection secrets in the keyring or NetworkManager's private persistent +connection files. Libreswan's NSS database lives in root-private +`/var/lib/ipsec/nss`; it is persistent state, outside the Nix store and system +generation rollback. Do not put PSKs, passwords or private keys in +`services.libreswan.connections` or `configSetup` text. Use root-private +runtime files and the service's supported secret-loading configuration. + +Desktop preferences use sparse `~/.config/nerdos/shell.json`; monitor choices +use `displays.json`; personal Hyprland Lua loads last from `hypr/user.lua`. +They apply without rebuilding the OS. Unknown preference fields, explicit +choices and conflict/migration sidecars are retained. Older shells treat a +newer unsupported preference schema as read-only. + +With developer tools enabled, first login seeds a writable LazyVim starter only +when `~/.config/nvim` is absent. The reviewed starter revision is pinned in the +desktop package; update that pin when qualifying new initial editor defaults. +Existing Neovim configuration is always personal. LazyVim downloads its plugins +on first use and manages their lock/state independently of OS updates. + +Personal widget code lives in `~/.local/share/nerdos/plugins/ID`, placement and +preferences in `~/.config/nerdos/plugins.json`, and state in +`~/.local/share/nerdos/plugin-data/ID`. Theme packages have their own personal +data directory. Use `nerd plugin` and `nerd theme`; no personal package is +copied into the vendor runtime or deleted during an OS rollback. + +[Widget API 1](architecture/user-widgets.md) and +[Omarchy compatibility](omarchy-plugin-compatibility.md) are public contracts. +The preserved native interface injects `pluginApi`; all six supported Omarchy +plugin kinds use the existing adapters. Plugins are trusted code and may access +the session's privileges. File survival alone does not prove runtime compatibility; +release qualification must run actual engine/plugin tests. + +Claude Code and Codex discover the same packaged `nerdos` skill through their +user-scoped discovery directories. Only absent slots gain a stable system-profile +symlink. A vendor update/rollback updates its skill target automatically; an +existing user-authored skill is preserved and reported. Other skills are never +modified. [The skill](../agent-skills/nerdos/SKILL.md) documents ownership, +secrets, local declarations, previews, application and recovery. + +Regional Settings previews write the machine-owned `desktop-settings.json` in +an isolated candidate through `nerd config region`. The template's `settings.nix` +imports it. Timezone and system language become persistent declarations after +the normal reviewed apply; they do not use mutable localed/timedated writes that +would disappear on rebuild. Existing locale categories remain separate. This +small typed file is not a place for secrets or arbitrary module expressions. diff --git a/docs/dependency-policy.md b/docs/dependency-policy.md deleted file mode 100644 index 241a625a..00000000 --- a/docs/dependency-policy.md +++ /dev/null @@ -1,239 +0,0 @@ -# Dependency and pinning policy - -The repository separates reproducible inputs from channels that intentionally -track security or nightly updates. Pins live in -`inventory/group_vars/all.yml`; changing one is a review decision, not routine -playbook convergence. - -## Immutable inputs - -- GitHub release applications and Nerd Font archives require an exact release - tag and SHA-256. Before contacting GitHub, the installer can use one regular, - non-symlink cached asset matching the inventory regex in that tag's sanitized - cache directory. It checks the actual SHA-256 of a private copy and installs - that copy, so cache changes cannot substitute unchecked bytes. Symlink or - traversal paths fail safely. Missing, corrupt or ambiguous caches use the - normal authorized release metadata/download path, which checks a GitHub release - digest when supplied and redownloads a bad cache once. It stages non-RPM payloads - and atomically switches `current`. Non-RPM installs retain three version - directories; the cache keeps the two newest releases plus the current pin if - it is older. -- Root-installed GitHub RPMs have the same tag/checksum requirement and are - serialized behind one DNF lock. `--nogpgcheck` does not make them - unauthenticated: the inventory SHA-256 is the explicit authorization for the - exact RPM bytes. -- Source-built applications use a human-readable tag plus a full commit, and - the build container is pinned by manifest digest. The commit, not the tag, - is authoritative. -- The LazyVim starter used for an absent `~/.config/nvim` is pinned to a full - commit. The role never overwrites an existing Neovim configuration. This pin - covers the starter files only; LazyVim plugin state is reproducible only - when the user's configuration has its own `lazy-lock.json`. -- Development Distrobox launchers use fully qualified manifest digests. Each - entry also records the source tag and review date. A changed pin affects a - newly created box; the role deliberately does not destroy an existing box. -- Rust uses a dated nightly (`nightly-YYYY-MM-DD`), not the moving `nightly` - channel. The role installs `rustfmt` and `rust-analyzer`, retains an installed - toolchain on a transient download failure, and fails an incomplete first - install. -- Claude Code and OpenCode versions are explicit inventory pins. - Claude's native installer accepts the exact target and keeps versioned - binaries. Claude Code's pin is the version an install receives and a - minimum afterwards: Claude Code updates itself, and a newer install is not - downgraded. Codex CLI tracks npm's `latest` stable release, resolved on each - installer/user-tool update run. OpenCode and Codex are installed from resolved npm package versions - into separate staged, versioned user directories and exposed only after each - binary reports the requested version. Neither package policy changes agent - permissions or adds unattended-launch flags. -- Vendor repository keys, standalone font files/archives, source inputs, and - XPS camera inputs carry the checksums or commits next to their configuration. - See the relevant inventory and role defaults for their update points. -- The RPM Fusion signing keys are vendored in-tree as the copies that the - `rpmfusion-*-release` packages ship (`roles/apps/files/rpmfusion-*.asc`), so - an already-imported key is recognized without a download; each is still - imported only after its full fingerprint matches. Rotating them means - replacing both files and updating the fingerprints in - `roles/apps/tasks/repos.yml` and the SHA-256 digests in - `tests/quickshell/converge-efficiency.test.cjs`. - -`tests/repository-policy.py` rejects an unpinned GitHub release, a floating -Distrobox image, malformed toolchain pins, drift in the CLI installer -template, or a changed YOLO alias. It runs inside the Python stage of -`tests/run`. - -Each ISO build lists the pinned downloads it bundles, with their URLs and -SHA-256 digests or commits, in its SPDX SBOM; see -[ISO releases](iso-releases.md#software-bill-of-materials). `image/test_sbom.py` -fails when a pin lacks a checksum or commit, or when its `enabled` condition is -not a plain boolean or `{{ features.NAME }}` reference. - -## Updating a pin - -1. Read the upstream release notes and identify the concrete compatibility or - security reason for the change. -2. Resolve the exact release tag/commit or container manifest digest. Download - artifacts from the configured upstream and calculate SHA-256 locally; when - GitHub publishes an asset digest, make sure it agrees. -3. Change the inventory pin and checksum together. For a Distrobox, keep the - reviewed source tag beside the digest and update the review-date comment. -4. Run `./tests/run`, then use - `ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff` - or an appropriately scoped direct Ansible check. -5. Apply the change and validate the installed version. Do not delete retained - versions or an existing Distrobox until the replacement is proven and any - data inside the box is accounted for. - -The baseline pins were taken from working installed versions and locally -cached artifacts on 2026-08-27; subsequent pin changes follow the review -procedure above. This keeps clean installations reproducible without silently -authorizing speculative upgrades. - -## Qualified compositor and shell series - -Hyprland (from the `sdegler/hyprland` COPR) and Quickshell (from Fedora) shape -every part of the desktop: the Lua configuration API, IPC, and the QML the -shell is written against. A release is qualified on one series of each, -recorded in `qualified_package_series` in `inventory/group_vars/all.yml`: - -```yaml -qualified_package_series: - - { name: hyprland, minimum: "0.56.2", below: "0.57~" } - - { name: quickshell, minimum: "0.2.1", below: "0.3~" } -``` - -Both installation paths turn each entry into a dnf versionlock entry in -`/etc/dnf/versionlock.toml` that admits `minimum <= version < below`. -`roles/base/tasks/versionlock.yml` does this for a checkout install (before -any role installs either package), an image installation and upgrade -reconciliation, all with `image/library/cybexos_versionlock.py`. Patch -releases inside the series still install and update through `cybex update`; -the next minor or major release is held back. The bounds compare like RPM -versions with an empty release, so `>= 0.56.2` admits `0.56.2-2.fc44`, and a -trailing `~` (`< 0.57~`) also excludes every `0.57` pre-release. - -CybexOS writes only entries whose comment starts with `CybexOS qualified -series`, and replaces edits to them on the next converge. Every other entry is -kept as written. dnf combines the entries for one name with OR, so an -administrator who has qualified another build can admit it with an entry of -their own, for example `sudo dnf versionlock add hyprland-0.57.0-1.fc44`, -without touching ours. Uninstall removes only the CybexOS entries. A file that -cannot be read safely (invalid TOML, an unknown version, values the converge -could not write back) is left untouched and the converge warns that the series -is not enforced. A converge also warns when an installed package is already -outside its series; dnf keeps it but holds it there. - -The image build verifies the series as well: `image/compose` fails when the -resolved `hyprland` or `quickshell` is missing or outside its series, so an -ISO is never published on an unqualified compositor or shell. - -The shared Fedora source-test action installs the same versionlock policy -before selecting Quickshell, then rejects a missing or out-of-series installed -runtime. Required checks therefore exercise the product's supported series; -the next-Fedora preview reports an unavailable qualified build explicitly. - -Because the COPR publishes only its newest build, a fresh install fails with -"matches only packages excluded by versionlock" once upstream has moved to an -unqualified series and the old build has been withdrawn; the same is true of -an image build. That failure is deliberate: it is the point at which the next -series must be reviewed. Installed systems are unaffected. The durable -updater runs `dnf upgrade --no-best` (dnf's default, stated so a local -`best=True` cannot override it), so a library upgrade that only the held-back -compositor could use is skipped instead of failing the whole update. A Fedora -major upgrade honors the lock too, so the target release's COPR must still -carry a build in the series. - -### Qualifying a new series - -1. Read the upstream release notes, and for Hyprland the Lua configuration - changes (`/usr/share/hypr/stubs/hl.meta.lua` and the shipped - `/usr/share/hypr/hyprland.lua`), for anything the CybexOS Lua or the shell - relies on: `hl.*` calls, dispatchers, config keys, events and IPC output. -2. Raise `minimum` and `below` in `qualified_package_series` together, run - `./tests/run` and `image/check-source`, then converge a test machine with - `./install` and confirm `rpm -q hyprland quickshell` reports the new - series and `hyprctl configerrors` is empty. -3. Rebuild and qualify the ISO from the same revision (`image/build`, - `image/qualify`). The compose check fails if the resolved packages are - outside the new series. -4. Release. Installed systems adopt the new lock when the release reconciles - (`cybex update`), and the packages move on the following update. - -To pull in only a reviewed patch release, nothing changes: it is inside the -series already. - -## Intentionally moving channels - -Some inputs should move, but their boundary is explicit: - -- Fedora/RPM Fusion/vendor repository packages and system Flatpaks track their - configured release channels so security fixes arrive through `./update`. - Hyprland and Quickshell move only within their qualified series (above). -- T3 Code is deliberately the Nightly application. Its updater verifies the - selected release asset but tracking a prerelease is part of that feature's - contract. -- Android repository metadata selects the current command-line tools, platform, - and build-tools. Downloads are integrity-checked against Google's metadata - and installed transactionally, but the metadata itself is a moving input. -- The forced Firefox 1Password extension URL tracks Mozilla's current signed - extension release. -- Anthropic's small HTTPS bootstrap script is not byte-pinned. It receives the - exact `claude_code_version`, and the bootstrap validates the native payload - against Anthropic's manifest. Pinning the bootstrap itself would require an - owner-maintained checksum and rotation process that the repository does not - currently have. After that install, Claude Code's own updater moves it past - the pin. - -These are not interchangeable with source-build or root-RPM pins. Adding a new -moving installer requires documenting why currency is more important than -byte-for-byte convergence and how a failed update preserves the working -installation. - -## Source build caches - -Source applications reuse a compiler image keyed by the pinned base and build -recipe. The derived image still installs current Fedora RPMs: the base digest -and requested recipe are not a complete description of the resolved toolchain. -The helper captures the derived image's immutable `sha256:` ID under the -builder lock, then runs compilation by that ID. It records all resolved RPM -NEVRAs, `rustc`, Cargo, GCC and C++ compiler versions, authorized source/base/ -recipe inputs, and output binary SHA-256 digests in each deployment's -`.cybexos-build-provenance.json`. `.cybexos-builder` remains the short image ID. - -Cargo's registry and per-application target caches live under -`/var/cache/cybexos-source-builds/`, outside installed application stores and -the ISO payload. Compiled targets are additionally keyed by the actual derived -image ID, so a recreated compiler image does not reuse another toolchain's -object cache; the Cargo registry remains shared. These caches trade local disk space for faster rebuilds; they -are not automatically expired. When no source build is running, an operator -can remove an obsolete recipe directory and its matching -`localhost/cybexos-source-builder:` Podman image. The next required -build recreates them. An unchanged, healthy installed binary still works -offline without either cache. - -The requested inputs and healthy installed deployment allow an unchanged -converge to return without Git, Podman or network access. They do not promise -identical output after deleting and recreating a builder: Fedora security -updates may change the resolved packages. To refresh a toolchain, remove only -an unused builder image as above and perform a required build (a reviewed -source/recipe change or repair); routine convergence does not silently rebuild -intact installations. This retains normal Fedora package currency without -creating a separate compiler pin registry. - -The image assembler exports these records in `applications.json` and the -checksum-covered `source-build-provenance.json` artifact, and retains them in -the offline application RPM. The ISO's SPDX root binds the exported file's -SHA-256 in `sourceInfo`; public assets carry it alongside the current raw RPM, -Flatpak, package-file and build inventories. This describes the actual derived -builder used, rather than only the requested container base. Missing compiler -metadata fails staging before activation and preserves the previous build. - -## Rust toolchain documentation - -New developer-tool installations use rustup's minimal profile plus explicit -rustfmt, Clippy and rust-analyzer components. The shared applications role -applies this to both checkout and ISO installs. Existing components are retained. -Offline Rust documentation is optional: install it with -`rustup component add rust-docs --toolchain nightly-2026-08-23` (use the current -inventory-pinned toolchain after a version change). `rustup doc` then opens the -local documentation. This keeps the compiler and daily development tools in the -default seed without copying the large documentation tree into every home. diff --git a/docs/desktop-improvements-ledger.md b/docs/desktop-improvements-ledger.md deleted file mode 100644 index a32c97a4..00000000 --- a/docs/desktop-improvements-ledger.md +++ /dev/null @@ -1,182 +0,0 @@ -# Desktop improvements implementation ledger - -Snapshot: 2026-10-21. Exact source/image gates, all four ISO scenarios, both English/Dutch checkout -fresh/saved parity cases, pristine offline/native/recovery and scoped native workspace/action regressions -passed for **tested product `d2037af92cb3a47a08a9755e5996d2c4b0cae28d`**. Actual -startup/pixel/performance measurement is **BLOCKED**: four failed attempts, zero accepted samples. -Physical hardware, generic Wayland CSD, first-frame and unsampled interaction limits remain; -**not fully qualified**. - -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at -`bee9d722021301875823e6285a6eef2896b283eb` on `2026-10-21T14:01:19Z` after both required hosted checks -succeeded; origin/main was verified there. The intervening CI-only head -`9974aaee9cb6160ddce86fe84d4a40fc4a75c055` changes no installed payload. Tested artifacts retain d2037af -identity; this documentation update is separate from the tested installed product. - -Implementation started on `feat/desktop-improvements-20261021` at -`3b310d106255558ca213c955ef37ee314e8681f2`; original main was clean/unmodified at discovery, -`6f4bdca3168d46cc76e8047fa94d172dac3cb696`. The workspace correction is -`607bce226d9f85c4e6307567a75cc5cfbc8b1243`; d2037af corrects notification equivalence. Historical results -are never transferred to the new revision. - -## Ownership, controls and contracts - -Actual disjoint T3 Codex delegates implemented compositor, UI, notifications and native titlebars; fresh -independent tasks reviewed integration/corrections with verified `codex/gpt-6.1-sol`, xhigh/priority -selections. Parent owns schema 29, English/Dutch strings, shared install/package policy, qualification -and cleanup. - -| Workstream / owner | Delivered behavior and remaining qualification | -| --- | --- | -| Workspace delegate | Shared/default and Per-monitor local slots 1–10, unique explicit IDs, physical identity, guest reachability and reversible transitions. Exact renamed/numbered opening, named/manual groups, owner closure/reload/restoration passed; physical matrix and first-frame remain unqualified. | -| UI delegate | Bar/overview/launcher/dock consume compositor mapping and restore before focus; Tiled/Scrolling/Floating controls and saved settings lifecycle. Parent's encrypted-US settings/empty-overview pixels reviewed; unsampled interaction/assistive technology and new-artifact pointer behavior unqualified. | -| Native delegate + parent | Pinned Fedora titlebars, themed controls, runtime ELF guard, offline forced rebuild/unchanged convergence and recovery passed. Historical pointer/clear-readd/wrong-build-ID evidence retains earlier source scope; generic negotiated Wayland CSD blocked. | -| Notifications delegate | Source snooze, exact bounded dedupe/count, explicit safe code/link actions, grouped popups and optional private history. Native OPEN/open separation/duplicate transfer/ActionInvoked/restoration passed; overflow/cleaned-label edges remain source/runtime fixtures. | -| Parent | One ISO-authoritative installation/security/recovery contract shared with checkout, migrations, upgrade and owned-file uninstall. Actual captures have exact source/content binding and no unreviewed package deltas; reviewed trust-key versus RPM bookkeeping differences are not byte-identical inventories. | -| Startup delegate + parent | R11 scoped inert review clean; actual startup/pixel/performance measurement BLOCKED after warm01–04, zero accepted samples/comparison/speedup. All leases closed/empty. No further VM series planned; future acceptance requires visibly verified Welcome/portals and eight fresh equivalent boots with current prerequisites. | - -`require("workspaces")` alone owns monitor identity, ordered ID assignments, layouts and minimize state; -UI never allocates IDs. Shared remains the first-install/existing-user default, managed base dwindle. -Per-monitor guests remain reachable; transitions never renumber/merge live workspaces, mirrors own no -assignments, and indistinguishable panels with changed connectors use the recorded deterministic -fallback. Native `user.lua` custom layouts remain owned by the user when `manageDefaultLayout:false`; -only deliberate default selection takes ownership. Explicit equal-default overrides persist until -`set_layout(id,"default")`; named/renamed destinations suspend numbered choices and preserve native state -and mode/manual group provenance through reload. - -Personal choices: `$XDG_CONFIG_HOME/cybexos/workspaces.json`; assignments: -`$XDG_STATE_HOME/cybexos/workspace-assignments.json`; schema-v1 runtime snapshot: -`$XDG_RUNTIME_DIR/cybexos/workspaces.json`. No duplicate shell preference owner or startup push. Settings -→ Workspaces controls mode/layout/titlebars; Bar owns existing dock opt-outs/pins/auto-hide. Super+Ctrl+L -cycles layout; Super+Ctrl+Alt+M minimizes; Super+Ctrl+M restores, including with dock disabled; -Super+Ctrl+Shift+arrows moves displays. Super+L lock/Super+M Slack remain intact. Overview/launcher/dock -restore hidden windows before focus; keyboard/mouse fallback works without titlebars. Full -API/storage/ownership contracts remain in [architecture](desktop-workspace-architecture.md) and -[controls](desktop-workspaces.md). - -Notifications retain one D-Bus owner and existing DND/quiet settings. Session snoozes keep suppressed -arrivals in history without replay. Exact action IDs retain case/whitespace; over 16 actions or 256 code -units per exact ID/cleaned label disables dedupe without altering source/snooze/groups. Visible and -accessible labels share plain-text cleanup. Persistence defaults off, uses bounded atomic private -storage, redacts codes across alternate fields and restores no live actions. Copy/link activation is -explicit, URI-constrained, never automatic or shell-interpolated. See [notification -security/controls](desktop-notifications.md). - -## Provenance and artifact binding - -| Component | Pin and retained contract | -| --- | --- | -| Omarchy | `v4.5.0-rc1`, `03ff1eff3812933f7088484533784d2f63eaf19a`; selected adaptations retain MIT notices; no moving-branch fixes imported. | -| Native titlebars | `v0.2.2`, `379ff910fcce9f0cc9b41f9a87c527997076a417`; patched source plus BSD-3-Clause/MIT licenses, fixed `/usr/lib64/cybexos/titlebars.so`. | -| Fedora compositor | Hyprland `0.56.2-2.fc44.x86_64`, source `efb50993780079460b0cbed1363e2166a2de1d9f`, ELF ID `04b20094d13879e70824418ba363f8dcc3253b34`; Scrolling built in, no compositor upgrade. | -| Shell | Quickshell `0.2.1^git20260209.dacfa9d-5.fc44`; bounded public Qt image-provider identity. | - -Both install paths share recipes, payload admission, personal-data preservation, -authentication/firewall/recovery policy and owned-file uninstall boundaries. Exact Fedora SDK/RPM and -running mapped-ELF identity gate native loading; checksum-cached bounded offline builds remove stale -loadable artifacts on failure. Declarative load persists on each parse; registry-gated callbacks, -independent availability/rendering status and exactly-three-button theme setup remain intact. Exact -Nautilus/Text Editor exclusions are policy, not generic CSD detection. See [native -lifecycle/security/licenses](native-titlebars.md). - -Build root: `/home/john/.local/share/cybexos/images/desktop-20261021-d2037af`, clean build -`20261021T121337Z-87cc6b87`. Testing ISO: `/data/pxe/iso/CybexOS-Live-44-20261021T121337Z-87cc6b87.iso` -(8,063,483,904 bytes). Supported checksum/iVentoy refresh/list/PXE/service checks passed. Exact RPM -filenames/bytes/digests and bounded report paths are in [validation](desktop-validation.md). - -| Binding | SHA-256 | -| --- | --- | -| Clean source content | `81249a8df4fad7e208c1d6322e0cd609fb71e3fec2523bbc31485389d5a56a1c` | -| Testing ISO | `28603ac50e9167f1896d5f0657d171eb8bd3ac429d1a54dbc37540a98b275552` | -| Actual-candidate dependency manifest: 82 RPMs/40,923,680 bytes | `4792413da9a4d624acced296892f0638f428776bc50aea1be195a1cdda9ebefb` | -| Converged installed native plugin | `1142347ed31dcfb67733602b8ec6d88733131e0f8140ad8c3f50c870ef24e878` | - -## Evidence, corrections and historical scope - -Current evidence is mirrored under -`/home/john/.local/share/cybexos/images/desktop-20261021-d2037af/validation/evidence/final-r15-run/`. -Here E denotes that path and V its validation root. MAIN/FINAL paths are historical identities; resolve -expected path/hash/size through the durable maps/history described in validation. `superseded/` retains -older reports. R30 accepted selected evidence durability, not release/runtime acceptance. Current/frozen -and all four failed-attempt inputs are recoverable; ten older precursor source bodies were already -absent before audit, so no complete transitive historical source claim is made. Future execution requires -a separately reviewed retarget. - -Exact Fedora source/image gates passed; validation records counts and log identities. The original -three root skips remain recorded, with two UID1000 reruns and unchanged RealReleaseSigning -(1 pass/0 skips, 1.464s, disposable key/RPM/repo) passing supplements; no waiver or actual product -signing. Signing log SHA-256: `ef8b3320a9f323eda412e8971af29709aede4f93337a472a8a82288ad499e882`. -E/`core-qualification-d2037af.json` aggregates prerequisites, not full release acceptance. R27's 234 -read-only checks excluded English, later parent-verified; no tests/live/PXE/large-artifact rechecks. - -| Retained reports | Material disposition | -| --- | --- | -| `completed-review-records.json`, mapped superseded reports | Early layout/default/group/UI/image/origin issues, toast lifecycle, sealed offline admission/native availability and keyboard/variant correction fixed. Earlier runtime/layout/titlebar/D-Bus/output evidence retains its source scope. | -| `review-records-r21-r22.json` | R15 renamed opening and action-ID alias, R16 manual groups, R18 named provenance, R20 overflow/presentation corrections committed as 607bce2/d2037af; R22 clean within final 13-file scope. Focused/comparative counts and hashes are in validation. | -| `review-record-r26.json`, preserved Foot/focus failures | External restoration/race/metadata/exception/fence and Kitty close-confirmation fixes; corrected unsupported `hl.dsp.window.focus` to `hl.dsp.focus`. R26 clean against all 19 pinned dispatch sites, then actual workspace pass. Earlier failures remain restored/preserved, not converted into passes. | -| `independent-acceptance-r27.json` | Clean completed-evidence audit; English separately verified. Recorded first-frame/hardware/CSD/action-edge/startup limits retained; subsequent teardown documented below. | -| `ci-only-revision.json`, `ci-patch-review-r28.json`, `main-push-result.json` | Hosted `titlebars.py` initially failed FileNotFoundError for absent `patch`; exact sole-path CI environment addition reviewed clean, both required hosted checks SUCCESS, PR #21 merged. No installed payload or gate/security change. | -| Durable-root `independent-retention-r29.json`, `retention-r29-fixes.json`, `independent-retention-r30.json` | R29's missing R27 nine-record/1,344-byte ledger snapshot (P2) and 1,111-byte native terminal supplement (P3) recovered; R30 independently verified both repairs, 14,896 scoped retention checks/1,661 mapped non-RPM hashes, no findings. Not new runtime/release acceptance; validation gives exact paths/hashes and historical-source limits. | -| Mapped historical installation/startup reports | b4dfdf6/75 four ISO and 75 pristine upgrade/native/recovery passes remain historical. b4 English checkout passed/Dutch keyboard failed; R14-cleared `75bc4243134ef93d96f208ab6edbb02e83438d18` fixed locale1 discovery, but 75 Dutch outcome capture/cleanup failed SSH255 and is not parity; English 75 was not rerun after R15. Failed inventory/black-bar/launcher/portal observations remain failed; pixels never establish a product portal defect. | - -Historical implementation sequence: 66b14a2, c5b5c96, 9702355, 629219f, 63f7785, 78903e9, 00f71b4, -b4dfdf6, 75bc424, 607bce2, d2037af. Validation records exact native opening/group and OPEN/open -ownership/count/ActionInvoked/restoration scopes, sole PID/current QML0 journals and limits: -first-frame/configure and full R20 bounds/label cases remain unqualified. - -## Startup: measurement blocked - -Actual startup/pixel/performance measurement is **BLOCKED**: four failed attempts, zero startup -samples/comparison. No optimization/speed claim is justified; no further VM series is planned. - -| Inert tooling snapshot | Scoped result / controlled manifest | -| --- | --- | -| Frozen R5 | 195 inert/eight help, three R4 P2s closed; `6898ed59a0d1efaf9b4b34b2e0bd5f83f280f6df8614cfc0005fbe41271d0739`. Standalone observer remains `51eada68b783068e5ac62305dc45381ba7862a20b84f520d2ba63319164ba80a`. | -| R7 | R6 `json` import/measured `controlledBootPolicy` binding fixed; 237 inert (42 onboarding outside legacy adapters), six adversarial rejections, eight help/80 Python/four payload compiles, old NameError negative; 271 files unchanged. Synthetic failures/adapter exclusions are not passing coverage. `9fd45d16597f4df7871fbc85434efda36aeb610dcd56dfc7adcb21aa17ea40ff`; FINAL/`startup-onboarding-independent-r7.json`. | -| R9 | R8 stage-attribution P2 fixed; 260 supplied inert/116 independent mocked preparations/eight help/104 Python/five payload compiles. Clean inert diagnostic scope only; immutable reviewed manifest `92fba8d77a9c35a669c3437ee344b1e788346eef2553a4aa05fa6bf4e694e9c2`; FINAL/`startup-diagnostics-independent-r9.json`. | -| R11 | R10 LF-separator P2 resolved with literal LF/terminating-LF guard after supported-warning support; 283 inert, seven independent methods/19 delimiter variants, four negative-control methods, eight help/128 Python/five payload compiles. FINAL/`startup-grub-independent-r11.json`; current manifest `e8a7f96eb48a972f912d3e0c61628b3451e5ce83ae11445e0f318e5c69194fe0`. Inert scope only. | - -| Attempt | Observed result / evidence / closed-empty lease | -| --- | --- | -| warm01 | Two deadline-no-action/null-hash outcomes, no input; parent recorded clipped/Welcome-covered picker frames, no product root cause established. Two restoration attempts restored actual SSH/firewall/pre-existing active runtime state. E/`startup-controlled-harness/attempt-d2037af-warm-01` and supplement retained; `ed16ccc887dc40e8a65e12336f66bae3`. | -| warm02 | Initial snapshot root command returned 1; diagnostic omitted output by design, raw output/substep/root cause unknown. R7/core/helper-bound; FINAL/`startup-attempt-warm02-summary.json`: 11 files/2,704,256 bytes, four duplicated evidence files; `cc6a72cf312b44b698b474330909e9da`. `startup-host-context-d2037af-warm02.json` retains exact prerequisites/truthful shared-host context. | -| warm03 | `before.root-boot-policy` bounded stderr proves `ValueError: Invalid or duplicate GRUB environment variable`; offending guest bytes unproven. FINAL/`startup-attempt-warm03-summary.json`: 11 files/2,706,700 bytes; `4f6364e98aec411cacd3ea673e04f551`. Exact E/`startup-controlled-harness/attempt-d2037af-warm-03` copy recorded by durable-root `startup-warm03-emergency-copy.json`. | -| warm04 | Initial snapshot/root policy, packaged reconciliation/readiness validation passed. Parent saw black background/pointer only, could not verify Welcome dialog, returned null: `explicit-observe`, no input. Failed `review-action` before SSH fixture/warm-up/measured boots; cause unknown, no product-regression proof. FINAL/`startup-attempt-warm04-summary.json`: 16 files/2,736,770 bytes; exact `startup-controlled-harness/attempt-d2037af-warm-04`; `e9114b0a47bf45418ee25de82abb4804`. Summary/frame SHA-256 identities are in validation. | - -All source/helper/bound inputs remained unchanged. Warm02/03 stopped before reconciliation/Welcome/SSH -fixture/warm-up; warm02–04 dependencies/staging cleanup passed, no candidate transferred. -FINAL/`grub-environment-format-probe.json` reproduces old-parser rejection of Fedora -`grub2-tools-minimal-1:2.12-66.fc44.x86_64`'s standard warning, not specific guest offending bytes or -warm02's cause. Validation owns full stage/privacy/provenance and R30 retention scope. - -Pixel/readiness acceptance is blocked and comparison/performance unavailable. A future separately scoped -benchmark must visibly verify Welcome/portals, revalidate current exact prerequisites and collect eight -fresh equivalent boots: distinct boot IDs/auth sequences, six bound diagnostic/frame samples, SDK82/full -configuration equivalence and root GRUB evidence. Preserve strict privacy/authentication/configuration -guards; label host-cache-warm QEMU readiness upper bounds including auth/probe/UI delays, no -cold-boot/causal-speedup claim. GRUB/binfmt/portal grace unchanged. - -Representative physical lid/mirrors/docks/two externals/connector ambiguity/scaling/rotation and Secure -Boot, generic negotiated CSD, first-frame/new-artifact pointer/unsampled assistive interaction and full -native notification edges remain unqualified. User may `git pull --ff-only` then `./install` for -own-machine testing; no completed user-machine test or agent rollout is claimed. - -## Cleanup and final documentation - -V/`parent-lease-final-cleanup.json`: MAIN lease `29c5e2febb054008b06ff08cd794e589` released at -`2026-10-21T16:33:05Z`, return 0, closed/remaining empty/errors empty, workspace absent; idle container -`6d814bcad464`, network and workspace removed. V/`final-resource-verification.json`: all 53 recorded -CybexOS leases closed, no task QEMU/developer Quickshell/mounts/MAIN containers, shared services untouched; -no temporary VM/container retained. Ordinary documentation worktree remains until protected PR merge, -then Git removal. This documentation update is separate from tested d2037af artifacts. - -Own b4/75 and baseline 3b roots/PXE copies were removed after archival. Baseline cleanup removed -17 files/10,088,530,860 bytes plus PXE ISO 8,027,873,280 bytes/112-byte sidecar after 11 metadata hash -checks. V/`baseline-final-cleanup.json` and `pxe-after-baseline-removal.json`: iVentoy refresh success, -PXE running/service active, candidate present/baseline absent, no restart. - -V/`retained-inventory-cleanup-snapshot.json` records artifacts 18 files/10,123,452,292 bytes, requested -PXE ISO 8,063,483,904 bytes/112-byte sidecar and V archive 1,774 files/274,027,350 bytes. Reasons and -exact paths are in validation. This cleanup-time snapshot excludes itself and later docs/review/PR -records; final handoff recomputes totals. No completed user-machine test is claimed. Active desktop -rollout/restart/reboot and public release remain unauthorized/unperformed. diff --git a/docs/desktop-notifications.md b/docs/desktop-notifications.md deleted file mode 100644 index 37296924..00000000 --- a/docs/desktop-notifications.md +++ /dev/null @@ -1,374 +0,0 @@ -# Notification refinements - -## Shared settings contract (for schema integration) - -All existing notification preferences keep their values. New properties in -`Common/Settings.qml` use the following defaults; normalize finite numeric -values by rounding and clamping, invalid types to defaults. No migration resets -existing DND, quiet hours, duration, appearance or source choices. - -| Property | Type/default | Normalization | -| --- | --- | --- | -| `notifHistoryPersist` | bool `false` | strict boolean | -| `notifHistoryCount` | int `50` | 10–200 | -| `notifHistoryHours` | int `24` | 1–168 | -| `notifHistoryBytes` | int `262144` | 16384–1048576 | -| `notifDedupeSeconds` | int `10` | 0–60; zero disables | -| `notifBurstLimit` | int `5` | 1–20 admissions per source per rolling 10-second window | -| `notifPopupGroups` | int `3` | 1–5 | -| `notifPopupPerGroup` | int `3` | 1–5 | -| `notifFullscreen` | string `"away"` | `"away"` or `"show"`; invalid → `"away"` | - -Source snoozes are session-scoped (including shell hot reload only when the -singleton survives); they never write source names to settings. Durations are -15 minutes, 1 hour and 8 hours, with explicit unsnooze controls in the center -and settings. They do not survive logout. DND and quiet-hour settings retain -their existing persistence and startup policy. Codes never bypass any quiet -policy; there is no implicit exception for critical senders either. - -Persisted history is separate from settings and disabled by default. Disabling -it deletes the owned history file. Clear all clears memory and the file. The -count, age and byte bounds apply together. The runtime session retains at most -`notifHistoryCount` entries regardless of persistence. - -New English keys requiring Dutch translations (all are passed through I18n): -`Notification history`, `Remember notification history`, -`Store private history across shell restarts and logins`, `Retention`, -`1 hour`, `24 hours`, `7 days`, `History limit`, `50 notifications`, -`100 notifications`, `200 notifications`, `Clear notification history`, -`Source snoozes`, `No sources snoozed`, `Unsnooze`, `Unsnooze all`, -`Snooze 15 minutes`, `Snooze 1 hour`, `Snooze 8 hours`, -`Copy verification code`, `Open link`, `Dismiss group`, `Expand group`, -`Collapse group`, `Repeated %1 times`, `Verification notification`, -`Content redacted`, `History storage unavailable`, `Fullscreen notifications`, -`Use another display or history`, `Show over fullscreen`, `Grouped popups`, -`Burst limit`, `Extra notifications collect in the center`, -`Send grouped preview`, `Send verification preview`. - -## Provenance - -Reference: Omarchy tag `v4.5.0-rc1`, commit -`03ff1eff3812933f7088484533784d2f63eaf19a`, reviewed from the pinned checkout -`/tmp/cybexos-desktop-20261021-upstream`. Read `docs/notifications.md`, the -notification plugin manifest, `Service.qml`, `Store.js`, `Detect.js`, -`Security.js`, `Delivery.js`, `Layout.js`, `Settings.js`, and the notification -test runner and logic fixtures before adaptation. The upstream engine uses -Quickshell NotificationServer/Io/Wayland/Hyprland, its own shell/Commons plugin -loader, theme and settings. CybexOS keeps its existing singleton, theme, -settings, actions and sole D-Bus owner rather than importing that plugin. - -`NotifSmart.js` adapts the bounded keyword-near-code detector and narrow HTTP -URL grammar from upstream `Detect.js` and `Security.js`. Its embedded MIT -notice preserves Omarchy's copyright and the engine's Omapager attribution -(Neil Jagdish Patel, 2026). Other notification admission, grouping and private -storage code is implemented for CybexOS. No later moving-branch changes are -incorporated. Upstream executable hints, automatic click fallbacks, network -favicon fetching, live-popup restore and default code quiet bypass are not -used. No dependency version increase is required. - -Implementation behavior and actual validation evidence are appended below. - -## Using the controls - -Hover a card, or Tab to it in the center, to reveal sender actions, verification -code copy, safe link offers and the three snooze durations. Source snoozes show -an end time and an Unsnooze button in both notification views and Settings → -Notifications. Unsnooze all is available there too. Snoozed, DND and quiet-hour -arrivals stay in the center; they never become a delayed popup queue. - -Exact repeated messages show “Repeated N times”. The window is measured from -the first occurrence, so a continuous repeat cannot keep extending it. Source, -full bounded summary/body, urgency, icon/image content and action identity/labels -must match. Opaque action IDs are compared verbatim, including case and -whitespace. Visible and accessible action labels use the same plain-text -cleanup as the comparison. More than 16 actions, or an action ID or cleaned -label longer than 256 code units, disables deduplication without changing -source grouping or snoozes. Quickshell's transient numeric image handles are compared using -bounded native pixel fingerprints. Different handles containing identical -pixels can merge; different pixels remain separate. A missing module, invalid -handle, unavailable provider or oversized image disables deduplication for that -image. Long inputs that exceed admission bounds and progress-hinted messages -never deduplicate. A D-Bus replacement updates its existing slot and restarts -its displayed lifetime, without increasing the occurrence count. Obsolete -senders cannot close or update a slot transferred to a later duplicate. - -The display-only `webOrigin` hostname is separate from the bounded -`webSourceIdentity` used for deduplication, snoozes, burst limits and groups. -Qualified HTTP(S) senders use canonical lowercase scheme/host and effective -port: `https://chat.example` and `https://chat.example:443` both identify -`https://chat.example:443`; `https://chat.example:8443` and -`http://chat.example:80` are separate sources. IPv4/IPv6 forms are validated, -IPv6 spelling is canonicalized, and a DNS root dot stays distinct in source -identity even though the display hostname omits it. Invalid ports, credentials, -controls, escaped authorities and unsafe/ambiguous numeric hosts are rejected. - -A supplied bare hostname has no known scheme. Its identity is -`unqualified:chat.example`, separate from every qualified URL origin; a supplied -port is retained without inventing a scheme. An explicit origin hint keeps -precedence over a body anchor. If only an actual HTTP(S) origin anchor or legacy -origin prefix supplies the source, its supplied scheme and port are retained. -Snooze controls show the qualified authority (or bare host/port) so two ports -remain distinguishable in the unsnooze list. Existing friendly names, brands -and icon consumers continue to use `webOrigin`. - -History's additive `webSourceIdentity` field is at most 320 characters and -contains only an origin authority, never path, query, fragment, credentials or -an executable link. Both admission and the storage helper validate it. Existing -v1 history with only a hostname migrates to the unqualified namespace; the lost -scheme/port cannot be reconstructed. Invalid or mismatched stored identity -also falls back conservatively to its validated unqualified hostname. Code -redaction includes this field in detection and clears it along with all other -source/display fields. Source snoozes remain session-only; no preference or -shared-schema migration is required. - -Popups group by exact website origin or the native desktop-entry/app-name pair; -friendly brand labels do not combine unrelated websites. There are at most -three groups with three cards each by default, with at most five new popup -admissions per source in a rolling ten-second window. Excess arrivals remain in -the center. The front card and group count show while collapsed; hover or -Expand group reveals the members. Each card has its own dismiss control, and -Dismiss group clears the currently displayed members. Hover and explicit expansion -pause all members. New distinct cards from a held group collect in the center -rather than rearranging the controls beneath the pointer. No held cards replay -when the pointer leaves. All motion follows reduced-motion settings. - -A source deck stays on the display chosen at admission. New decks use the -focused display. The default fullscreen policy routes to another available -display, or directly to history when all displays are fullscreen. Changing -focus does not drag a deck between displays; disconnects and fullscreen changes -reconcile existing decks. Popup surfaces never take keyboard focus, including -when they appear beneath the pointer; mouse actions remain available. Use the -notification center for keyboard navigation. Popup height is bounded to the -screen with scrolling for expanded content. - -The opt-in native regression is `python3 -B tests/notification-focus-live.py`. -It uses a disposable Qt input window and actual Wayland keyboard enter/leave -events to check arrival, hover, and low/normal/critical popups appearing beneath -the pointer. It moves the pointer, injects no keys, closes its own notifications, -and restores the pointer and original window focus. On Hyprland 0.56.2 the old -OnDemand mode fails this check at notification arrival; None preserves focus. -The managed service remains the only Quickshell instance throughout the check. - -Code detection requires a nearby English or Dutch verification keyword and a -numeric or mixed uppercase token. It deliberately excludes product names such -as “VS Code”, ordinary years and unlabelled order numbers. Copy is explicit, -uses `wl-copy --sensitive` with separate arguments, and clears the clipboard -after 60 seconds only if it still contains that code. The clipboard manager -must honor the sensitive hint to avoid retaining it. Link offers show their -actual host, expose the full destination to accessibility, and only open on -explicit activation through Qt. HTTP(S) URLs must pass a narrow ASCII hostname, -port and escape grammar; executable schemes, credentials, control/bidi -characters, numeric-host ambiguities and nested escapes fail closed. No -notification content is interpolated into a command shell. Sender text always -renders as plain text. Attached images are limited to safe local files and -Quickshell in-process images; notification arrivals trigger no icon subprocess -or network favicon request. - -## Storage and integration - -When enabled, history lives at -`$XDG_STATE_HOME/cybexos/notifications/history.json` (fallback -`~/.local/state/cybexos/notifications/history.json`). The directory is 0700 and -the file 0600. One long-lived Python standard-library helper handles JSON-lines -requests. Changes coalesce behind a 250 ms timer, with at most one snapshot in -flight and one dirty flag, so a slow disk cannot accumulate notification -snapshots in the process pipe. Reads have a hard 1 MiB cap; requests have an -8 MiB framing cap; count, age and actual encoded byte limits apply together. -Writes use a private exclusive temporary file, fsync and same-directory atomic -replacement. Owned directory components and file reads do not follow symlinks; -clear removes only the owned history filename. Invalid, corrupt, wrong-version, -oversized or nonprivate files recover as empty and are repaired atomically. -Storage failure leaves session history available and shows a settings hint. - -Only inert, whitelisted text and count/time/urgency fields reach disk. A -code-bearing entry becomes a generic verification placeholder: source labels, -origin, desktop entry, raw/displayed text and every alternate/derived string are -discarded, including any identity that repeats the code. The storage helper -also checks alternate/nested input before whitelisting. It never stores images, -hints, links, executable argv, source fingerprints, D-Bus IDs or action handles. -Restored rows have no live, code or link actions and never create popups. New -arrivals received during the startup read take precedence. Obsolete read replies -after Clear all or a preference change are ignored. Disabling persistence -removes the file without creating storage when it never existed. - -Parent integration owns the Settings properties/schema/translations/package -parity and `Common/qmldir`. Register **ordinary types**, not singletons: -`NotifButton NotifButton.qml` and `NotifSnoozes NotifSnoozes.qml`. The existing -recursive shell payload must include the two new JS modules and -`scripts/notification-history.py`; Python 3 and wl-clipboard are existing -runtime dependencies. The helper is invoked through Python, so it does not -need a separate service or executable installation target. There remains one -existing `Notifs.server` NotificationServer owner. - -The Quickshell 0.2.1 source API was additionally checked at the pinned release: -`src/wayland/toplevel_management/qml.hpp` provides fullscreen state and the -screens where a toplevel is visible, and `src/services/notifications/notification.hpp` -provides replacement property signals and action lifecycle. The installed -Fedora Quickshell `0.2.1^git20260209.dacfa9d-5.fc44` removes `image-data`, -`image_data` and `icon_data` from exposed hints in `notification.cpp:152–154`. -Those hints therefore cannot supply stable attached-image identity. Its public -image provider exposes existing images through `QQuickImageProvider::Image`. -No newer API or dependency upgrade is assumed. `NotifSmart.js` also adapts -`Security.js`'s local-image-path validation under its embedded MIT notice. - -## Native attached-image identity and installation - -`roles/desktop/files/notification-images/` is local MIT-licensed repository -source, with no external source dependency or Quickshell SDK. The -`CybexOS.Images 1.0` module uses the current `QQmlEngine`'s public -`imageProvider("qsimage")` API and a checked `QQuickImageProvider` cast. -Its only accepted input is the exact form -`image://qsimage/<1–20 decimal digits>/<1–20 decimal digits>`. It never opens -files, URLs, other providers or commands. Image/Pixmap providers are supported; -missing, null and unsupported providers return a precise bounded error ID. - -Before canonicalization, both dimensions are capped at 4096, canonical storage -and original QImage storage at 16 MiB. SHA-256 covers a domain separator, -big-endian width/height and RGBA8888 pixels, excluding row padding. Pixel data -and image handles are not retained by the module. Runtime fingerprints are -excluded from persisted history along with all images. No subprocess runs -when a notification arrives. `NotifImageFingerprint.qml` loads synchronously -through a fixed-URL lazy Loader only for valid numeric image handles; it must -stay absent from Common/qmldir. A source preview without the module continues -to receive notifications, with dynamic-image dedupe disabled and a diagnostic -such as `native-module-unavailable` or `provider-unavailable`. - -Both installation paths use the identical `notification-images/build.py` -CMake recipe and native CTest. The checkout role is -`roles/desktop/tasks/notification-images.yml`; the image builder runs -`python3 image/notification_images.py` before `image/package`, which calls -`prepare_notification_images(ROOT, build, payload)`. The recipe uses standard -Fedora CMake, C++ compiler, pkgconf, `qt6-qtbase-devel` and -`qt6-qtdeclarative-devel`; public Qt6 Qml/Quick APIs need no compositor ABI pin. -Installation is `/usr/lib64/qt6/qml/CybexOS/Images`. Source checksums, Qt versions -and installed artifact checksums are recorded in -`/usr/share/cybexos/notification-images/build.json`. A changed recipe, Qt version -or installed artifact triggers rebuilding; valid unchanged artifacts skip the -build. The image packager rejects missing/changed sources, unexpected artifacts, -altered binaries or inconsistent build manifests. The compiler's temporary -directory is removed even on failure. - -Parent integration: import `notification-images.yml` from desktop main.yml; -import/call `prepare_notification_images` adjacent to titlebars in image/package; -run image/notification_images.py before desktop-payload and copy -notification-images.json into delivered artifacts. The RPM needs -`qt6-qtbase-devel qt6-qtdeclarative-devel`, the module under -`/usr/lib64/qt6/qml/CybexOS/`, and its license directory under `%license`. -The existing `/usr/share/cybexos/` file entry covers the recipe and manifest. -Uninstall removes only Images/, the recipe directory and its license directory. -Add `notification-images` to tests/run's Python fixtures. No new schema, setting -or translated user-facing string is introduced by image identity. - -## Validation evidence and limits - -Starting revision: `3b310d106255558ca213c955ef37ee314e8681f2`; implementation is -an uncommitted shared-worktree diff as requested. Parent records the final -integrated tested commit. This agent did not install the live desktop, start -Quickshell, launch a host container/VM, commit, reset or stash work. - -Passed: 114 Node tests across notification-policy, notification-history, -notif-helpers, notif-card, notifications-widget, notification-icon, -accessibility-contracts, indicators, settings-layout, typography, idle-timeouts, -view-churn, brand-icons and format test files. The new policy fixtures execute -actual service functions/subscriptions against fake signals, covering -replacement versus duplicate ownership, progress/source separation, quiet -expiry, burst/output routing, stale actions and restore redaction. The history -fixtures run the actual persistent Python process on disposable directories, -cover restart, corruption, permissions, symlinks, alternate-field redaction, -retention, encoded byte caps and clear/disable. Temporary test directories are -removed by each test. `git diff --check` passes. - -The parent's integrated QML lint reported an unused Quickshell -import in NotificationsPage; it has been removed. Parent source-gate findings -for brand allow-list validation and open-coded duration constants were fixed -without weakening their contracts, and the adjacent tests pass. - -The independent review P2 image-identity fix was compiled and tested in the -parent's existing Fedora 44 container `6d814bcad464` using GCC 16.2.1 and Qt -6.11.2. Native CTest passes (9 behavioral cases plus init/cleanup: 11 QtTest -passes): same/different pixel content under different handles, dimensions, -canonical format equality, row padding, URI rejection without provider fetch, -missing/null/unsupported providers, dimensions/original-storage bounds, -Pixmap and a real QML singleton using its engine's fake public provider. -Five Python artifact-admission/parity cases and 27 focused Node cases pass. -The unchanged second installer invocation reports UNCHANGED. Fedora's -tests/qml-lint passes all 355 QML files plus four harnesses with the native -module installed. Ruff, yamllint, ansible-lint and git diff --check pass. -These native tests establish the public Qt contract; the provider is a fake, -so they do not establish actual Quickshell/D-Bus image behavior. - -Independent review R2 found and fixed a further source collision: display-host -normalization had collapsed origins with different schemes/ports. The focused -fixtures now cover effective/default ports, scheme/nondefault-port/root-dot -separation, ambiguous hostname sources, canonical IPv4/IPv6, hostile origin -input, per-origin dedupe/groups/snoozes, additive v1 history migration and -identity-only code redaction. The notification/adjacent suite passes 130 Node -checks; full Fedora QML lint and helper Ruff pass. Parent reports actual D-Bus -image/replacement/snooze/action and private-history restart/redaction checks -passed before this R2 fix; the new origin routing needs its integrated runtime -rerun. This agent made no VM changes for R2. - -After `c5b5c96d284654bf9eb31766aaff09b098fbb574`, the parent's native -group/fullscreen/output matrix passed its behavior assertions but the same -invocation logged a `screenGroups` binding loop. The first change deferred -hold/focus publication during delegate teardown; a native rerun still failed, -so those lifecycle fixtures did not establish that the loop was fixed. - -The confirmed remaining cycle was `screenGroups` → window visibility → native -window creation → `screenChanged` → `screenGroups`. The old filter read -`PanelWindow.screen`. In the installed Quickshell revision `dacfa9d`, -`src/window/proxywindow.cpp`'s `setVisibleDirect` creates the backing window; -`createWindow` calls `completeWindow`, which unconditionally emits -`screenChanged` after connecting it, even for the same output. Toast selection -now reads `modelData.screen`, the authoritative output supplied by Variants. -Geometry can still read the live window screen without making popup admission -depend on window mapping. No timer, count, group bound or output-routing policy -changes are needed. - -`NotifHolds.js` now keeps owner claims in plain JavaScript state and coalesces -reactive publication through `Qt.callLater`, with idempotent no-ops and disposal -guards. Admission reads the claims immediately, so notifications cannot slip -into a hovered group while publication waits. Old delegates cannot release a -new delegate's hold for the same source. Buttons and cards share deferred focus -claims, preserving the aggregate pause during focus transfers and cancelling -callbacks when a deck dies. Group bounds, identities and countdown animation -state remain unchanged. - -The focused notification/adjacent suite passes 137 Node checks, including a -guard against synchronous reactive writes during the actual service's -holdGroup function and owner/focus-transfer races. The guard fails against the -old c5b5c96 holdGroup teardown. A further regression executes the production -`screenGroups` expression with a forbidden live-screen getter and checks -assigned-output changes, mixed-output groups, missing screens and card order. -It fails against the old filter. This is a dependency-boundary test, not a -simulation of native window mapping. A real Qt6 qmltestrunner regression passes 24 -model rebuild/output-removal/queued-focus destruction cycles with every QML -warning treated as a failure (3 QtTest passes including init/cleanup). It uses -the production queues in a Qt model/delegate graph; it does not construct the -Quickshell PanelWindow/ScriptModel. - -The parent's guarded VM experiment changed only the output predicate on top of -the hold/focus change: the full D-Bus matrix and three complete toast matrices -passed, with a clean invocation journal after each. These exercise grouped -burst bounds, single-output fullscreen suppression, alternate-output routing, -output creation/disconnection and focus preservation. The trap restored the -product source/service and verified the sole qs process. The parent then -transferred the final production source and repeated the full actual D-Bus -matrix plus all three toast/output matrices: exit 0, clean journal after each, -and the product shell restored with only the service-owned qs process. Full -Fedora QML lint passes all 355 files and four harnesses; git diff --check passes. -No VM or service was mutated by this agent for the binding-loop fix. - -The Node and Qt fixtures do not establish native behavior; the separate parent -VM runs supply that evidence for the exercised paths. Untested pointer/keyboard -and assistive-technology interactions, physical multi-monitor/scaling behavior -and installed-product qualification remain outside these checks. Settings -provides Send test, Send grouped preview and Send verification preview using -the same in-shell admission path. The parent owns final source/image gates, -independent review and release qualification against the integrated revision. -The native module remains in the parent's existing container for final lint. -A 92,160-byte matching VM module/recipe/license payload is retained at -`/var/lib/agent-resources/leases/29c5e2febb054008b06ff08cd794e589/workspace/notification-images-native-module.tar` -for the parent's isolated VM qualification, SHA-256 -`9dc18af643be70b4fe42211c667aeb4e9749f5a1cab856f027a75e5fa3fef458`. -Its lease owns cleanup. No host services or live desktop were changed. diff --git a/docs/desktop-startup.md b/docs/desktop-startup.md deleted file mode 100644 index 5e03b692..00000000 --- a/docs/desktop-startup.md +++ /dev/null @@ -1,268 +0,0 @@ -# Startup investigation and measurement - -The tested installed product remains `d2037af92cb3a47a08a9755e5996d2c4b0cae28d`. -Source/image gates, exact artifacts/testing PXE publication, four ISO scenarios, both checkout -parity cases, pristine offline/native/recovery and scoped native tests pass within their separate -scopes. **Actual startup/pixel/performance measurement is BLOCKED:** warm01–04 all failed with -zero startup samples, no comparison and no justified optimization/speed claim. No further VM -series is planned in this task. Hardware, first-frame and generic CSD limits remain; -the product is not fully qualified. This documentation update is separate from -the tested installed-product artifacts. - -Reference inspection uses Omarchy `v4.5.0-rc1`, commit -`03ff1eff3812933f7088484533784d2f63eaf19a`, specifically Limine configuration -and its two binfmt overrides. CybexOS retains GRUB, Plymouth, authentication, -encryption, recovery and Docker socket activation. No GRUB/binfmt/portal-grace -optimization has been made. - -## Investigation and unchanged policy - -Omarchy's one-second Limine value is a menu timeout, not complete boot time. -CybexOS live media's five-second menu does not establish installed GRUB delay. -Installed saved environment, failed-boot behavior, dual-boot detection, menu -access and recovery must be observed before changing policy. - -The actual Fedora 44 systemd-binfmt package orders after the mount and automount -without pulling in the mount. Omarchy's eager-mount hypothesis is structurally -relevant, but a container cannot establish this installed system's critical -path or a repeatable benefit. Binfmt remains enabled for tools and emulation. - -`hyprland-session-start` publishes the complete session environment and starts -the session target before the one-second portal-restart grace. Quickshell -starts through that target before the sleep. Removing it would not by itself -prove a one-second improvement to the first usable desktop. A socket or active -unit also does not establish file-picker/screen-sharing readiness. The grace -remains until a reliable bounded alternative and fallback are measured with -actual portal consumers. - -## Evidence contract - -`python3 scripts/startup-evidence.py capture --help` describes the read-only -capture command on an authorized installation. Capture never reboots, stops -services or changes boot configuration. It retains system/user systemd-analyze -time, blame and critical-chain, selected monotonic unit milestones, session -journals, GRUB environment, binfmt unit text, kernel and exact desktop packages. -Failed or unavailable firmware/loader diagnostics remain failures; missing -values are not zeroes. A long unit duration alone does not prove a desktop -critical-path delay. - -Desktop-ready requires a rendered bar and a successful launcher interaction -with a responsive compositor. Portal-ready separately requires an exercised -file picker and screen-sharing selection/Start with valid streams and confirmed -session cleanup. Retain boot-relative observer times, protocol results and -reviewed frames. Human/automated authentication, diagnostic/probe scheduling -and deliberate UI delays must be reported with their timing meaning; do not -silently subtract them from a different milestone. - -The ordinary comparator needs at least three distinct boots per revision on -the same hardware/configuration, exact kernel/dependencies and genuinely -observed milestones. It rejects repeated boot IDs, missing observations and -mixed dependencies even if labels match. Label cold/warm and first/normal -login separately; first-login seeding cannot stand in for normal login. Exact -source, package, monitor, encryption, power and bootloader state belong in the -evidence. Caller-supplied observations still require retained independent -review; the comparison command cannot prove their truth alone. - -## Preserved failed observations - -| Run | Observed evidence | Qualification | -| --- | --- | --- | -| Original baseline 3b310d1 probes | Three probes failed inventory capture and retained no usable UI frames. | Failed/unqualified; preserved. | -| Superseded 63f7785 first encrypted login | systemd reported 27.949s: kernel 0.626s, initrd 18.853s, userspace 8.469s; graphical.target at 8.442s of userspace. Authentication, first-login work and console test-access bootstrap were present. The initial bar frame was black; launcher typing rendered a responsive bar. One Escape cleared its query without dismissing it, obscuring the picker. | Diagnostic only; no desktop-ready or normal-login sample. Picker/ScreenCast did not complete. A long ldconfig unit did not prove recurring delay. | -| Historical b4dfdf6 encrypted observer | Inventory and fixed-path root hashes passed, launcher dismissal and normal-shell audit passed. File picker rendered but Escape returned error 2. ScreenCast reached CreateSession=0, then SelectSources remained pending with unconfirmed cleanup. | Failed/unqualified; no readiness sample or UI attestation. | -| Historical 75bc424 encrypted US observer | Inventory/root fixed-path hashes and two-Escape launcher dismissal passed. File picker rendered large. Inspected actions exceeded the 9/10-second deadlines. SelectSources/source-picker pixels rendered, but selection and Session.Close failed and cleanup was not accepted. | Failed/unqualified; partial visible UI is not portal readiness. | -| Exact d2037af controlled attempt `d2037af-warm-01` | Failed during baseline preparation. Two deadline-no-action outcomes, no input sent. File-picker header/Cancel clipped offscreen; SelectSources covered by first-run Welcome. | Failed/unqualified with zero accepted samples; no readiness timings/comparison/speedup. Pixels do not establish a product root cause. Restoration passed; lease closed/empty. | -| Exact d2037af controlled attempt `d2037af-warm-02` | Initial onboarding snapshot failed before reconciliation/Welcome/SSH fixture/warm-up. Root command returned 1; retained diagnostic omits command output by design, raw output unavailable. Exact substep/root cause unknown. | No input/context/measured boot/accepted sample/comparison/readiness timing. Candidate not transferred; source/helpers/bindings unchanged; lease closed/empty. | -| Exact d2037af controlled attempt `d2037af-warm-03` | Initial snapshot failed at `before.root-boot-policy`; bounded stderr proves `ValueError: Invalid or duplicate GRUB environment variable`. Exact offending guest bytes unretained. | No input/context/samples/timing/comparison; no candidate transferred, source/helpers/bindings unchanged, lease closed/empty. Warm02's cause remains unknown. | -| Exact d2037af controlled attempt `d2037af-warm-04` | Initial snapshot including root boot policy, packaged reconciliation and readiness validation passed. Parent's Welcome frame review saw black background/pointer only; dialog unverified, null response produced `explicit-observe` and no input. | Failed `review-action` before SSH fixture/warm-up/measured boots; zero startup samples. Black-frame cause unknown; no product-regression proof. Lease closed/empty; final measurement blocked. | - -Pinned backend inspection explains the observer assumptions: Hyprland portal -1.4.1 opens its source picker during SelectSources, rather than only Start; -GTK portal 1.15.3 maps Escape/dialog-close to code 2 and explicit Cancel to code -1. The observed failures do not classify a product portal defect. A corrected -observer must interact at SelectSources within its deadlines, use deliberate -file selection or explicit Cancel, complete successful Start/streams and -confirm Close/cleanup. It cannot relabel timeout, code 2 or missing cleanup as -success. All previous failed evidence remains intact. - -The external observer now enforces request/frame context, two-step launcher -dismissal, partial-failure preservation, cleanup gating and no input after -terminal events. Its inert review evidence and historical wrapper findings -are centralized in [desktop validation](desktop-validation.md); they establish -tooling behavior, not portal pixels or actual startup samples. - -## Controlled experiment and failed attempts - -The original baseline and candidate inventories differ by 82 native SDK -packages. Historical pristine offline-upgrade success does not make those -inventories equivalent for performance comparison. The retained controlled -wrapper specifies preparation with only the exact verified 82-package closure -before baseline observations; the pristine upgrade/recovery gate remains a -separate test. - -After 155 phase-1 inert tests, the d2037af startup retarget's fresh independent -R5 review is clean: all three R4 P2s in standalone idle/config validation, -stdout-lock deadline handling and stale standalone-pass handling are closed. -The review passed 195 inert tests and eight help checks, and independently -reproduced the archived failures. `startup-retarget-independent-r5.json` and -the frozen R5 controlled/observer manifest pins are recorded in validation. -These tooling results do not approve subsequent onboarding changes. The completed core aggregate passes all four -ISO scenarios, both checkout cases, pristine offline/native/recovery and -targeted native prerequisites. R27's 234 consistency checks cover completed -evidence excluding English, subsequently verified by the parent. These records -remain separate from readiness qualification. - -Actual attempt `startup-controlled-harness/attempt-d2037af-warm-01`, in the -durable evidence mirror defined in validation, failed during baseline -preparation before any accepted sample. Its two -sidecar outcomes are `deadline-no-action`, with null action hashes and no input -sent. The parent recorded offscreen file-picker header/Cancel controls and a -SelectSources picker covered by first-run Welcome. These failed fixture pixels -do not establish a product root cause or portal defect. R5's clean review and -unchanged source/helper/bound inputs retain their separate scope. - -Two restoration attempts report restored; `fixture-transport.json` in the -main evidence and failure supplement restores the actual original SSH/firewall -state, including pre-existing active/runtime state. The parent verified lease -`ed16ccc887dc40e8a65e12336f66bae3` closed/check empty. The whole attempt and -failure supplement remain retained. Validation centralizes the bounded record -paths and restoration detail. This failed run supplies no readiness timings, -comparison or speedup. Overall teardown is recorded separately in validation. - -Fresh scoped R7 closed R6's missing top-level `json` import and measured -`controlledBootPolicy` preparation binding. Its 237 inert tests include 42 onboarding -tests outside legacy adapters; six adversarial cases were rejected, eight help -checks and 80 Python/four generated-payload compiles passed, and the old NameError -negative control reproduced. All 271 inventoried files were unchanged. Synthetic -fixture failures and adapter exclusions supply no passing coverage. This is -inert tooling review only; exact manifests/report are in validation. - -The fresh R7/core/helper-bound warm02 attempt failed at the initial onboarding -snapshot, before reconciliation, Welcome callback, SSH fixture or warm-up. -The root command returned 1. Its retained diagnostic omits command output by -design (`include_output=False`); raw output is unavailable, so the exact snapshot -substep/root cause is unknown. No product root cause is established. The exact -82-dependency fixture and staging cleanup were prepared; candidate payload was -not transferred. No input, sidecar context, measured boot, accepted sample, -comparison or readiness timing resulted. Source/helper/bound inputs were unchanged. -`startup-attempt-warm02-summary.json` records 11 files/2,704,256 bytes, including -four evidence files duplicated in the failure supplement; lease -`cc6a72cf312b44b698b474330909e9da` is closed/check empty. The truthful host-context -record and exact prerequisites are centralized in validation; shared workloads -and launch load are context, not sustained-idle proof. Warm01 remains unchanged. - -R8's diagnostic stage-attribution P2 is fixed; fresh R9 is scoped clean with -260 supplied inert tests, 116 independent mocked preparations, eight help checks, -104 Python/five payload compiles. FINAL/`startup-diagnostics-independent-r9.json` -pins the immutable reviewed manifest recorded in validation. This is inert -diagnostic review only; original exceptions and strict guards remain preserved. - -Actual warm03 failed at `before.root-boot-policy` during initial onboarding -snapshot, before reconciliation/Welcome/SSH fixture/warm-up. Failed-stage bounded -redacted stderr proves `ValueError: Invalid or duplicate GRUB environment variable`; -exact offending guest bytes were not retained. Outer run omitted-output placeholders -do not contradict the nested captured diagnostic. Dependencies/staging cleanup -passed; no candidate transferred or input/context/measured boot/sample. Source/helper/bound inputs stayed unchanged. FINAL/`startup-attempt-warm03-summary.json` -records 11 files/2,706,700 bytes; lease `4f6364e98aec411cacd3ea673e04f551` closed/check -empty. Its exact attempt is copied under the durable validation root's -`evidence/final-r15-run/startup-controlled-harness/attempt-d2037af-warm-03`; -`startup-warm03-emergency-copy.json` there records it. - -FINAL/`grub-environment-format-probe.json` reproduces old-parser rejection of -a standard warning line from Fedora `grub2-tools-minimal-1:2.12-66.fc44.x86_64`. -This is supported-format reproduction, not proof of particular guest bytes. -Warm02's actual cause remains unknown; warm01/02 evidence is unchanged. - -R10 found an LF-separator P2 after supported-warning support. Literal LF -boundaries and a terminating-LF guard fixed it; fresh R11 is clean: 283 supplied -inert tests, seven independent methods/19 delimiter variants, four negative-control -methods, eight help/128 Python/five payload compiles. FINAL/ -`startup-grub-independent-r11.json` pins current controlled manifest -`e8a7f96eb48a972f912d3e0c61628b3451e5ce83ae11445e0f318e5c69194fe0`. -Strict policy/hash/authentication/comparison guards remain. This is inert review only. - -Actual warm04 passed the baseline initial snapshot including `before.root-boot-policy`, -packaged reconciliation and readiness validation. The parent reviewed a Welcome -frame containing black background and pointer only, could not verify a dialog -and explicitly returned null. Its outcome was `explicit-observe`; no input was sent. -The run failed at `review-action` before SSH fixture, warm-up or measured boots. -One Welcome review context, zero portal contexts/startup samples; dependencies/staging cleanup passed, candidate not transferred, source/helpers/bound inputs -unchanged. Black-frame cause is unknown; no product regression is established. - -FINAL/`startup-attempt-warm04-summary.json` records 16 files/2,736,770 bytes; -summary SHA-256 `0c5d017a9b3b3d143369680d461dc42968d8f53c001908ec15f1e7128ab2b741`. -The exact directory is `startup-controlled-harness/attempt-d2037af-warm-04`; -its `evidence/onboarding-preparation/welcome-review.png` SHA-256 is -`491f31f9d3c41aed91a5756b818bf8828a2f1c3acd70178be2988aa4485f7907`. -Lease `e9114b0a47bf45418ee25de82abb4804` closed/check empty. Warm02's cause -remains unknown; warm03's exact offending bytes remain unproven. All failed -attempts retain their distinct scope; no later tooling pass converts them to samples. - -Startup attempts are stopped. No further VM series is planned in this task. -Actual startup/pixel/performance measurement remains **BLOCKED**. Diagnostic -privacy, named read-only stages, bounded credential-redacted output, original -exceptions and no retained argv/input/environment remain required. - -A future controlled experiment requires one unmeasured warm-up plus three measured -boots on each side: eight distinct actual boot IDs with strictly increasing -positive integer start sequences. Authentication records must match their -captured boot ID, expected role and exact input/transport record. All six -measured samples must bind to their captured diagnostics and reviewed frame -results; replayed authentication or relabelled captures fail closed. Console -SSH bootstrap on a measured boot invalidates it. - -The complete package/configuration comparator remains in force, with only the -two reviewed first-party package exemptions and the reviewed schema 28→29/nine-default settings projection. The prepared SDK inventory and recorded -SSH/firewall fixture must match all observations exactly. All ten root-hash -paths must be present, with required files retained. Supplemental same-boot -root GRUB evidence includes environment bytes, configuration and bounded BLS -entries; pending menu/previous/next/recovery choices block comparison even if -identical across all samples. The reader never clears or edits those choices. -Original unavailable diagnostics remain visible. - -Preparation uses verified, root-sealed dependency-only transport, no trust-key -import and disabled repositories. No candidate payload is transferred before -baseline observations. The unchanged upgrader runs once with candidate-bound -inputs and recovery creation, then candidate observations and recovery checks -follow. SSH/firewall restoration and managed teardown remain required on -success, failure and interruption. An unverified restoration remains a failure. - -## Final blocked result and future acceptance - -If accepted, the experiment must be labelled **sequential host-cache-warm QEMU -stop/start observed readiness upper bounds**, normal login, SDK82-prepared -baseline, explicit SSH fixture. Bounds include authentication, probes and UI -delays. Report median/minimum/maximum and the actual idle test window/host load. -This does not establish representative cold boot, earliest usable frame, -physical-hardware behavior or a causal speedup. Unchanged startup policy alone -does not prove equal boot behavior. An equivalent series can still fail. - -Final disposition: observations failed with zero accepted startup samples across -four attempts; pixel/readiness acceptance is blocked; comparison/performance -results are unavailable. There are no pending startup measurement slots to fill -in this task and no further VM series is planned. - -A future separately scoped benchmark must visibly verify Welcome and both portal -consumers, then collect eight fresh equivalent boots with revalidated current -source/helper/artifact prerequisites and a separately reviewed retarget. Preserve the -authentication, package/configuration, GRUB and six measured-sample diagnostic/frame bindings above. -Representative physical timing and lid/dock/display coverage remain blocked. - -The [validation report](desktop-validation.md) records exact installed/native status, -failed-evidence identities, R30 scoped retention acceptance and completed teardown. -Both R29 repairs were independently verified. Current/frozen sources and all four -failed-attempt inputs are recoverable; ten older precursor source bodies were -already absent before audit, so complete transitive historical source is not claimed. -Original absolute paths are historical map-resolution identities, not a runnable -environment. Baseline 3b and b4/75 payload roots/testing ISOs were removed after -archival; final d2037af testing deliverables and bounded diagnostics remain. -All 53 recorded leases are closed, MAIN's workspace absent and no temporary VM/container -retained; shared services untouched. Validation qualifies the cleanup-time inventory -snapshot, which excludes later documentation/review/PR records. - -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at -`bee9d722021301875823e6285a6eef2896b283eb` on `2026-10-21T14:01:19Z`, both -required hosted checks successful and origin/main verified. CI-only head -9974aaee adds `patch` to the Fedora CI action; tested installed artifacts remain -d2037af. Active user desktop rollout/restart/reboot and public release remain -unauthorized. This documentation update is separate from the tested artifacts. diff --git a/docs/desktop-validation.md b/docs/desktop-validation.md deleted file mode 100644 index 30558c04..00000000 --- a/docs/desktop-validation.md +++ /dev/null @@ -1,304 +0,0 @@ -# Desktop improvement qualification - -Snapshot: 2026-10-21. **Tested installed product: `d2037af92cb3a47a08a9755e5996d2c4b0cae28d`.** -Exact source/image gates, four ISO scenarios, English/Dutch checkout fresh/saved parity, pristine -offline upgrade/native rebuild/recovery and scoped native workspace/action regressions passed. **Not -fully qualified:** these are QEMU/fixture results. Actual startup/pixel/performance measurement is -**BLOCKED** after four failed attempts, with zero accepted samples; hardware, generic Wayland CSD, -first-frame and unsampled interaction limits remain. - -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at bee9d722 on -`2026-10-21T14:01:19Z`, after both required hosted checks succeeded; origin/main was verified there. -The later CI-only fix and merge do not change the identity of the d2037af artifacts. This -documentation update is separate from those tested installed-product artifacts. - -## Revisions, artifacts and evidence - -| Role | Exact revision | -| --- | --- | -| Tested product/artifacts | `d2037af92cb3a47a08a9755e5996d2c4b0cae28d` | -| CI-only PR head: adds `patch` solely to `.github/actions/fedora-source-environment/action.yml` | `9974aaee9cb6160ddce86fe84d4a40fc4a75c055` | -| Merged, verified origin/main | `bee9d722021301875823e6285a6eef2896b283eb` | -| Implementation start | `3b310d106255558ca213c955ef37ee314e8681f2` | -| Original main, clean/unmodified at discovery | `6f4bdca3168d46cc76e8047fa94d172dac3cb696` | -| R15/R16/R18 workspace correction, included in tested product | `607bce226d9f85c4e6307567a75cc5cfbc8b1243` | - -Build root: `/home/john/.local/share/cybexos/images/desktop-20261021-d2037af`. Clean build -`20261021T121337Z-87cc6b87`, UTC `2026-10-21T12:13:37.650803+00:00`, binds 1,432 source files to -content SHA-256 `81249a8df4fad7e208c1d6322e0cd609fb71e3fec2523bbc31485389d5a56a1c`. Checkout source -archive SHA-256: `d25860faa4e72d5655474408b03195b128fda6adeba2eff6972c21f613510e7a`. - -Artifacts below are under the build root's `artifacts/`. Digests come from its `SHA256SUMS` and -recorded bindings; sizes are recorded stat values. - -| Artifact | Bytes | SHA-256 | -| --- | --- | --- | -| `CybexOS-Live-44-20261021T121337Z-87cc6b87.iso` | 8,063,483,904 | `28603ac50e9167f1896d5f0657d171eb8bd3ac429d1a54dbc37540a98b275552` | -| `cybexos-desktop-0.0.0~dev-1.20261021121337.gd2037af92cb3.fc44.x86_64.rpm` | 30,146,729 | `b7cf0a08ff5621f1b661b58ae88bf03551da8cea5cc4c739b9383b7c98930ae7` | -| `cybexos-offline-apps-1.5.1-1.fc44.x86_64.rpm` | 1,639,333,037 | `9d28269b1eec5fbff08d684f5098a8902c22b344f0eb17368dbdd97968b466d9` | -| `build-provenance.json` | 335 | `3c7d904f6bf0fd3a15f23a77ae9debed7481b592f24d8c70b7d4b382e878436d` | - -Testing ISO: `/data/pxe/iso/CybexOS-Live-44-20261021T121337Z-87cc6b87.iso`, with the same ISO -bytes/hash. Supported publication completed checksum, iVentoy refresh/list/PXE/service checks. This -is testing publication, not a public release. `pxe-publication.json` is publisher text despite its -suffix; R27 audited that record, not raw API responses or a new live PXE check. - -The durable root is `/home/john/.local/share/cybexos/images/desktop-20261021-d2037af/validation`. -Here **V** denotes that root; **E** is its `evidence/final-r15-run/` mirror. Original **MAIN** was -`/var/lib/agent-resources/leases/29c5e2febb054008b06ff08cd794e589/workspace`; **FINAL** was -`MAIN/final-r15-run`; that disposable workspace is now absent. Relative reports resolve under E -unless marked historical, FINAL or V. Original absolute paths are historical identities, not a -runnable environment. Resolve each original path plus expected SHA-256 and byte count through -V/`archive-map.json`, `archive-history.json`, `superseded-archive-map.json` and sealed `maps/`; -never substitute a newer file by name or rewrite hash-bound evidence. Older reports are under -`superseded/`. R30 accepted selected evidence durability within the limits below; future execution -requires a separately reviewed retarget. - -`artifact-bindings-d2037af.json` and `dependency-verification.json` bind both candidate RPMs to -`dependency-rpms-d2037af/manifest.json`, SHA-256 -`4792413da9a4d624acced296892f0638f428776bc50aea1be195a1cdda9ebefb`: 82 exact RPMs, 40,923,680 bytes. -Offline payload version remains 1.5.1. The historical pristine-upgrade baseline ISO SHA-256 was -`0fdd68f925c64f24323385b40e6383c5f5704fbcdb19581c5d134ee48b945a2d`; its payload was removed -after metadata archival. - -Qualified Fedora 44: Hyprland `0.56.2-2.fc44.x86_64`, source -`efb50993780079460b0cbed1363e2166a2de1d9f`, ELF build ID `04b20094d13879e70824418ba363f8dcc3253b34`; -Quickshell `0.2.1^git20260209.dacfa9d-5.fc44`. Earlier GPU fixtures used KVM, virtio-vga, llvmpipe -and disposable virtual outputs, not representative physical displays. - -## Completed actual qualification - -| Acceptance | Result and reproducible report | -| --- | --- | -| Exact source gate | Passed all 17 stages: 1,451 Node tests/148 files, 46 Python suites, 355 QML files/four harnesses with actual Qt/runtime checks, 233 Lua assertions. `gates-d2037af/results.json`, `source.log`. | -| Exact image gate | Passed: 30 application tests, 559 fixtures with three original skips, 14 installer Node checks. `gates-d2037af/image.log`; all three skips subsequently passed separate supplements below. | -| Four ISO scenarios | Passed within QEMU UEFI/outbound-blocked scope. `qualification-{plain,encrypted}-{us,nl}-evidence/qualification.json` and fresh/saved outcomes. Physical hardware/Secure Boot excluded. | -| Both checkout languages | Passed full graphical install, exact source/content/archive, saved choices and native settings lifecycle. `checkout-plain-{us,nl}-evidence/{checkout-qualification,parity-fresh,parity-saved}.json`: every profile has `differences:{}` and no unreviewed package deltas. English was separately verified by the parent after R27; its lease closed/empty. | -| Pristine offline upgrade/native/recovery | Passed guest signatures, one offline transaction, forced native build, one-line unchanged convergence, candidate runtime and recovery boot/restore. `qualification-offline-upgrade-evidence/{qualification,offline-upgrade-transaction,native-offline-rebuild,native-candidate-runtime}.json`. No SDK preinstallation into pristine baseline. | -| Targeted native workspace/actions | Passed within the stable observations and instrumentation scope below. `qualification-plain-us-evidence/{r15-workspace-rename-native,notification-actions-native}.json`. | -| Core prerequisites / independent audit | `core-qualification-d2037af.json` passed within startup-fixture prerequisite scope. `independent-acceptance-r27.json`: 234 read-only consistency checks, zero failures/findings, no tests executed; English excluded and subsequently parent-verified. Neither grants full project/release acceptance. | -| Main integration | Complete: `main-push-result.json`, `ci-only-revision.json`, `ci-patch-review-r28.json`. Both required checks SUCCESS: Fedora 44 source contract; Image tooling, installer fixtures and account isolation. | - -All ISO cases passed graphical installation, unchanged unused disk, boot without ISO, selected -locale/timezone/keyboard, desktop parity, live cleanup, SELinux Enforcing, password-required sudo -and fresh/saved settings lifecycle. Plain cases passed password login/plain Btrfs. Both encrypted -cases passed verified-encryption autologin, initial keyring unlock, synthetic-secret persistence -across cold reboot, authenticated logout/crash/manager recovery, missing-cache locked-keyring -protection, fallback password unlock and consumed failed-autologin attempt followed by -password-login recovery. These are not startup samples. Checkout package exceptions are reviewed -trust-key records versus ISO desktop/offline RPM bookkeeping; outcome parity does not mean -byte-identical inventories. - -Upgrade records bind newer desktop EVR, offline-apps 1.4.0→1.5.1 and recovery point -`20261021T125122Z-5063`. Forced output was `CHANGED: native titlebars v0.2.2 for -0.56.2-2.fc44.x86_64`, followed by one-line `UNCHANGED: native titlebars v0.2.2`. Converged plugin -SHA-256: `1142347ed31dcfb67733602b8ec6d88733131e0f8140ad8c3f50c870ef24e878`. The candidate snapshot -proves exact provenance, loaded hyprbars, true Scrolling/titlebar capabilities, empty error and -workspace 1 Floating on Virtual-1; it does not requalify pointer controls or -first-frame/client-configure behavior. Two SSH-255 poweroff diagnostics remain as -`root-failure-{1,2}.json` beside the final passed upgrade report. Offline transport retains bounded -metadata/hash checks, installed-trust signatures, root-only no-follow sealed staging, disabled -repositories and one transaction after recovery creation. Startup's SDK82-prepared baseline is a -separate experiment, not the pristine gate. - -| Gate log identity | SHA-256 | -| --- | --- | -| `gates-d2037af/source.log` | `1b917ab451a0de782773114b454556da1cd0d224751df4af9b315dc054406e1c` | -| `gates-d2037af/image.log` | `3f3e54a73b31568c321d209a402b0c612b2e4c864115deaf074a685e3a0b0be1` | -| `gates-d2037af/real-release-signing-supplement.log` | `ef8b3320a9f323eda412e8971af29709aede4f93337a472a8a82288ad499e882` | - -The original root run still records three skips. UID1000 reruns in `exact/candidate-r15` passed -publication permission staging and seeded-account keys/server/one-time-key behavior -(`gates-d2037af/unprivileged-supplement.log`). Unchanged RealReleaseSigning then passed one -test/zero skips in 1.464s after adding `rpm-sign`, `createrepo_c`, `createrepo_c-libs`, `drpm` only -inside the leased gate container. Its disposable key/subkey/RPM/repository directories were verified -absent; source stayed clean (`gates-d2037af/real-release-signing-results.json`). All skips have -passing supplements, not waivers or rewritten original results. No actual product -signing/public-release publication occurred or was authorized. - -## Native and UI evidence limits - -Workspace stable observations passed renamed `research-r15` opening tiled without mode tags, -numbered opening floating with ownership tags, named-group provenance through new -members/original-owner closure/reload/return to numbered Tiled, and manual tiled/floating groups -retaining choices as members joined and reloaded. Preferences/name were restored, test clients -absent, snapshot error empty and terminal fence set. This is not first-frame/client-configure proof. - -The instrumented notification test kept `OPEN`/`open` distinct, transferred the lowercase duplicate -to sender 3/count 2, emitted exact ActionInvoked `[1,"OPEN"]` and `[3,"open"]`, and expired only -superseded lowercase sender 2 before cleanup. No settings were written. Normal -`roles/desktop/files/quickshell/shell.qml` bytes/metadata/timestamps were restored; -original/restored SHA-256: `287edb8c3f4d5fb594e7927f1a5d3b79ad36c1012e251bbad2a2c39d89b3804b`. -Recorded boundaries had sole managed qs PID, checked nonempty current journals and zero QML errors; -entries/probe files cleared. Guest-local terminal state was retained until disposable teardown. R20 -action-count/length and cleaned-label/accessible-presentation edges remain source/runtime fixtures, -not this native test. - -`settings-visual-review.json` records parent's encrypted-US 1280×800 workspace settings, -notification top/grouped-popup/lower history/source-snooze controls and empty overview. Drag frames -covered lower content missed by wheel-only capture. The parent's frame review covers those views; -unsampled interactions, assistive-technology behavior and first-frame qualification remain open. - -Earlier native tests covered all layouts, local navigation/moves, guests, minimize/restore/reload, -custom defaults/overrides and manual groups; a 1600×1000 virtual output at scale 1.25/90° kept -geometry inside usable bounds. Historical titlebar compilation/offline convergence, declarative -reload, clear/re-add, drag/maximize/minimize/close and recompiled wrong-build-ID rejection passed. -The earlier byte-edit guard probe was invalid and supplies no acceptance. Historical writer -JSON/0700/ECHILD success required the exact bounded token and four behavioral regressions. Earlier -D-Bus/source/image separation, replacement/snooze/DND/redaction/restart and three -grouped-burst/fullscreen/output create/disconnect matrices passed after the toast-loop fix. These -predate later corrections; they do not requalify this artifact's pointer, mixed-client CSD, full -notification edge or physical/first-frame behavior. - -## Startup measurement: blocked - -Actual startup/pixel/performance measurement is **BLOCKED**. All four controlled attempts failed -with zero startup samples; no comparison, optimization or speed claim is justified. -Source/helper/bound inputs remained unchanged in each run. No further VM series is planned in this -task. - -| Attempt | Observed failure and limit | Retention / lease | -| --- | --- | --- | -| `d2037af-warm-01` | Baseline preparation failed, `actualAcceptanceClaim:false`; both `portal-actions/00000{1,2}-outcome.json` records were `deadline-no-action`, null action hashes, no input. Parent recorded clipped file-picker header/Cancel and SelectSources covered by first-run Welcome; pixels establish no product root cause. | Whole attempt/supplement retained, two restoration attempts passed; lease `ed16ccc887dc40e8a65e12336f66bae3` closed/check empty. | -| `d2037af-warm-02` | Initial onboarding snapshot root command returned 1. Retained diagnostic omits output by design (`include_output=False`); raw output, exact substep and root cause remain unknown. | FINAL/`startup-attempt-warm02-summary.json`: 11 files/2,704,256 bytes, four evidence files duplicated in supplement; lease `cc6a72cf312b44b698b474330909e9da` closed/check empty. | -| `d2037af-warm-03` | Initial snapshot failed at `before.root-boot-policy`. Bounded redacted failed-stage stderr proves `ValueError: Invalid or duplicate GRUB environment variable`; exact offending guest bytes unretained. Outer run output placeholders remain omitted output. | FINAL/`startup-attempt-warm03-summary.json`: 11 files/2,706,700 bytes, four duplicated evidence files; lease `4f6364e98aec411cacd3ea673e04f551` closed/check empty. | -| `d2037af-warm-04` | Initial snapshot including `before.root-boot-policy`, packaged reconciliation and readiness validation passed. Actual Welcome frame showed black background and pointer only; parent could not verify a dialog and returned null: `explicit-observe`, no input. Failed at `review-action` before SSH fixture/warm-up/measured boots; black-frame cause unknown, no proof of product regression. | FINAL/`startup-attempt-warm04-summary.json`: 16 files/2,736,770 bytes; lease `e9114b0a47bf45418ee25de82abb4804` closed/check empty. | - -Attempt directories are `startup-controlled-harness/attempt-d2037af-warm-0{1,2,3,4}`. Warm01's -`evidence/fixture-transport.json` and failure-supplement copy restore original SSH/firewall state: -enablement disabled/permanent SSH false, preserving originally active/runtime SSH true. Warm02/03 -stopped before reconciliation, Welcome callback, SSH fixture or warm-up: exact 82-dependency -fixtures/staging cleanup passed, no candidate transferred, no input/context/measured boot/sample. - -Warm01 launch load 0.95/1.66/2.40, no other task build/install VMs and remote hosted CI are context. -FINAL/`startup-host-context-d2037af-warm02.json` binds R7/core/helpers and launch load -0.828125/0.81640625/1.03125, finished task build/install VMs and running shared persistent -workloads. Neither record proves sustained idle or readiness. Warm01/02 evidence remains unchanged. - -Warm03's exact attempt is copied under E/`startup-controlled-harness/attempt-d2037af-warm-03`; -V/`startup-warm03-emergency-copy.json` records all 11 files/2,706,700 bytes; R30 later verified -recoverability of all four attempts. FINAL/`grub-environment-format-probe.json` -records an executable-format reproduction with Fedora `grub2-tools-minimal-1:2.12-66.fc44.x86_64`: -its standard warning line is rejected by the old parser. This proves supported-format rejection, not -the exact guest bytes behind warm03, and supplies no explanation for warm02. - -Warm04's exact original directory is FINAL/`startup-controlled-harness/attempt-d2037af-warm-04`. -Summary SHA-256: `0c5d017a9b3b3d143369680d461dc42968d8f53c001908ec15f1e7128ab2b741`; its -`evidence/onboarding-preparation/welcome-review.png` frame SHA-256: -`491f31f9d3c41aed91a5756b818bf8828a2f1c3acd70178be2988aa4485f7907`. The five evidence files, -including the frame, are duplicated in its failure supplement. One Welcome review context, zero -portal sidecar contexts/input/startup samples; dependency preparation/staging cleanup passed, no -candidate transferred. Warm02's cause remains unknown; warm03's offending bytes remain unproven. - -| Frozen tooling review | Scoped result / controlled manifest | -| --- | --- | -| R5: `startup-retarget-independent-r5.json` | Three R4 P2s closed; 195 inert/eight help checks, archived failures reproduced. Full-pipe callback/close 9.001s/0.0003s, unchanged flags/workers joined, not boot times. Manifest `6898ed59a0d1efaf9b4b34b2e0bd5f83f280f6df8614cfc0005fbe41271d0739`. | -| R7: FINAL/`startup-onboarding-independent-r7.json` | R6 global `json`/measured `controlledBootPolicy` preparation binding fixed; 237 inert (42 onboarding outside legacy adapters), six adversarial rejections, eight help/80 Python/four payload compiles, old NameError negative reproduced; 271 files unchanged. Synthetic fixture failures/adapter exclusions are not passing coverage. Manifest `9fd45d16597f4df7871fbc85434efda36aeb610dcd56dfc7adcb21aa17ea40ff`. | -| R9: FINAL/`startup-diagnostics-independent-r9.json` | R8 stage-attribution P2 fixed; clean inert review: 260 supplied (65 onboarding/diagnostic outside legacy adapters), 116 independent mocked preparations, eight help/104 Python/five payload compiles. Failed stages preserve original exceptions/causes, passed stages stay unblamed, strict guard/eligibility AST unchanged. Immutable reviewed manifest `92fba8d77a9c35a669c3437ee344b1e788346eef2553a4aa05fa6bf4e694e9c2`. | -| R11: FINAL/`startup-grub-independent-r11.json` | R10 LF-separator P2 fixed after supported-warning support: literal LF boundaries and terminating-LF guard. Clean inert review: 283 supplied, seven independent methods/19 delimiter variants, four negative-control methods, eight help/128 Python/five payload compiles. Policy/hash/authentication/comparison guards preserved. Current reviewed manifest `e8a7f96eb48a972f912d3e0c61628b3451e5ce83ae11445e0f318e5c69194fe0`. | - -Standalone observer remains `51eada68b783068e5ac62305dc45381ba7862a20b84f520d2ba63319164ba80a`. -These reviews establish inert tooling contracts, with no startup/pixel/performance acceptance. -Diagnostics remain opt-in, bounded and credential-redacted for named read-only stages, preserve -original exceptions and retain no argv/input/environment. The task's startup attempts are stopped. - -Pixel/frame acceptance is blocked: no accepted measured-sample frames or portal readiness. -Comparison/performance is unavailable without a valid equivalent series. - -A future separately scoped benchmark must visibly verify Welcome and both portal consumers, then -collect eight fresh equivalent boots with current revalidated source/helper/artifact prerequisites, -distinct boot IDs/auth sequences, six same-boot diagnostic/frame bindings, SDK82/full configuration -equivalence, reviewed settings projection and root GRUB evidence. Preserve all guards. Label any -accepted series sequential host-cache-warm QEMU stop/start readiness upper bounds including -authentication/probe/UI delays; no representative cold-boot or causal-speedup inference. Full -contract is in [startup](desktop-startup.md). No GRUB/binfmt/portal-grace optimization was made. - -## Retained review and superseded evidence - -Actual fresh T3 Codex tasks used verified `codex/gpt-6.1-sol`, xhigh/priority selections. -Implementers were not sole reviewers. Retained reports distinguish source/inert review, actual -runtime, recorded-evidence audits and release limits. - -| Review/history | Material result and retained reference | -| --- | --- | -| R1–R14 | Ownership/defaults, group/origin/UI/image issues, toast loop, offline admission/root sealing, native availability, wrapper cleanup and locale1 keyboard/variant fixes; `completed-review-records.json` and mapped historical/superseded reports. | -| R15/R16/R18/R20/R22 | Renamed opening/manual and named-group provenance corrected in 607bce2; exact IDs, null signatures over 16 actions or 256 code units per ID/cleaned label, shared plain-text visible/accessibility cleanup in d2037af, without source/snooze/group changes. R22 clean: 105 focused Node, 233 Lua/hotplug/margins, 972 notification, 2,317 layout assertions, 24 comparative probes. Host lacked parity prerequisites; later exact Fedora gates supply them. `review-records-r21-r22.json`. | -| R17/R19/R21/R23–R26 | External helper cleanup/metadata, races, exceptions, terminal fence and Kitty `-o confirm_os_window_close=0` fixed. R23: 4 supplied/20 independent/12 compiles; R25: 6/18/3 negative/6 compiles. Preserved Foot absence and unsupported `hl.dsp.window.focus` failures both restored. R26 corrected to `hl.dsp.focus`: 19 pinned dispatch sites, 7 supplied/14 independent/6 Python/10 Lua plus negatives; later actual workspace pass. `review-record-r26.json`, `preserved-plain-us-foot-launch-failure`, `preserved-native-focus-r25-failure`. | -| R27/R28/main | R27 reproduced content hash from 1,432 immutable Git blobs without large-artifact/live rechecks; English excluded then parent-verified. Initial hosted `titlebars.py` failed FileNotFoundError for missing `patch`; narrow R28 sole-path diff clean, then both hosted checks passed/PR merged. `independent-acceptance-r27.json`, `ci-patch-review-r28.json`, `main-push-result.json`. | -| Historical startup tooling | R8: 74 inert/144 terminal/16 stale-context probes. Earlier 3b→75 wrapper R1 fixed five bindings; R2 found replay; R3: 58 wrapper/72 inert-substitute observer checks (two product-dependent checks excluded), 273 compare/32 failure probes. These pins do not qualify retarget/runtime. | -| Historical installed results | b4dfdf6 and 75 passed four ISO cases; 75 pristine upgrade/native/recovery passed. b4 English checkout passed/Dutch keyboard failed; fixed in 75, whose Dutch install then failed SSH255 during outcome capture/cleanup, not parity. English 75 was not rerun after R15. OPPO font TLS, OpenH264 404/retry and initial pristine missing-SDK solver failures remain preserved, not waived. | -| Historical startup failures | Original 3b probes failed inventory/no frames. 63f7785 first encrypted login had 27.949s systemd diagnostics, black bar/launcher/picker failure with authentication/seeding/console setup, not readiness. b4 picker Escape=2 and ScreenCast cleanup failed. 75 inventory/root hashes/two-Escape dismissal passed, but large picker and inspected actions missed 9/10s deadlines; SelectSources rendered, selection/Close/cleanup failed. Backend 1.4.1 opens picker at SelectSources; GTK 1.15.3 Escape=2/Cancel=1 explains observer assumptions, not a product defect. See mapped `superseded/` reports and startup document. | - -R22 reviewed `75bc4243134ef93d96f208ab6edbb02e83438d18` plus correction diff SHA-256 -`a02af5098c37a48f0aa56950caf6d5f60c1a0909ad02beebf2d014fc902d3fe7`, then committed as -607bce2/d2037af. R26-reviewed native helper/test SHA-256 respectively: -`21eb0c82538e91dfc7b0bff4cc26671cb3a95523be8f6c59467e602939063cee`, -`3db76f20cf2c1c89508ad3350be428327975cfd70a55804996900a49510d446b`. Historical wrapper/R8 manifests -respectively: `0449beffb14af04201854c1ce20361281af25461c4e7e1caddd00d0a507d9649`, -`074746e8cca6909b1e4b0074c65754b5539ab88b0198cd4bae6a01a8a6ed9893`. `progress.json` can lag -completed evidence; preserve originals and use the map. - -## Remaining qualification - -Blocked/unqualified: representative physical lid/mirrors/docks/two externals, connector identity -ambiguity/scaling/rotation; Secure Boot; first-frame/client configure and new-artifact -pointer/unsampled assistive interactions; generic Wayland negotiated-CSD query (exact Nautilus/Text -Editor exclusions are policy only); full native notification overflow/presentation cases; actual -startup/pixel/performance measurement (blocked). No complete project or release qualification is -claimed. - -## Retained evidence and completed cleanup - -V/`independent-retention-r30.json` independently accepted selected evidence durability: 14,896 -record/assertion checks, no findings, 1,661 mapped non-RPM hashes. These are retention checks, not -product tests or release/runtime acceptance. The 80,789-byte report SHA-256 is -`7c79dd51a1abb1ee1451a99b1ab8412d4e88fa9e5412b483906fccf61a91f0fb`. Its audited -`archive-map.json` has 1,438 current records/193,372,721 payload bytes and SHA-256 -`f27ac95d8015900f6960baff3878047b7cd5479fb2edee9b8571daeb0a8c6002`. - -R30 independently verified both R29 findings corrected in V/`retention-r29-fixes.json`; R29 and -its hash-bound originals remain unchanged. - -| Retention correction | Exact retained binding under V | -| --- | --- | -| R29-1, P2: missing R27-bound nine-record ledger snapshot, 1,344 bytes; later ledger was 11 records/1,642 bytes, not a teardown failure | SHA-256 `7ea3038a25d250c89dd412db70de72073ec62c64dc3971a1188aba0a1485af36`, recovered at `history/7ea3038a25d250c89dd412db70de72073ec62c64dc3971a1188aba0a1485af36/final-r15-run/completed-lease-checks.json`; current ledger and R27 unchanged | -| R29-2, P3: missing late native terminal supplement, 1,111 bytes | `evidence/final-r15-run/native-terminal-inventory-binding.json`, SHA-256 `a3506c2f7560a980f2eb8ddd00ecb3fbe00a3e4b68b20882d59344d87c1d6795`; Kitty included/Foot absent, not new native qualification | - -R29's audited map remains at -V/`maps/8832a1321bcffad93bcbf84500dfa33c5ca281e03fbbbf714103d75d43e647b0.json`, with SHA-256 -matching its basename. R30 verifies current/frozen sources and all four failed-attempt inputs are -recoverable, including version-specific preserved helper aliases. Ten older pre-R3/pre-R8 precursor -source bodies were already absent before the MAIN audit; their metadata does not establish complete -transitive historical source preservation. Bounded text/privacy checks do not prove secrets absent -from PNG pixels or RPMs; ISO/RPM payloads were not rehashed by the audit. R30 preceded MAIN teardown; -the subsequent cleanup records below establish resource removal. - -Own b4dfdf6/75bc424 roots and PXE copies were removed after archival -(`superseded-root-cleanup.json`, `superseded-pxe-cleanup.json`, `pxe-after-superseded-removal.json` -under V). V/`baseline-final-cleanup-plan.json` and `baseline-final-cleanup.json` record removal of -`/home/john/.local/share/cybexos/images/desktop-20261021-baseline-3b310d1`: 17 files/10,088,530,860 -bytes, plus its 8,027,873,280-byte PXE ISO and 112-byte sidecar, after 11 metadata hash checks. -V/`pxe-after-baseline-removal.json` records refresh success, PXE running/service active, final -candidate present and baseline absent, without restart. The historical 3b baseline ISO is unavailable. - -V/`parent-lease-final-cleanup.json` records MAIN lease `29c5e2febb054008b06ff08cd794e589` released -at `2026-10-21T16:33:05Z`, return 0, check closed with `remaining:[]`/`errors:[]`, workspace absent; -idle container `6d814bcad464`, network and workspace removed. V/`final-resource-verification.json` -confirms all 53 recorded CybexOS leases closed, no task QEMU/developer Quickshell processes, mounts -or MAIN containers, and shared services untouched. No temporary VM/container is retained. The -ordinary documentation Git worktree `/home/john/Code/CybexOS-desktop-qualification-20261021` remains -until protected PR merge, then removal through Git. - -| Retained path | Cleanup-time count/bytes | Reason | -| --- | --- | --- | -| Build root's `artifacts/` | 18 files/10,123,452,292 bytes | Requested exact d2037af testing deliverables/provenance | -| Testing ISO under `/data/pxe/iso` named above | 8,063,483,904 bytes plus 112-byte sidecar | Requested PXE test image/checksum | -| V | 1,774 files/274,027,350 bytes | Bounded qualification/reviews, helper lineage and unresolved startup diagnostics; private VM state excluded | - -These are the `2026-10-21T16:33:43Z` cleanup-time snapshot in -V/`retained-inventory-cleanup-snapshot.json`, excluding itself and later documentation/review/PR -records; they are not final totals. Final handoff recomputes retained totals. - -User may `git pull --ff-only` then `./install` for own-machine testing with the limits above; no -completed user-machine test is claimed. Agent rollout/restart/reboot and public release remain -unauthorized/unperformed. This documentation update remains separate from tested d2037af artifacts. diff --git a/docs/desktop-workspace-architecture.md b/docs/desktop-workspace-architecture.md deleted file mode 100644 index 49e631a0..00000000 --- a/docs/desktop-workspace-architecture.md +++ /dev/null @@ -1,404 +0,0 @@ -# Workspace compositor contract - -Contract in tested product revision -`d2037af92cb3a47a08a9755e5996d2c4b0cae28d`; source/image gates, the exact-source -ISO/RPM build and testing PXE publication pass. All four ISO scenarios, both -English/Dutch checkout parity cases, pristine offline/native/recovery and scoped -native workspace/notification checks pass. The product is **not fully -qualified**: startup measurement is blocked; first-frame, hardware and generic CSD -limits remain open in [desktop validation](desktop-validation.md). -This documentation update is separate from the tested installed-product artifacts. -Main integration is complete at `bee9d722021301875823e6285a6eef2896b283eb`; -the intervening CI-only fix 9974aaee changes no installed-product payload. -Artifact qualification remains bound to d2037af. - -The compositor's `require("workspaces")` is the sole owner of identity, -numbering, layouts, minimize and persistence. Shared remains the first-install -mode; the managed base configuration uses dwindle. Without a personal workspace -default, explicit user.lua native layout choices remain authoritative. Enabling -per-monitor mode or taking ownership of the default layout is a deliberate choice. -The module never renumbers or merges live workspaces. A persisted assignment is -an ordered list of ten globally unique IDs, rather than an arithmetic block. -It adopts unowned workspaces already on that output, then allocates unused IDs. -This preserves existing windows/layouts and avoids destructive ID collisions. -A disconnected owner's workspaces remain reachable as guests after the ten -local slots on the surviving output; reconnect moves the whole workspace home. -A full local list never makes guest windows inaccessible. - -## API (schema v1) - -`$XDG_RUNTIME_DIR/cybexos/workspaces.json` is atomically replaced on relevant -compositor events. Arrays are JSON arrays (including when empty): - -- `v:1`, `mode:"shared"|"per-monitor"`, - `defaultLayout:"dwindle"|"scrolling"|"floating"|"unmanaged"`, - `defaultLayoutManaged:boolean`, `nativeDefaultLayout:string`, - `titlebarsEnabled:boolean`, `capabilities:{scrolling:boolean,titlebars:boolean}`, - `error:string` (empty on success). - `capabilities.titlebars` describes native availability, independently of the - titlebarsEnabled preference or whether any workspace uses Floating. It stays - true for a loaded, successfully configured component while its bars are hidden. -- `monitors:[{name,identity,label,activeWorkspace,workspaces:[integer IDs]}]`. - Only independent enabled outputs occur here; mirrors own no workspaces. -- `workspaces:[{id,localNumber,monitor,label,layout,layoutManaged,layoutOverride,nativeLayout,guest,ownerIdentity}]`. - `monitor` is the current reachable output, `ownerIdentity` the persisted - physical owner (empty for an unowned guest). `localNumber` is 1–10 for a local - slot; guest rows use 0 and an unambiguous label. In Shared mode regular global - IDs remain labels/numbers. Empty local slots are included. Each row occurs - once. Special/named workspaces are never allocated or rearranged. - `layout` uses the defaultLayout enum. `layoutManaged` is true only for an - explicit workspace override or a managed default. Otherwise native - `workspace.tiled_layout` and `general.layout` remain authoritative. Custom - native layouts such as master report `layout:"unmanaged",nativeLayout:"master"`; - never normalize this to Tiled. Native strings are bounded to 64 safe token - characters. Floating reports its underlying native layout as dwindle. - `layoutOverride:boolean` separately reports a recorded explicit per-workspace - choice. It remains true even when that choice equals the default. An inherited - managed default has `layoutManaged:true,layoutOverride:false`; an inherited - native layout has both false. UI inheritance/reset state must use - layoutOverride, never effective-layout equality. New snapshots always include - the flag; parsers must tolerate its absence in older v1 snapshots and retain - their legacy behavior until receiving the updated compositor snapshot. -- `minimized:[{address,workspace,monitor}]`: original regular workspace and - last known monitor. Windows live in `special:cybexos-minimized` until restore. - The compositor validates the live window before acting; UI cached rows confer - no authority to act on a nonexistent or recycled address. - -Public functions called through `hyprctl eval`: -`focus(localNumber, monitorName)`, `move(address, localNumber, monitorName)`, -`focus_id(id)`, `move_id(address,id)`, `next(direction,monitorName)`, -`new_workspace(monitorName)`, `set_mode(mode)`, `set_default_layout(layout)`, -`set_layout(id,layout)`, `cycle_layout(id)`, `set_titlebars_enabled(boolean)`, -`minimize(address)`, `restore(address)`, `activate(address)`, -`move_monitor(direction,address)`. Optional address/monitor select current focus; -optional layout ID selects the focused regular workspace. `next` accepts -1/1; -`move_monitor` accepts left/right/up/down/next/previous. Invalid/stale targets -raise errors before dispatch. `activate` restores minimized windows first. -`focus_id` permits a currently live regular workspace or a persisted local slot; -`move_id` validates both destination and current live window. Window addresses -must match `0x` plus 1–16 hexadecimal digits. Success returns nil; new_workspace -returns the selected ID. Callers must check process exit status. -`set_layout(id,"default")` removes a workspace override and restores inheritance; -"default" is an API action, never a persisted layout value. Cycling an unmanaged -custom layout starts at dwindle. `set_default_layout` always deliberately takes -ownership, even when selecting the same displayed layout. - -## Personal choices and lifecycle - -`$XDG_CONFIG_HOME/cybexos/workspaces.json` is authoritative personal data: -`{v:1,mode,defaultLayout,manageDefaultLayout,titlebarsEnabled,layouts:{"ID":"layout"}}`. -No preference file means `manageDefaultLayout:false`; the module does not write -`general.layout`, create a preference file, or claim existing native layout rules. -Mode, titlebar and workspace-only edits preserve this false flag, including -across reloads. The stored defaultLayout is a managed enum placeholder until -`set_default_layout` deliberately sets manageDefaultLayout true. Existing v1 -files without the flag retain their previous managed-default semantics. -Every deliberate per-workspace choice is saved, including choices equal to the -current default. Default edits preserve all these overrides and affect only -inheriting workspaces. Only `set_layout(id,"default")` removes an override. -Existing sparse preference maps retain every recorded entry; choices discarded -by an older implementation cannot be reconstructed, and missing entries keep -their inherited semantics. No migration invents missing personal choices. -Settings reads this compositor snapshot and sends setters only for deliberate -edits. workspaceMode/workspaceDefaultLayout/floatingTitlebars are not persisted -in shell.json; there is no second preference owner or startup push of defaults. -The custom Workspaces settings page can use `Settings.sectionKeys.workspaces=[]`. -Display custom values as “Custom (master)” / “Aangepast (master)”, or equivalent; -the unmanaged value is informational, never a setter argument. The reset action -must send `set_layout(id,"default")`, rather than the displayed default enum. -An absent native layout query falls back to dwindle for reporting only. -No installer writes personal choices into managed runtime trees. - -`$XDG_STATE_HOME/cybexos/workspace-assignments.json` retains physical assignments -across compositor sessions. A bounded session file in the runtime directory -retains minimize metadata across Lua reloads, scoped by HYPRLAND_INSTANCE_SIGNATURE. -The module also retains live floating ownership on window tags, which survive -config reloads. A shell restart needs only reread the snapshot. A compositor -restart preserves choices/assignments, but cannot restore windows that exited. -Old event subscriptions/timers/rules are retired on Lua reload. - -All input is strict bounded JSON parsed using the existing displays.lua reader; -no personal file is executed as Lua. Writes use private directories and atomic -replacement. Invalid files are ignored as a whole and reported in snapshot -error; setters refuse to overwrite a corrupt personal file. Native titlebar -availability is reported independently of the user's enabled preference. - -## Integration and qualification - -Deploy `workspaces.lua` and every `workspace_*.lua` companion alongside the -other compositor modules on both install paths. Load after monitors/displays, -looknfeel and user.lua. The XPS Shared policy exposes a mode switch to the -authoritative owner; it is disabled only while Per-monitor is selected. -Opening ownership runs synchronously on `window.open`, bound to the qualified -compositor's `openLate` event after native floating rules and grouping. It reads -the actual workspace name and native floating state before mapping returns. -Numeric `r[]` and `n[false]` selectors both include renamed positive-ID -workspaces, so no numeric opening window rule is installed. The shared -float-exclusion collector is no longer consumed or modified by this module; -native rules have already established independent floaters before the event. - -Native scrolling and optional decoration provenance, shortcuts, behavioral -evidence and remaining gates are recorded below and in desktop-validation.md. -Fixtures are source tests, not compositor or ISO qualification. - -## Identity, collisions and transitions - -The qualified HLMonitor exposes a short make/model description and a separate -serial, not separate manufacturer/model fields. Their length-prefixed values -form the physical fingerprint. Placeholder serials (`0`, all zeroes, `unknown`, -`none`, `null`) count as missing. A serial with no description remains stable -across connectors. With neither description nor serial the connector is the -only available identity. Internal panels are considered before externals; -connector order breaks ties, never Hyprland's recycled numeric monitor ID. - -A connected monitor retains its existing fingerprint/identity binding. Saved -connector matches come next; remaining matching identities are assigned in -stable order. Duplicate or absent serials can distinguish attached survivors, -but two indistinguishable panels whose connectors both change cannot be -physically distinguished; stable connector order is the documented fallback. -No claim of perfect physical tracking is made for that ambiguous case. - -Assignments own ten explicit IDs. Both live occupied IDs (including positive -IDs renamed into named workspaces) and every offline assignment are reserved. -New owners adopt unowned regular workspaces on their own output, then fill with -unused IDs. A renamed slot receives a new empty slot; the named workspace is -left intact. All ten occupied slots make `new_workspace` report capacity; -existing guest windows are still listed and reachable. Assignments are bounded -to 64 monitor identities, 640 slots; preferences to 1024 layout exceptions; -minimize to 256 windows; every data file to 256 KiB. Exceeding a bound reports an -error instead of stealing an ID or dropping live windows. - -Shared ↔ Per-monitor transitions do not renumber or merge workspaces, move -individual windows, alter window groups, or reset layouts. Shared keeps global -numbered shortcuts and the existing XPS rule owner. Per-monitor disables that -owner, installs one set of monitor rules, and reclaims whole owned workspaces -using workspace.move rather than the focus dispatcher's visible-workspace -swap behavior. Offline owners become guests without losing their IDs or layout -choices. Reconnection reclaims those IDs and restores unrelated visible -workspaces. Mirror outputs never acquire assignments. Lid close/open is the -same disabled-output/reconnect contract. Physical scaling, rotation and reserved -bar/dock areas are read when restoring normalized floating geometry. - -The vendor three-finger horizontal swipe is replaced with an event-driven local -list gesture while Per-monitor is selected and restored in Shared. Existing -Touchpad opt-outs and user.lua gesture overrides take precedence. Special and -named workspaces keep their existing bindings/behavior. Floating windows get -mode-owned tags only when the mode actually floats them, or a legacy opening -tag proves ownership. Pre-existing floaters and native app/dialog rules keep -their ownership. A new member of a mode-owned floating group inherits that -ownership before the original member can close. All -members are claimed before any grouped window floats. Fullscreen and pinned -windows retain their state; reconciliation runs again when they leave it. -Explicit Super+F floating choices release mode ownership for every member of -the selected window's group before the compositor toggles that group once. New -members inherit the existing group's manual choice before arrangement, so -opening a grouped client cannot reverse manual tiling or floating through reload. -Group provenance also follows new members while named or special, so renaming -back or closing the original owner cannot discard that choice; it does not -change the native floating state on those destinations. -That manual tag takes precedence over a legacy opening-rule tag, including its -dynamic `*` form, so newly opened Floating windows remain tiled after Super+F -through subsequent events and configuration reloads. - -Renamed/named/special workspaces retain their native floating state and never -gain Floating-workspace decorations. Existing origin ownership stays with a -window for a later return to a numbered destination. A saved numbered layout -override suspends while its ID has a user name and resumes when numbered again; -renaming never deletes the saved choice. Previously unowned floating windows -are preserved because they cannot be distinguished safely from deliberate user -floaters. - -Minimize records every group member and uses a hidden special workspace with a -window-local origin tag. PID, live mapped address, tag and compositor-session -signature guard stale metadata. `activate` restores before focus. Restoring a -floating window rescales normalized geometry into the current usable rectangle -and clamps the whole frame; fullscreen internal/client state is retained. -Super+Ctrl+M restores a minimized window even with the dock disabled. The -runtime session file is not loaded into another compositor session, and tags -die with their windows, so recycled addresses do not inherit hidden state. - -## Shortcuts and titlebars - -- Super+1…0 and Super+Shift+1…0: focused monitor's local slot / move window. -- Super+mouse wheel and the vendor horizontal swipe: monitor-local navigation. -- Super+Ctrl+L: cycle workspace layout. Super+L remains screen lock. -- Super+Ctrl+Alt+M: minimize. Super+M remains the Slack web app. -- Super+Ctrl+M: restore a minimized window, including when the dock is disabled. -- Super+Ctrl+Shift+arrows: separate display move, retaining useful geometry. -- Existing Super+left mouse drag, Super+right mouse resize, Super+Q close and - Super+F floating continue to provide controls if native titlebars are absent. - -Optional native titlebars are declared on every compositor config parse when installed, -and render only for Floating layouts. The fixed path is -`/usr/lib64/cybexos/titlebars.so`. `titlebarsEnabled` is the personal request; -`capabilities.titlebars` means the native registry confirms the plugin, its -required callback exists, and native configuration/button setup succeeded. -Loaded Tiled and disabled Floating modes report capability true with native -rendering disabled. Missing/unrelated registry entries, missing required APIs, -or failed configuration/button setup report false, even with an installed file. -Setup runs once per Lua lifecycle/theme change while hidden or visible; -rendering changes update only the enabled option. Unchanged event refreshes -reuse successful setup without config writes, button resets or duplicate buttons. -Loss of registry/API availability or any setup failure invalidates the cached -success, so recovery verifies setup again even when returning to old preferences. -A missing/ABI-incompatible plugin never disables keyboard/mouse controls. -Titlebars consume `cybexos/theme/tokens.json` (`surfaceRaised`, `text`, UI font -and text size) through the strict JSON reader. The existing -`cybexos_system_theme()` trigger refreshes them without another theme owner or -polling. The packaged native `clear_buttons()` extension clears existing -buttons before exactly three themed buttons are added, preventing duplicate -hit targets and stale icon textures. Its clear/re-add and pointer/theme behavior -passed historical isolated native tests on earlier product source; the exact -evidence scope and current-candidate gates are in desktop-validation.md. -User.lua can supply -`__cybexos_titlebar_colors={background,foreground,font,size}` deliberately. -The loaded-plugin registry gates all native callbacks and dynamic no-bar rules. -Runtime events, preference edits and theme refresh never declare/load plugins. -After hyprbars is registered, an exact known-CSD class rule suppresses its bars -for `org.gnome.Nautilus` and `org.gnome.TextEditor`; that rule is retired on -reload/plugin absence. This is an explicit application policy, not automatic -Wayland CSD detection or a claim about Firefox/Electron and arbitrary clients. - -Directory initialization uses system Python once per output directory per -compositor lifetime: O_NOFOLLOW directory open, effective-UID ownership check, -fchmod(0700), and a cryptographically random temporary prefix. Event writes use -only Lua I/O and atomic rename in that private directory; identical snapshots -and assignments are skipped. File modes inherit the process umask; the checked -0700 directory is the privacy boundary. Invalid or unreadable personal files -cannot be silently overwritten by setters. A same-UID actor can edit their own -preferences; this is not a sandbox against the account owner. - -## Provenance and native dependencies - -Inspected Omarchy tag `v4.5.0-rc1`, commit -`03ff1eff3812933f7088484533784d2f63eaf19a`: monitor-workspaces.lua, -workspace-modes.lua, workspace-layouts.lua, workspace-layout-toggle, its package -list and LICENSE, and shell tests hyprland-monitor-workspaces-test.sh, -hyprland-floating-workspaces-test.sh and hyprland-workspace-layout-test.sh. -The tag's migration cases for named workspace selection and saved layout/default -handling informed strict numeric selectors and explicit default overrides. -No subsequent moving-branch fixes were copied. Monitor allocation/persistence is -an independent implementation; floating claim tags, claim-before-group-float, -legacy opening ownership and logical/rotated geometry are adaptations of the -pinned workspace-modes.lua. Its MIT copyright/permission/warranty notice is -retained in workspace_layouts.lua. - -Inspected Hyprland `v0.56.2`, commit -`efb50993780079460b0cbed1363e2166a2de1d9f`: Lua query/dispatcher/rule/event/window, -monitor/workspace objects, and LuaFunctionGesture.cpp. Native scrolling is -already included (`src/layout/algorithm/tiled/scrolling`); `layout="scrolling"` -is a workspace rule. No compositor upgrade or scrolling plugin is needed. -Runtime availability probes the registered `scrolling.column_width` option; -unavailable Scrolling is not offered by cycle/setters and falls back to dwindle -when reading saved choices from an incompatible build. The actual Fedora -`hyprland-0.56.2-2.fc44` package and its installed stubs were inspected. -Earlier all-layout native runs are recorded in desktop-validation.md; -source inspection alone is not native qualification. - -Inspected optional `omacom/omarchy-hyprland-titlebars` commit -`379ff910fcce9f0cc9b41f9a87c527997076a417` (v0.2.2), README, -CMakeLists, main.cpp, barDeco.cpp, tools/verify.py and both licenses. Native -build needs CMake >=3.27, C++23, pkg-config `hyprland`, `libdrm`, `libinput`, -`libudev`, `pixman-1`, `wayland-server`, `xkbcommon`; it uses `-fno-gnu-unique`. -The shared object checks exact host/client Hyprland build hashes. Build against -exact Fedora Hyprland headers and rebuild/requalify on each compositor package -update; an Arch binary or matching minor version alone is insufficient. Package -both BSD-3-Clause LICENSE and MIT LICENSE.MIT. The plugin's Lua namespace stays -hyprbars, with workspace_tag and add_button support. - -Upstream skips X11 border opt-outs and supports explicit hyprbars:no_bar rules; -it does not automatically detect Wayland client-side titlebars. Automatic CSD -avoidance remains blocked on a supported API. The packaged clear_buttons patch -has historical isolated native runtime evidence; exact-class exclusions remain -explicit policy with mixed-client acceptance limited as documented in -native-titlebars.md and desktop-validation.md. The inspected 0.56.2 -`LuaBindingsConfigRules.cpp` resolves nonbuiltin `hl.window_rule` keys through -the registered window effect table, so `['hyprbars:no_bar']=true` is added only -after the native registry proves the plugin is loaded. Do not advertise general -duplicate-decoration avoidance. Its verifier needs a running outer -Hyprland, foot, grim, wayland-scanner and a C compiler; it launches a nested -compositor. On thebeast that verifier must run under an agent-resource managed -task/VM lifecycle. - -## Evidence and remaining gates - -Starting revision: `3b310d106255558ca213c955ef37ee314e8681f2`. The R15/R16/R18 -workspace corrections are committed in -`607bce226d9f85c4e6307567a75cc5cfbc8b1243` and included in exact tested product -`d2037af92cb3a47a08a9755e5996d2c4b0cae28d`. The contracts above include the -reviewed renamed-opening, manual-group and named-provenance corrections. - -`./tests/hyprland-workspace-modes` passes 233 pure-Lua behavioral assertions. -The exact Fedora source gate includes those assertions; XPS hotplug/policy, -single-window margins, syntax, source/image parity and actual Qt/runtime gates -also pass. These checks cover: - -- Physical identity ambiguity, connector changes, occupied/named IDs, full - slots/guests, mirrors/lid reconnect, visible-workspace swap avoidance and - Shared/Per-monitor transitions. -- Native capability failure, gesture opt-outs, independent floaters, fullscreen, - groups/opening tags, manual ownership, minimize/reload/activate/group restore, - scale/rotation/reserved areas and stale live-window validation. -- Strict/bounded/corrupt JSON, safe writer behavior and event-subscription - retirement. user.lua master defaults and unmanaged native rules survive mode/titlebar edits and reload without taking ownership. Explicit choices equal - to defaults survive default edits; reset, cycling and legacy sparse/v1 - inheritance retain their distinct meanings. -- R15 renamed positive-ID openings and suspended overrides, native dialog/user - float rules and group ownership. There are no numeric opening rules and no - consumption/mutation of the shared float-exclusion arrays. -- R16 manual-group provenance inherited before arrangement, and R18 ownership - inherited while named/special without changing native floating state. Reload, - original-owner closure and return to numbered workspaces preserve the choice. -- Declarative titlebar/registry/known-CSD rule lifecycle; loaded Tiled and disabled - Floating capability reports; missing APIs/registry and native setup failures; - recovery, cached successful setup and exactly-three-button theme refresh - without event loads, redundant writes or duplicate buttons. - -Fresh independent R22 found no actionable issue in the final correction scope. -Its detailed probes, historical native runs, external-helper reviews and -resource status are centralized in desktop-validation.md and the ledger. -Earlier native layout/titlebar tests retain their earlier product scope. - -The writer accepts Linux ECHILD from `pclose` only with the exact bounded helper -success token; absent/malformed tokens and explicit nonzero exit status remain -failures. Its historical native JSON/0700 result and regression evidence are -recorded in validation. - -All four exact d2037af ISO scenarios and English/Dutch checkout fresh/saved parity, -settings lifecycle and saved choices passed. Pristine offline upgrade/native -rebuild/convergence/recovery also passed. Its candidate-runtime report verifies -exact provenance, loaded titlebars, true capabilities, empty snapshot error and -one explicit Floating workspace. - -The separate `qualification-plain-us-evidence/r15-workspace-rename-native.json` -report passed renamed/numbered opening, named-group provenance, original-owner -closure, manual tiled/floating groups, reload and restoration. These are stable -native observations, not first-frame/client-configure proof. Exact notification -action separation and restoration passed separately; validation records its -scope and the source/runtime-fixture coverage of action-bound/label edges. - -The core prerequisite aggregate passes. Independent R27 is clean within its -completed scopes; English checkout was verified separately by the parent. -Actual startup/pixel/performance measurement is **BLOCKED** after four failed -attempts, zero accepted samples/comparison. R11 is scoped inert review only. -Warm04 passed root-policy/reconciliation validation but its Welcome frame was -black background/pointer only; null review response sent no input. Failure -preceded SSH fixture/warm-up; cause unknown, no proof of product regression. -Warm02's cause and warm03's exact offending bytes remain unknown. All startup -leases are closed/empty. No further VM series is planned; a future benchmark -requires visibly verified Welcome/portals and eight fresh equivalent boots with -current prerequisites, a reviewed retarget and unchanged guards, as detailed in -validation. R30 independently accepted scoped retention and verified both R29 -repairs; it does not grant runtime/release acceptance. All 53 recorded leases are -closed, MAIN's workspace absent and no temporary VM/container retained; shared -services untouched. Baseline/intermediate payloads were removed; final testing -deliverables and bounded evidence remain. Original paths are historical map-resolution -identities. Validation records the historical-source limit and cleanup-time inventory -snapshot, which excludes later documentation/review/PR records. -Physical lid/mirror/dock/two-external/connector/identity/scaling permutations and generic Wayland CSD remain blocked. -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at bee9d722 on -`2026-10-21T14:01:19Z` after both required hosted checks succeeded; origin/main -was verified at that merge. Validation distinguishes the tested d2037af -artifacts from the CI-only fix and merge. Active user desktop rollout -and public release remain unauthorized. These scoped results do not establish -full desktop/release acceptance. diff --git a/docs/desktop-workspaces.md b/docs/desktop-workspaces.md deleted file mode 100644 index 2ff3a046..00000000 --- a/docs/desktop-workspaces.md +++ /dev/null @@ -1,225 +0,0 @@ -# Desktop workspace controls - -Open **Settings → Workspaces** to choose **Shared** or **Per-monitor** numbering, -the default layout, and a layout for an individual workspace. Existing and new -installations start in Shared mode. Without a saved managed layout choice, the -compositor preserves the native `general.layout` from the user's configuration -(normally dwindle). Floating workspaces show the dock automatically on their -own display; changing layout never changes its saved pins or auto-hide preference. - -In Per-monitor mode the compositor supplies each display's local numbers 1–10. -Each bar marks the workspace visible on its own display, including displays -without keyboard focus. Numbered/dot style, minimum visible slots and hide-empty -remain the existing bar preferences. Occupied and current workspaces stay -visible. A disconnected display's reachable visiting workspaces use the -compositor's explicit labels, including their old connector where needed. The -UI does not infer monitor identity or calculate IDs from arithmetic blocks. - -The layout symbol next to each bar's workspace strip shows Tiled, Scrolling or -Floating, or Custom with the native layout name (for example, master). Click it -to cycle that display's active numbered workspace. Scrolling appears -in the settings choices only when the compositor reports native support. A -missing decoration component is shown explicitly; the titlebar switch saves the -preference but does not claim an unavailable plugin can draw titlebars. -The default layout readout says **Unmanaged (master)** when the user's native -layout remains authoritative. Choosing or resetting the default deliberately -enables compositor management; simply opening Settings never changes it. -An individual workspace's **Default** option and reset clear its saved override -with `set_layout(id, "default")`, including when the default is unmanaged. -The action value `default` is never a saved layout preference. -An explicit layout choice remains owned by that workspace even when it equals -the global default. For example, choosing Tiled while the default is Tiled -keeps that workspace Tiled after changing the default to Floating. The picker -continues to show Tiled and offers reset; only Default clears the override. - -## Window and keyboard controls - -| Control | Action | -| --- | --- | -| Super+1…0 | Focus workspace 1–10, local to the focused display in Per-monitor mode | -| Super+Shift+1…0 | Move the focused window using the same mapping | -| Super+Ctrl+Shift+arrow | Move the focused window to another display | -| Super+Ctrl+L | Cycle workspace layout; Super+L continues to lock | -| Super+Ctrl+Alt+M | Minimize the focused window; Super+M continues to open Slack | -| Super+Ctrl+M | Restore a minimized window | -| Super+Tab | Open/close the overview | -| Overview arrows / Tab | Select a window | -| Overview Page Up / Page Down | Select a workspace, including the New workspace tile | -| Overview Enter | Restore/focus the selected window or focus the selected workspace | -| Overview Ctrl+N / Ctrl+L | New workspace / cycle the selected workspace layout | -| Overview Ctrl+M / Ctrl+Enter / Ctrl+W | Minimize / maximize or restore size / gracefully close the selected window | -| Overview drag | Move a window to the destination tile; New workspace allocates through the compositor | - -The overview includes minimized windows on their original workspace, with an -icon and a Minimized label instead of a live capture. Typing filters them like -other windows. The launcher Windows provider also labels minimized windows and -their monitor/workspace, and restores them before focus. Scratchpads and other -special workspaces keep their existing behavior. Named regular workspaces remain -in the overview and use the validated native named-workspace dispatcher. This -also covers `renameworkspace 1 research`, which retains a positive ID: native -name and numbered/named kind survive tile serialization, selection and drops. -Live workspace/window metadata takes priority over a stale numbered snapshot, -and works before the initial snapshot exists. Names are escaped as Lua data; -special workspaces and control characters remain rejected. Named-workspace -windows remain reachable through the launcher and dock; numbered-layout and -minimize controls are unavailable for them, matching the compositor API. - -The dock restores minimized windows on click or through **Restore -window** in its menu. Clicking a focused app with exactly one window minimizes -it; apps with multiple windows retain cycling. Its menu also provides minimize. -Configure it through **Settings → Bar → Dock**, linked from the workspace page. -When the dock is absent in other layouts, global minimize/restore shortcuts -and the overview/launcher remain available. If native titlebars are unavailable, -Super+mouse drag/resize and the overview's maximize, minimize and close controls -remain usable. - -The compositor's personal choices live only in -`$XDG_CONFIG_HOME/cybexos/workspaces.json` (normally -`~/.config/cybexos/workspaces.json`). Shell restarts read them through the runtime -snapshot and never push startup defaults. Assignment persistence, monitor -identity ambiguity, reconnect behavior and mode-owned floating state are -documented in [the compositor contract](desktop-workspace-architecture.md). - -## Integration contract - -- Register `singleton DesktopWorkspaces DesktopWorkspaces.qml` in - `Common/qmldir` (lead has done this) and initialize it from `shell.qml`, so its one snapshot reader - exists even when the dock and workspace bar module are off. No startup - commands or preference mirroring are performed. -- Register `Settings/WorkspacesPage.qml` as the `workspaces` settings page and - searchable rows for `workspaceMode`, `workspaceDefaultLayout`, `floatingTitlebars`. - `sectionKeys.workspaces` is empty: those choices belong exclusively to the - compositor's user-owned `~/.config/cybexos/workspaces.json`, not shell.json. - Custom rows intentionally have no shell `settingKey`; search opens this page - without treating these values as shell preferences. Individual rows provide - custom reset actions. Dock controls have one owner, the Bar page. -- The page reads the runtime snapshot and sends deliberate setter calls only - after `Settings.loaded`. Missing/invalid snapshots disable compositor-only - controls and retain existing shared navigation. A shell restart never sends - default values back to the compositor. -- Snapshot v1 uses monitor `workspaces` integer-ID arrays, workspace `guest` - and `ownerIdentity` fields, `capabilities.scrolling/titlebars` booleans, an - `error` string, and `titlebarsEnabled` for the saved titlebar choice. Guest - `localNumber:0` is preserved. Only the compositor allocates new IDs. -- Optional v1 `defaultLayoutManaged`/`nativeDefaultLayout` and workspace - `layoutManaged`/`nativeLayout` fields preserve native custom layout readouts. - Earlier v1 snapshots without ownership flags retain managed behavior. - `layout: "unmanaged"` and unknown custom layouts never become Tiled in the UI. - Snapshot rows carry an explicit numbered kind in presentation; live named - workspaces are not decorated or dispatched as those cached rows. -- Workspace `layoutOverride:boolean` is the authoritative inheritance bit. - The picker and its reset state use it independently of `layoutManaged` and - equality with the global default. `layoutManaged` describes layout support/ - management, not inheritance. For older v1 snapshots missing `layoutOverride`, - the parser retains the previous heuristic: unmanaged rows and rows equal to - a managed default display Default; other managed rows display their layout. - That compatibility path cannot reconstruct a same-layout explicit choice - absent from the older snapshot. No UI setter is sent to repair or guess it. -- The UI also touches the dedicated `Common/Dock.qml` model, in addition to - DockWindow.qml, to route all dock activation through restore-before-focus. -- Public module calls execute with argv `["hyprctl", "eval", expression]`, in - one bounded queue, with a live-address recheck immediately before execution. - Success requires normal exit, status zero, and exactly `ok` on stdout: - hyprctl may otherwise return a Lua error while exiting zero. Errors remain - visible in the workspace settings page. A five-second operation deadline and - missing-binary handling release the queue. Native fallback dispatcher objects - continue through `Hyprland.dispatch`. No shell interpolation is used. -- New-workspace drag uses one eval expression: the module's returned ID is - passed directly to `move_id`. Creating a new workspace selects it through the - module; dropping on an existing tile remains a silent move. - -## Provenance - -Reviewed Omarchy `v4.5.0-rc1`, exact revision -`03ff1eff3812933f7088484533784d2f63eaf19a`: its -`shell/plugins/bar/widgets/Workspaces.qml`, `default/hypr/workspace-modes.lua`, -`default/hypr/workspace-layouts.lua`, and -`bin/omarchy-hyprland-workspace-layout-toggle`, with relevant tests -`test/shell.d/hyprland-monitor-workspaces-test.sh`, -`hyprland-floating-workspaces-test.sh`, `hyprland-workspace-layout-test.sh`, -`workspace-layout-mode-migration-test.sh`, and the monitor-overlay acceptance -fixture. Upstream's MIT license (David Heinemeier Hansson) was read. The UI -implementation here is original CybexOS code built on its existing bar, dock, -overview, settings, theme and validated command helpers. It adopts the ideas of -per-display active state, visible layout control and mode-aware activation; -it does not copy upstream's connector/block mapping, layout subprocess/cache, -shell execution or substantial source. No later moving-branch fix was imported. - -## UI validation - -Tested against the local diff based on -`3b310d106255558ca213c955ef37ee314e8681f2`; no UI-agent commit was made in the -shared checkout. The integrated final revision and release gates are the lead's -responsibility. - -- **Passed:** 105 Node tests across desktop-workspaces, window-commands, - workspaces, overview-helpers, desktop-navigation, launcher-providers, - dock-helpers and tabler-icons. These exercise arbitrary non-block IDs, - monitor-specific state, connector changes, guests, missing/corrupt snapshots, - minimized-window inclusion/activation, stale addresses, eval error responses, - capability gating, startup preference guards, commands and existing controls. - Review regressions additionally exercise positive-ID named tile selection - and drops, missing/stale snapshots, named launcher/dock activation, closed - native windows, safe quoted names, unmanaged/custom layout fields, older v1 - compatibility, Default override clearing, and focused dock minimization. - The R2 explicit-override follow-up was tested on the uncommitted diff based - on `66b14a22dcc691851ed5ba87c57d88c7f98df8ef`: an explicit Tiled choice equal - to the default remains selected after a global Floating change, Default - clears ownership, and older snapshots retain their previous heuristic. -- **Passed:** `git diff --check` for the working tree at the UI handoff. -- **Blocked on host prerequisites:** `python3 -B tests/installation-parity.py` - ran 21 tests and reported 24 fixture errors from missing `ansible-playbook`; - `PYTHONPATH=image python3 -B -m unittest image.test_installation_parity - image.test_user_parity` ran 34 tests and reported 9 errors from missing Ansible - and the GNOME SMB schema. These runs do not pass parity. The lead's Fedora - environment must run both gates after integration. -- **Pending integration/runtime:** QML lint/loading and real compositor - multi-monitor, scaling/rotation, lid/mirror, minimize geometry, fullscreen, - groups, native scrolling and decoration qualification. The UI agent did not - alter the active desktop or create host containers/VMs. Node fixtures are not - runtime or installation qualification. Temporary UI test logs are removed - after this report; no UI-agent build artifacts are retained. - -## Dutch strings for lead integration - -Add these missing catalog entries through the existing catalog workflow: -The earlier handoff strings below have been integrated by the lead. The review -follow-up adds only `Custom (%1)` and `Unmanaged (%1)`; `Default` already exists. - -```json -{ - "Custom (%1)": "Aangepast (%1)", - "Unmanaged (%1)": "Onbeheerd (%1)", - "Configure dock": "Dock instellen", - "Cycle workspace layout": "Werkbladindeling wisselen", - "Default workspace layout": "Standaardindeling voor werkbladen", - "Display": "Beeldscherm", - "Enter to restore or switch · drag to move · Page Up/Down for workspaces": "Enter om te herstellen of wisselen · sleep om te verplaatsen · Page Up/Down voor werkbladen", - "Floating titlebars": "Titelbalken voor zwevende vensters", - "Floating windows": "Zwevende vensters", - "Maximize / restore": "Maximaliseren / herstellen", - "Minimize": "Minimaliseren", - "Minimized": "Geminimaliseerd", - "Native titlebars are unavailable. Use Super-drag and the overview controls.": "Native titelbalken zijn niet beschikbaar. Sleep met Super en gebruik de knoppen in het overzicht.", - "Native titlebars for windows that need them": "Native titelbalken voor vensters die ze nodig hebben", - "New workspace": "Nieuw werkblad", - "Open the overview with Super+Tab. Enter restores a window; Ctrl+M minimizes; Ctrl+L cycles layouts; Page Up/Down selects workspaces.": "Open het overzicht met Super+Tab. Enter herstelt een venster; Ctrl+M minimaliseert; Ctrl+L wisselt de indeling; Page Up/Down selecteert werkbladen.", - "Per-monitor": "Per beeldscherm", - "Restore window": "Venster herstellen", - "Scrolling": "Schuivend", - "Scrolling is unavailable in this compositor build": "Schuivende werkbladen zijn niet beschikbaar in deze compositorversie", - "Select a workspace": "Selecteer een werkblad", - "Shared": "Gedeeld", - "Shared keeps global numbers. Per-monitor gives each display its own 1–10.": "Gedeeld gebruikt globale nummers. Per beeldscherm geeft elk beeldscherm eigen nummers van 1 tot 10.", - "The bar layout button cycles the layout of its own display": "De indelingsknop in de balk wisselt de indeling van het eigen beeldscherm", - "The dock appears on floating workspaces. Minimized windows remain in the overview and launcher.": "Het dock verschijnt op zwevende werkbladen. Geminimaliseerde vensters blijven in het overzicht en de starter.", - "Tiled": "Getegeld", - "Used by workspaces without their own layout choice": "Voor werkbladen zonder eigen indelingskeuze", - "Visiting workspace": "Werkblad van een ander beeldscherm", - "Workspace": "Werkblad", - "Workspace command failed": "Werkbladopdracht mislukt", - "Workspace controls are waiting for the compositor": "Werkbladinstellingen wachten op de compositor", - "Workspace layout": "Werkbladindeling", - "Workspace mode": "Werkbladmodus" -} -``` diff --git a/docs/efficiency-review-2026-10-15.md b/docs/efficiency-review-2026-10-15.md deleted file mode 100644 index 5f4359f2..00000000 --- a/docs/efficiency-review-2026-10-15.md +++ /dev/null @@ -1,215 +0,0 @@ -# Efficiency review and implementation — 15 October 2026 - -The audit compared the working implementation with source revision -`5ce58a3ec8cfa75164f60923d640bfae5cc7227c`. Claude Fable 5.1 was asked to -verify the findings at Max effort. It confirmed the principal mechanisms, -qualified their scope, and identified the default Rust documentation as an -additional large seed cost. This independent review covered the findings; -the implementation was subsequently tested and measured as described below. - -Raw observations, workloads and caveats are in -[the measurement record](measurements/efficiency-2026-10-15.json). - -## Measured changes - -Paired artifact measurements use the first optimized build. The final build -after installation-parity corrections is identified below and in the JSON -record; runtime and component workloads are unchanged. - -| Area | Before | After | Scope | -| --- | --- | --- | --- | -| Production web Docker image | 803,746,314 B | 104,205,923 B | 87.0% smaller uncompressed image layers; standalone runtime includes its traced dependencies. | -| Web runtime `node_modules` | 498,699 KiB | 20,144 KiB | Development compiler and Cloudflare tooling stay in the build stage. | -| Browser wallpaper | 1,838,747 B | 90,336 / 250,438 / 678,532 B | 1280 / 2560 / 5120 pixel WebPs. The tested 2× browser fetched 250,438 B, 86.4% less. | -| Unrelated window renders during 20 clock ticks | 20 | 0 | Paired React test; the displayed clock crossed a minute. This measures render work, not browser CPU. | -| Quiet Sound page, four seconds after settling | 40 `pactl` probes | 0 | Real PipeWire-Pulse and QML. The 15-second safety poll remains; a real volume change still generated one five-probe snapshot. | -| Two-output widget RSS, median of three runs | 97,222,656 B | 80,986,112 B | About 15.5 MiB less in the isolated widget workload, before first popup use. | -| Two-output widget construction, median | 462 ms | 406 ms | About 12% faster. Headless Sway, software rendering and fake quota responses; not whole desktop login time. | -| Forced Wayfreeze rebuild, median of three runs | 129.39 s | 2.76 s | About 47× faster with a warm compiler/Cargo cache. Includes variable network time in the baseline. | -| Seven native JPEG wallpapers | 28,282,513 B | 27,936,923 B | 345,590 B saved through lossless optimization; decoded pixels, dimensions and filenames preserved. | -| Complete xz ISO | 8,071,483,392 B | 7,996,391,424 B | 75,091,968 B (0.93%) smaller. Every upstream RPM identity and Flatpak deployment matches between builds. | -| Build source archive | 33,331,560 B | 32,277,823 B | 1,053,737 B (3.16%) smaller; each archive records its exact source snapshot. | - -The initial cached builder image took 84.73 seconds to provision in a separate -run; an unchanged application then returned in 4.4 ms without network access. -These are different paths from a forced rebuild. Network variation prevents a -reliable claim that the first ever build became faster. - -All nine original findings and the additional Rust documentation finding are -addressed below. The browser container now uses the traced standalone Node -runtime; the Worker build remains available. The clock update is isolated and -aligned to minutes, while the open usage panel retains second-level updates. - -## Native runtime - -Sound subscriptions now accept card, sink, source, stream and server events, -and ignore client/module churn caused by the read-only probes themselves. -`LC_ALL=C` makes the filter independent of the desktop language. The measured -loop occurred only while the Sound settings page was open. - -Model Usage quota and legacy activity workers are shared by connection, -credentials, provider selection and refresh policy. Reference counts retain -workers across removal of the first output, and awake consumers control -polling. Manual refresh remains available while automatic work is paused. -Local loopback proxies remain usable without external network connectivity. -The existing shared Rust live stream is retained. A single-output system does -not gain from deduplicating output subscriptions. -The actual Quickshell engine fixture starts two matching consumers and records -one quota child and one legacy-activity child for their initial fetch. Removing -the awake consumer pauses automatic work; one manual refresh adds one child -of each kind, and changing credentials starts one new child of each kind. -These are controlled helper-invocation counts, not external API traffic. - -The popup's forms and delegates are constructed on first use and retained -after closing. The headless Wayland test checks synchronous settings creation, -keyboard focus, retained nonsecret drafts, and switching between limits, costs -and their settings. The memory gain concerns QML objects and bindings; it is -not a claim about GPU surfaces. The vendored integration is recorded as a -reviewed patch, checked before an upstream sync can replace the current copy. - -Plugin discovery keeps its registry watcher and immediate refresh on relevant -page changes. Its two-second package scan now applies only to Bar and Plugins; -unrelated Settings pages use the existing five-minute interval. A real helper -benchmark with Appearance open measured five scans in ten settled seconds -before the change and zero afterward (one initial scan was retained). - -## Application payload and update downloads - -New Rust installations use the minimal profile with explicit rustfmt, Clippy -and rust-analyzer. Existing user components are preserved. The pinned -`nightly-2026-08-23` documentation contains 739,153,743 bytes in 65,926 -files. Copying that tree with the existing seed function took 8.58 seconds on -the host's ZFS storage. That is a component benchmark, not installed Btrfs -first-login timing. Offline docs can be added with `rustup component add -rust-docs --toolchain nightly-2026-08-23`. - -The baseline application seed alone contained 3,586,210,627 bytes across -85,475 files; the rebuilt seed contains 2,847,058,243 bytes in 19,548 files, -a 20.6% byte reduction and 77.1% fewer files. Application stores and system -fonts are additional to these figures. Its `.rustup` directory -accounted for 1,428,749,636 bytes. The ISO also duplicated 6,468,676 bytes of -pinned fonts in 20 additional files. Packaging now verifies the canonical -prepared fonts and copies only the required notices to the license directory. -Missing or corrupt prepared fonts fail packaging. - -The desktop and large offline application seed now have separate RPMs. The -seed's independent version and reviewed input digest prevent shell-only -releases from forcing an application download. The desktop requires that -exact input identity. Spec ownership, signing, repository publication, SPDX -artifact bindings and offline upgrade qualification account for both RPMs. -The actual desktop RPM fell from 1,739,588,415 to 29,824,379 bytes; the -companion is 1,641,343,352 bytes. Together they are 68,420,684 bytes (3.9%) -smaller than the original monolith. A shell update that reuses the companion -needs a 98.3% smaller desktop download. The seed remains in a fresh ISO. -An actual RPM transaction from the monolith to both packages verified all -65,548 regular-file/symlink payload paths with zero duplicate regular-file -owners and a clean RPM verification. That private-root transaction excluded -scriptlets; it is not a boot or installation qualification. - -Published offline RPM identities must remain -immutable: the signing workflow authenticates the previous channel manifest -and rejects changed payload contents or ownership under the same identity. -Reuse an existing artifact or advance its version when moving upstream inputs -change. See [the release policy](releasing.md). - -Source builds provision a reusable compiler image per pinned base and build -recipe, mount a shared Cargo registry and separate locked target directories, -and copy only the requested executables. Cache data lives outside installed -application stores. The transactional update and healthy offline skip paths -are retained. - -## Compression choice - -A new `image/benchmark-compression` tool compares identical input hashes, -verifies extracted contents, alternates codecs across repeated runs, and -removes its temporary images. On the 4,457,839,657-byte baseline RPM payload -with 131,381 files and eight processors, one paired run measured: - -| SquashFS codec | Image bytes | Compression | Extraction | -| --- | --- | --- | --- | -| xz | 1,496,461,312 | 55.48 s | 6.55 s | -| zstd | 1,571,086,336 | 33.24 s | 4.86 s | - -Zstd was faster and 5.0% larger on this workload. A separate two-run Rust-docs -comparison was 2.9% larger without a consistent extraction advantage. These -measurements do not establish boot or installation time for the full ISO. -The qualified xz default remains, and the existing zstd build option remains -available. Binary stripping stays disabled because the payload contains -upstream binaries whose verified bytes must be retained. - -The review also checked the Android SDK selection and native timer guards. -The SDK already installs only platform-tools, the selected platform and -build-tools. The native timers examined already stop when their work or view -is inactive. No pruning or timer removal was justified for either item. - -## Validation and deployment - -The full source contract passes: 1,369 unit tests, QML lint and actual engine -lifecycle checks, 43 Python isolation/policy fixtures, installer convergence, -updater, snapshot and migration checks. Image checks pass 27 primary tests, -518 Python tests (two environment-specific skips) and 14 installer JavaScript -tests. The web passes lint, TypeScript, 116 component tests and two production -Worker/asset tests. Its Compose service was rebuilt/restarted on `thebeast` -and is healthy; desktop/mobile browser checks passed. - -Real qualification exposed checkout defaults that differed from the ISO. -Checkout now reads the ISO package selections, groups, exclusions and desktop -runtime requirements after shared repository setup. Prerequisite commands, -upstream RPM installation and Ansible disable weak dependencies. Both paths -use the complete locale and firmware baseline. Checkout prepares the shared -codec repositories and selects the ISO providers before base/desktop package -transactions, avoiding nine dependencies left by replacing interim codecs. It -also selects the ISO's `ocl-icd` OpenCL dispatch library in that transaction. -Shared policy selects graphical boot without interrupting the running session; -the test asserts that result. -Saved choices and user-owned data remain protected. - -The final ISO passed a complete plain-US graphical installation with outbound -networking blocked, boot without the ISO, application/toolchain checks, -password login and sudo, enforcing SELinux and managed Settings lifecycle -checks. Offline reconfiguration preserved explicit saved choices and personal -settings. A separate actual public checkout installation used the exact -archived source on disposable Fedora Cloud, rebooted into SDDM and repeated -the lifecycle/reconfiguration checks. The delivered source archive records -the installed product snapshot; subsequent changes are limited to documentation -and qualification comparison tooling. Fresh and saved installed outcomes pass -the strict same-source comparison, including package versions, effective user -unit activation, boot target, authentication and firewall policy. - -Cloud fixture preparation updates only its owned OS baseline, removes named -preexisting Cloud packages, selects the current generic Fedora identity and -boots the current kernel before installation. -The public installer does not prune user package inventories or old kernels. -Only the UTC spelling alias and equivalent session-target activation are -normalized; masks, missing units and inactive services remain differences. -The signed Brave keyring's background importer adds three prerelease keys on -running Fedora. The initial ISO chroot lacks `timeout` during that transaction; -both paths retain the identical keyring package and pinned stable repository. -Those three exact fingerprint/release identities are documented exceptions; -unknown keys and family-wide key exceptions fail. No trust records were deleted. -Every allowed package difference retains an explicit reason. See -[the installation contract](installation-parity.md). - -A new encrypted-US installation from the supported October 14 ISO passed, -followed by installation of these exact final split RPMs, reboot and recovery -boot/restore. It also verified the unused disk, encrypted Btrfs, login policy, -desktop parity, selected regional settings and live-system cleanup. These are -actual QEMU installations, not fixture-only evidence. Physical hardware, -Secure Boot and the full public-release matrix are outside these runs. - -Final build: `20261015T183818Z-25fb4f79`. Its ISO is `7,996,391,424` bytes; -the desktop RPM is `29,830,738` bytes and the offline companion -is `1,641,308,348` bytes. All artifact checksums and the SPDX ISO/RPM -bindings pass. The assembled payload also passed an exact credential-byte audit. - -The ISO is served at `/data/pxe/iso/CybexOS-Live-44-20261015T183818Z-25fb4f79.iso`. -The complete artifact bundle is `/data/cybexos-efficiency-20261015-yCv5AF/optimized-build-qualified-r6/artifacts` -(10,057,712,493 logical bytes), retained with compact qualification -reports for review. iVentoy refresh, image-list, running PXE status and service -checks passed. The JSON record contains final hashes and qualification reports. - -Disposable builds, test VMs, download/compiler caches, temporary logs and the -test container image were removed. The final ISO shares storage with the -retained artifact bundle. Preexisting PXE images were preserved, and the final -iVentoy refresh/list/service checks passed. No task-owned processes or mounts -remain. Compact cleanup and service checks are retained with the evidence. diff --git a/docs/fedora-major-upgrade.md b/docs/fedora-major-upgrade.md deleted file mode 100644 index 3488dba3..00000000 --- a/docs/fedora-major-upgrade.md +++ /dev/null @@ -1,368 +0,0 @@ -# Fedora major-upgrade runbook - -## Guided upgrade workflow - -`cybex upgrade-system` now coordinates preflight, a durable background DNF5 -**download**, an explicit offline reboot, target convergence and rollback. It -requires a separately reviewed target release; the current source manifest -supports **Fedora 44 only**. This workflow does not qualify or advertise Fedora -45. Incrementing a release number is insufficient. - -Select either a clean reviewed target checkout/release directory whose -`release-manifest.json` **and** inventory declare the target, or a signed -`cybexos-desktop` RPM for that release and architecture which provides -`cybexos-supported-fedora = `. The RPM signature must verify against the -installed trusted keyring. Selecting local source is an explicit administrator -trust decision, as with bootstrap; the workflow verifies compatibility and -freezes the selected bytes, but does not claim a local checkout hash proves its -author. A Git checkout must be clean and only tracked files enter the frozen -payload. No branch is pulled, reset or switched. - -Make and test an external backup first. Supply a JSON receipt beside its tar -archives, with paths relative to the receipt: - -```json -{ - "v": 1, - "createdAt": "2030-01-01T12:00:00Z", - "archives": [{ - "path": "workstation.tar.zst", - "sha256": "REPLACE_WITH_THE_ARCHIVE_SHA256", - "covers": ["/home/john", "/etc", "/var/lib/xps-hardware", "/etc/pki/akmods"] - }] -} -``` - -Use the actual backup timestamp, account home and checksum. The validator -requires a backup from the last seven days on another filesystem UUID, hashes -each archive, reads it completely through tar and verifies its declared -coverage. The account home and `/etc` are mandatory, plus the hardware/signing -paths when present. Archive members should be root-relative (`home/john/…`, -`etc/…`). This verifies integrity and coverage; the independent restore test -remains part of preparing the backup. - -```sh -cybex upgrade-system check --target --source /path/to/reviewed-release --backup /mnt/backup/receipt.json -cybex upgrade-system prepare --target --source /path/to/reviewed-release --backup /mnt/backup/receipt.json -# On an RPM installation, use --rpm /path/to/signed-target.rpm instead. -cybex upgrade-system status -# Once status is ready, close applications and explicitly start the offline upgrade: -cybex upgrade-system reboot -``` - -`prepare` returns after starting a durable root service. It does not reboot or -install packages into the running OS. Status becomes `ready` only after the -DNF download succeeds and the rollback checkpoint is armed. Closing the terminal -does not cancel the service. Its journal is under the `downloadUnit` named by -status. The helper never adds `--allowerasing` or disables signatures. - -Preflight requires the normal managed Btrfs root, free space on root/var/boot, -a clean RPM database, completed current-release updates, the default current -kernel, no pending hardware reboot, usable signed repositories, and healthy -installed camera ABI/userspace checks. Enabled repository URLs must follow -`$releasever` or be release independent; a URL pinned to the old Fedora release -must be reviewed first. Do not point the running system at target-only package -repositories. For an RPM target, the old CybexOS channel is omitted from the -offline download and the explicitly signed target RPM is applied after boot. - -`cybex upgrade-system cancel` is available after a completed or failed download, -before reboot is scheduled. It checks the saved metadata fingerprint before -cleaning DNF's offline state. It refuses to interrupt active DNF work or delete -an offline transaction that another operation replaced. An interrupted worker -retains diagnostic state rather than guessing which cached transaction to erase. -A normal reboot before scheduling the upgrade does not strand the download: -cancel or schedule it afterwards if the installed Fedora release and the saved -offline metadata are unchanged and no offline reboot is already scheduled. - -The first target boot converges the frozen source with the saved installation -choices, or installs/reconciles the staged RPM. RPM convergence requires the -current package's durable reconciliation status and every account to be ready; -a successful command exit alone is insufficient. Recovery waits for mounts, -the system bus and network availability, with login ordered after recovery. -Failed convergence, root health, -kernel or signing checks select the previous root and vendor desktop checkpoint -and reboot before allowing login. Successful root checks produce -`awaiting-desktop`, not success: the validation timer waits for an actual -Hyprland session, then verifies the managed shell through the transaction health -checks. A recovery bar does not count as a healthy desktop. A failed desktop -check selects rollback and records `restartRequired`; the active session is not -abruptly rebooted. Use `cybex upgrade-system reboot` to enter that restored root. - -Status is private under `/var/lib/cybexos/major-upgrade/`. Frozen release payloads -are under `/var/lib/cybexos/major-upgrade-payloads//`; source is retained for -reproducibility, while a committed RPM payload or explicitly cancelled payload -is removed. The transaction journal and checkpoint are independently stored in -the Btrfs recovery store. Personal files and settings are never reverted by the -vendor checkpoint. The backup covers data outside that checkpoint. - -`tests/major-upgrade.py` exercises artifact compatibility, signature gates, -staging, the download/reboot boundary, cancellation ownership, backup checks, -boot failure/rollback, deferred desktop validation and process-group cleanup -using fixtures. It is not an end-to-end Fedora major-upgrade qualification. - -## Release engineer preparation - -The playbook supports exactly the release named by `fedora_release`; this is a -safety boundary, not a default. Prepare and test repository support for the -next Fedora release before upgrading the workstation. Do not change the value -on the current-release branch and then continue applying that branch to the old -OS: the `always` preflight correctly refuses that mismatch. - -Replace `` below with the reviewed numeric release and use a dedicated -branch for all compatibility changes. - -## 1. Establish a recoverable baseline - -1. Finish or cancel any updater run. `cybexos-update-run status` must be terminal, - not `queued` or `running`. -2. Fast-forward the current-release branch, run `./update`, reboot into the - newest current-release kernel, and run `./verify --require-hyprland`. -3. Confirm that Secure Boot and the signed camera modules are healthy with the - checks in `docs/xps-2026-hardware.md`. Do not begin a release upgrade while - `/var/lib/xps-hardware/ipu7/reboot-required` describes the current boot. -4. Make and test a backup that covers the user's home, repository checkout, - `/etc`, `/var/lib/xps-hardware`, and `/etc/pki/akmods`. Also record - `rpm -qa`, `flatpak list --system`, enabled repositories, and the current - kernel. The guided workflow restores its pre-upgrade root/vendor checkpoint on - failure; the external backup and recovery media cover unsupported layouts - and personal data. Never attempt a mass package downgrade. -5. Ensure the prepared target-release branch and recovery media are available - without relying on this machine's graphical session. - -## 2. Prepare repository support - -Create an upgrade branch and audit every release-coupled input. At minimum: - -- change `fedora_release` and `supportedFedora` together, the README support - text, and the required job's container and name in - `.github/workflows/tests.yml` (its release assertion and the image tooling - read `fedora_release`); see [Flipping the release](#flipping-the-release); -- review required and optional RPM names (including the explicit `nodejs24` - packages), RPM Fusion release packages, vendor repositories, COPR output, - Flatpak behavior, and SELinux policy on the target release; -- publish and checksum a target-release `mdview` RPM instead of weakening its - `fc44` asset selector; review every other root RPM against the new DNF/RPM - stack; -- resolve and record a new digest for `source_build_container_image`; the - image must match the Fedora release used to build source applications; -- refresh the Fedora Distrobox source tag/digest if that box should follow the - host release. Existing boxes are not automatically recreated; -- rebuild and validate the XPS camera RPM/DKMS bundle against the target stock - kernel. Review the Fedora 44-specific BTF ABI baseline, spec description, - package names, patches, and hardware documentation rather than merely - changing comments; -- review the dated Rust nightly and all other immutable dependency pins for - compatibility, upgrading only those with a concrete reason; and -- run `python3 tests/fedora-release.py`, which names every remaining - release literal that disagrees with `fedora_release`, and inspect the rest - with: - - ```bash - rg -n 'Fedora 44|fedora:44|fc44|nodejs24|releasever|fedora_release' \ - README.md docs inventory roles site.yml tests image .github - ``` - -Parser fixtures containing `fc44` transaction examples do not need mechanical -replacement if they remain valid cross-release inputs. Change tests when the -contract changes, not for cosmetic consistency. - -Run `./tests/run` on the branch and require its target-release CI job to pass. -Exercise a disposable target-release VM or equivalent hardware-safe test host -for package resolution and Ansible check mode. A container can validate source -contracts, but it cannot validate SDDM, user systemd, Secure Boot, DKMS, camera, -audio, haptics, or the live Hyprland session. - -## 3. Download and perform Fedora's offline upgrade - -On the still-supported current release, commit or stash local work and verify -the DNF5 plugin exposes the expected commands: - -```bash -dnf system-upgrade --help -sudo dnf upgrade --refresh -sudo dnf system-upgrade download --releasever= -``` - -Read the proposed transaction. Stop and resolve repository, dependency, -package-removal, disk-space, or signature problems; do not reflexively add -`--allowerasing` or disable GPG checks. Once the download succeeds, recheck -that no repository updater is active, close applications, and start the -offline transaction: - -```bash -sudo dnf system-upgrade reboot -``` - -Do not interrupt power during the offline transaction. - -## 4. Converge the upgraded system - -1. After the first boot, confirm `/etc/os-release`, network access, storage, - Secure Boot state, and the running kernel from a console if the graphical - session is unhealthy. -2. Switch the checkout to the already-tested target-release branch. The old - branch should now fail its OS preflight, which prevents accidental use. -3. Inspect convergence first, then apply it: - - ```bash - ./tests/run - ./bootstrap --check --diff - ./bootstrap - ``` - -4. Let package, DKMS, and initramfs handlers finish. If the play or camera - diagnostics request a normal reboot, do it only after Ansible has reached a - terminal result. -5. Run `./verify --require-hyprland`, inspect the current boot and - `quickshell.service` journals, and manually verify camera, audio, haptics, - fingerprint, external displays, suspend/resume, Flatpaks, Distroboxes, and - the fallback GNOME session. -6. Run `sudo dnf system-upgrade log` if the offline transaction needs diagnosis. - Keep the old kernel and retained application/tool versions until the new - release has survived normal work and at least one suspend/resume cycle. - -Only merge the target-release branch and update the public support statement -after the installed-system checks pass. Record hardware gaps in -`docs/xps-2026-hardware.md` rather than hiding them with ignored failures. - -## Fedora 45 qualification - -**Fedora 45 is not qualified.** `fedora_release` remains `"44"` and -`release-manifest.json` lists only `"44"` in `supportedFedora`. Neither changes -until every item below has passed on a dedicated target branch, recorded with -dates, versions and run links. The `Fedora preview` CI job is an early warning -at source level; a passing fixture suite is not an installation result. - -### Repositories and COPR availability - -- [ ] Fedora 45 `fedora`, `updates` and `fedora-cisco-openh264` metalinks - resolve for x86_64, and the builder's `fedora-gpg-keys` ships - `RPM-GPG-KEY-fedora-45-x86_64`. `image/build.repo` uses `$releasever`, so it - needs no edit if the repository IDs keep their `fedora-N`, - `updates-released-fN` and `fedora-cisco-openh264-N` forms. -- [ ] RPM Fusion publishes `rpmfusion-free-release-45` and - `rpmfusion-nonfree-release-45` at the URLs `roles/apps` derives from - `fedora_release`, signed by the pinned key fingerprints, and every codec and - akmod package the roles request from it resolves. -- [ ] The Brave, 1Password, ChatGPT and Tailscale repositories only use - `$basearch`. Install each vendor RPM on Fedora 45 anyway: dependencies, - signatures under the new RPM, and scriptlets (the ChatGPT `%pretrans` that - `image/compose` installs in a second transaction) can still break. -- [ ] The `sdegler/hyprland` COPR has a `fedora-45-x86_64` chroot with - successful builds of every package the desktop requires: `hyprland`, - `hyprland-guiutils`, `quickshell`, `hypridle`, `hyprlock`, `hyprsunset` and - `xdg-desktop-portal-hyprland` (`image/cybexos-desktop.spec`). Its signing - key must still match the pinned fingerprint. -- [ ] Flathub has no Fedora release, but install every `apps_flatpaks` entry - on Fedora 45 to cover its Flatpak and OSTree versions. -- [ ] Required and optional RPM names resolve, including `nodejs24` and - `nodejs24-npm`, `base_required_packages`, and the CI dependency list in - `.github/actions/fedora-source-environment/action.yml`. - -### Hyprland and Quickshell - -- [ ] Record the Hyprland and Quickshell versions the COPR builds for Fedora - 45 and read the upstream changes since the last verified Fedora 44 versions - (Hyprland 0.56.2 and Quickshell 0.2.1 in this documentation). -- [ ] The compositor configuration is Hyprland Lua - (`roles/desktop/files/*.lua`, `roles/desktop/templates/*.lua.j2`). Confirm - that the API it calls keeps its semantics: `hl.bind`, `hl.unbind`, - `hl.dispatch`, `hl.exec_cmd`, the `hl.dsp` dispatchers (`exec_cmd`, - `window`, `focus`, `layout`, `global`, `exit`, `send_key_state`), - `hl.config`, `hl.get_config`, `hl.env`, `hl.monitor`, `hl.get_monitors`, - `hl.on`, `hl.window_rule`, `hl.layer_rule`, `hl.workspace_rule`, - `hl.animation` and `hl.curve`. The luajit fixtures in `tests/hyprland-*` - stub this API, so only a live session with no configuration errors proves - it. -- [ ] Run `tests/qml-lint` and the QML runtime stages with Fedora 45's Qt and - the COPR's `qs`, then run the shell through `quickshell.service` and check - its journal for QML errors. Check hypridle, hyprlock, the portals, screen - sharing and the authentication agent in the same session. - -### Anaconda and the custom installer - -- [ ] Diff the Fedora 45 Anaconda and anaconda-webui against the reviewed - reference, Anaconda 44.30 and anaconda-webui 68 (`image/INSTALLER.md`). - Review every interface `image/live-rootfs/usr/libexec/cybexos-installer-backend` - calls (storage scan, automatic partitioning with Btrfs and LUKS2, disk - initialization, tasks, users, localization and `InstallWithTasks`). Update - the strict proxy member sets in `image/test_installer.py` from the new - source rather than loosening them. -- [ ] Re-check `image/live-rootfs/etc/anaconda/conf.d/20-cybexos.conf`: the - `webui_web_engine` hook, hidden pages and spokes, the Btrfs default scheme - and the network default. Re-check the liveinst launcher's authorization and - its refusal of Kickstart-based live installations. -- [ ] Confirm that Anaconda still runs the post-scripts that - `usr/share/anaconda/interactive-defaults.ks` includes from - `post-scripts/90-cybexos.ks` through the Runtime DBus module, and validate - both kickstarts with pykickstart's `F45` handler. - -### Image build - -- [ ] `image/live.ks` and `image/build.repo` name no release, and - `image/compose`, `image/build-in-guest` and `image/build` read - `fedora_release` through `image/fedora_target.py`. -- [ ] Review the builder's livecd-tools/imgcreate. `image/compose` adds the - signature verification livecd-tools 31 skips, loads RPM 6's SQLite database, - installs `Requires(pretrans)` packages in a second transaction and hooks - `_configure_bootloader`. Re-read each workaround against the new version and - never drop signature verification. -- [ ] Pin the official Fedora Cloud 45 image name and SHA-256 together, from - Fedora's signed CHECKSUM file, in `image/build`, `image/qualify-checkout` - and `tests/fedora-vm-convergence` (and the literal in - `tests/repository-policy.py`). The builder runs the target release; the - source contract rejects a builder image for another release. -- [ ] Check the live initramfs that compose regenerates with - `image/dracut-live.conf`, the Plymouth `cybex` theme, GRUB and syslinux - branding on UEFI and BIOS, and the kernel, firmware and Mesa packages - compose requires. -- [ ] Sign the desktop RPM in a Fedora 45 container - (`.github/workflows/desktop-release.yml`) and publish a separate update - channel: `image/channels/stable.json` and `image/github_release.py` use the - `/44/x86_64` path. - -### CI preview job - -- [ ] The `Fedora preview` workflow (`.github/workflows/fedora-preview.yml`) - runs `./tests/run` in `fedora:45` on pull requests, pushes to `main`, - weekly and on demand. It shares the required job's dependency and COPR - setup, installs what Fedora 45 provides, runs every check it can, and then - fails the job if anything was unavailable. The job may fail without failing - its run; it is not a required check and `release.yml` never calls it. - Require a run with every stage passing and no `Unavailable` report before - flipping; even a clean preview qualifies nothing. - -### VM qualification - -- [ ] `./tests/fedora-vm-convergence` on the pinned Fedora Cloud 45 image: - a zero-change second pass and uninstall restoration. -- [ ] Build a Fedora 45 ISO from the target revision and run `image/test-live` - and `image/qualify` for every scenario in `image/qualification.py` - (`encrypted-us`, `plain-us`, `encrypted-nl`, `plain-nl`, `encrypted-de`, - `plain-de`), then `image/qualify-checkout` and `image/release-gate`, which - also requires the prior-release RPM upgrade and recovery check. -- [ ] Upgrade lifecycle: install the current Fedora 44 release, then run - `cybex upgrade-system` to 45 once with a signed target RPM and once with a - reviewed target checkout, plus a deliberately failed convergence that must - roll back. Use encrypted and plain installations. -- [ ] Hardware: the XPS camera bundle and BTF ABI baseline, Secure Boot, - audio, haptics, fingerprint, external displays and suspend/resume, as in - [section 4](#4-converge-the-upgraded-system). - -### Flipping the release - -Only after every item above passes, on the target branch and in one change: - -1. Set `fedora_release: "45"` in `inventory/group_vars/all.yml` and - `"supportedFedora": ["45"]` in `release-manifest.json`. The playbook - targets exactly one release, so the manifest names only that one. -2. Run `python3 tests/fedora-release.py`. It fails with each remaining - literal to review: the required job's container and name, the Fedora Cloud - pins, the signing container, the update channel path, the `fc45` mdview - asset and the source-build image comment. -3. Rename the required check to `Fedora 45 source contract` in branch - protection and `docs/releasing.md`; the `fedora-44` job id can stay. -4. Move the preview matrix to the following release. The source contract - rejects a preview of a supported release. -5. Rebuild and qualify the ISO and RPM from that revision. Earlier reports do - not carry over. diff --git a/docs/fusebox-widget.md b/docs/fusebox-widget.md index 68c69cea..63e29168 100644 --- a/docs/fusebox-widget.md +++ b/docs/fusebox-widget.md @@ -6,11 +6,10 @@ the last hour of load, each account's health and quota, which clients are connected, and the latest requests. It is the operator's view of the server. The built-in Model Usage widget remains the place for quota history and cost estimates. -Like the other connected-service widgets, it is on for new installations, -including ISO installations, that select connected widgets. Otherwise it is -available but off. Upgrading places it immediately after Model Usage in -whichever column Model Usage occupies, and turns it on only for installations -with connected widgets. No connection starts until a server and key are set. +Fusebox is available but hidden on new installations, including ISO installs. +Enable it in Settings → Menubar → Widgets. Connected-service support does not +force widget visibility. Saved visibility and server preferences are preserved. +No connection starts until a server and key are set. ## Setting it up @@ -20,8 +19,8 @@ Open Settings → Menubar → Widgets → Fusebox: `https://fusebox.example.ts.net`. A pasted dashboard link such as `…/#/accounts` is accepted. User names, passwords and queries are refused. - **Management key**: the server's `management-key`. The field is a password - field. Committing it hands the key to `scripts/fusebox.py` over stdin, which - writes it to `~/.config/cybexos/fusebox/management.key` (mode 0600 in a 0700 + field. Committing it hands the key to `desktop/quickshell/scripts/fusebox.py` over stdin, which + writes it to `~/.config/nerdos/fusebox/management.key` (mode 0600 in a 0700 directory, replaced atomically). The key is never stored in `shell.json` or on a command line. It is sent only to this server, as a bearer token in request headers, including the WebSocket upgrade, and never in a URL. The @@ -94,7 +93,7 @@ the page. brings it back. A fault whose expected end changes is a new incident and shows again; one that clears (unseen for 15 minutes, so a Fusebox restart doesn't count) is forgotten and shows the next time it trips. Dismissals are - kept in `~/.local/state/cybexos/shell/fusebox.json`. + kept in `~/.local/state/nerdos/shell/fusebox.json`. - **Load**: one card with requests in progress, requests in the last full minute, failures in the last hour and the median time to first token of recent successful requests, over one bar per minute for the last hour, @@ -215,7 +214,7 @@ request history. `Common/Fusebox.qml` owns one `Process` for the whole shell, independent of monitor count. It runs while the widget is on, or while its dashboard or -settings are open, and a server and key are set. `scripts/fusebox.py live` +settings are open, and a server and key are set. `desktop/quickshell/scripts/fusebox.py live` holds one WebSocket to `/api/live`. On every connect it first reads `/api/overview`, `/api/accounts`, `/api/requests?limit=40` and `/api/faults`, then forwards `load`, `faults`, `request` and `tick` events. Line-delimited @@ -256,45 +255,34 @@ restarts a helper that stays silent for 30 seconds; the helper prints a heartbeat at least every five seconds. The 60-minute history starts from the server's own minute buckets on connect and counts each finished request into its minute, as Fusebox's dashboard does. Breaker actions, priority and account details -are one-shot `scripts/fusebox.py action|activity` requests through +are one-shot `desktop/quickshell/scripts/fusebox.py action|activity` requests through `CommandRequest`; `action priority` sends only 10, 0 or -10, the dashboard's First, Normal and Last. Nothing is retried automatically. ```sh -cybexos-runtime ipc fusebox status -cybexos-runtime ipc fusebox refresh -cybexos-runtime ipc fusebox configure -cybexos-runtime ipc popouts open fusebox +nerdos-runtime ipc fusebox status +nerdos-runtime ipc fusebox refresh +nerdos-runtime ipc fusebox configure +nerdos-runtime ipc popouts open fusebox ``` To remove the key, use the undo chip on the Management key row, or delete -`~/.config/cybexos/fusebox/management.key`. Uninstalling CybexOS leaves user -configuration in place. +`~/.config/nerdos/fusebox/management.key`. System updates and generation +rollback retain this runtime credential. ## Tests -`tests/fusebox.py` covers key custody (permissions, atomic replacement, -symlinks, invalid keys, CLI output), URL handling, timestamps with Fusebox's -nanoseconds, the account, overview, fault, request, session and client -allowlists (plans, priority, reset plans, attribution, sources, limits read -from a configuration full of keys that must not leak), gzip answers and their -size bound, lockout, restart and rejected-key pacing, the derived fault rules, -and the live stream against a local Fusebox fake. The stream -tests check header-only authentication, snapshot order, event forwarding, -coalesced account refetches, the older-release fallback, rejected keys, -refused redirects and missing keys, and that seeded requests drop session -fingerprints. Its banked-reset tests check that reading status never spends, -that a spend sends the confirmed quote and grant exactly once, that a changed -inventory is refused, that a timeout, server error or unreadable answer is -reported as uncertain without a second request, and that unknown operation -states fail closed. No test spends a real reset. `tests/quickshell/fusebox.test.cjs` covers +Key custody, HTTP/WebSocket transport, reconnects, secret redaction and +banked-reset failures still require isolated native runtime qualification; +no test may spend a real +reset. `tests/quickshell/fusebox.test.cjs` covers the banked-reset rules and the guarantee that only the confirmation spends, the settings schema and migration, account state, quota meters and their Used/Remaining display, pace and run-out times, plan names, combined weekly limits, who used a window, sources, budgets, priority, badge-worthy faults, hidden names, request counting, figures, fault timing and notification rules, dashboard -links and registration. `image/test_desktop_payload.py` checks that the ISO +links and registration. The shared NixOS module checks that the ISO payload ships the same widget and derives the same layout as the checkout. -Run `./tests/run` and `python3 -B image/check-source`; use +Run `./tests/run` and `nix flake check`; use `tests/lib/quickshell-live` and the managed service for live checks. diff --git a/docs/gdm-replacement-research.md b/docs/gdm-replacement-research.md deleted file mode 100644 index b83fa27c..00000000 --- a/docs/gdm-replacement-research.md +++ /dev/null @@ -1,241 +0,0 @@ -# Replacing GDM while preserving single-password startup - -Research date: 2026-09-24. CybexOS baseline: `f89ae3d`. -Implementation: SDDM integration is present in both workstation provisioning -and image packaging. The running workstation has not been switched from GDM. -Source checks pass; full encrypted-install qualification is still pending. -ISO creation and PXE publication were deferred at the user's request. The -disposable builder completed the desktop RPM; it was stopped during image -composition, and its temporary artifacts were removed. No replacement ISO -was completed or booted. -The implementation uses only Fedora packages and the shared configuration -helper described below. - -## Validation completed - -- `./tests/run`: passed, including static checks, 1,040 unit tests and - 20 Python fixture programs. The active workstation's live QML lifecycle - check was skipped by the gate; no live shell changes were made. -- `image/check-source`: passed 13 welcome checks, 83 Python tests and four - Node checks. These include the guest-only login/keyring qualification harness. -- Login policy: 18 tests cover encryption checks, boot-attempt consumption, - concurrent starts, invalid policy, live-media isolation and safe defaults. - Both encryption verifiers also recognized the actual encrypted Btrfs host - through read-only inspection. -- Session launchers: six process scenarios cover normal exit, startup failure - and a real compositor-child SIGKILL in both deployment paths. -- An isolated GNOME Keyring/Secret Service fixture successfully created, read - and deleted a synthetic secret. This does not establish LUKS/PAM handoff. -- Full disposable Fedora 44 Ansible test: first install passed (233 tasks OK, - 115 changes); the second pass had 197 tasks OK and **zero changes**. Rendered - configuration/service assertions passed. Uninstall passed (40 tasks OK, - 18 changes), including exact restoration of the previous display-manager - alias, PAM/configuration and existing user preferences. SDDM was selected for - next boot without being started during provisioning. - -The VM fixture now seeds valid JSON preferences and optionally reuses -inventory-pinned downloads, checked on both host and guest, to avoid transient -font-server timeouts. No installation or restoration assertions were skipped. -All disposable builder/test artifacts were removed after these checks. - -**Still unqualified:** actual encrypted boot, boot-cache/keyring handoff, -graphical greeter recovery and physical hardware behavior. The four-cold-boot -qualification harness covers these software recovery cases when ISO testing -resumes. The running workstation remains on GDM. - -## Decision - -Moving completely away from GDM is technically viable with Fedora's **SDDM**, -systemd's `pam_systemd_loadkey` and GNOME Keyring's existing PAM module. -This is the most practical candidate for an encrypted, single-user CybexOS -installation. It needs managed configuration and lifecycle handling, but no -new authentication module or fork of a login manager. - -Keep the running workstation on GDM until a replacement image passes the -acceptance checks below. Source-level viability is not evidence that our -complete Fedora initramfs, SELinux, installer and desktop path works with SDDM. - -Stock Fedora 44 greetd is less suitable for the complete requirement. Its -automatic `initial_session` skips PAM authentication, which prevents the -standard password-loading/keyring authentication sequence from running. -Its ordinary password-login keyring support does not resolve this difference. -[Versioned greetd worker](https://github.com/kennylevinsen/greetd/blob/0.10.3/greetd/src/session/worker.rs), -[initial-session implementation](https://github.com/kennylevinsen/greetd/blob/0.10.3/greetd/src/context.rs). - -An upstream module, `pam_fde_boot_pw`, exists specifically for this greetd gap, -but it was not available in the configured Fedora repositories. Adopting it -would add a third-party authentication package for CybexOS to maintain and -qualify. That is a poor trade for this task compared with SDDM's standard path. -[Module documentation](https://git.sr.ht/~kennylevinsen/pam_fde_boot_pw/blob/master/README.md). - -## Credential and session flow - -The intended sequence is: - -1. The owner enters the LUKS passphrase during boot. -2. The boot password agent temporarily caches it in the kernel keyring. -3. SDDM automatically starts the selected owner's CybexOS session. -4. Its PAM authentication stack loads the cached password and supplies it to - GNOME Keyring; the session stack starts/unlocks the keyring. -5. The existing `hyprland-quickshell` launcher starts the desktop. - -Systemd explicitly documents this use with `sddm-autologin`: load the password -with `pam_systemd_loadkey`, then use `pam_gnome_keyring`, with a service -`KeyringMode=inherit` override. The keyring password must match the LUKS -passphrase. The installed systemd-pam 259.9 package provides the module. -[Version-matched systemd documentation](https://github.com/systemd/systemd/blob/v259/man/pam_systemd_loadkey.xml). - -Fedora's existing SDDM autologin PAM stack must be extended in the correct -order, retaining its SELinux, account, session and login bookkeeping. Keep -`gnome-keyring` and explicitly retain `gnome-keyring-pam` after removing GDM. -No blank keyring password, plaintext password file or password-processing -shell helper is required by this design. -[Fedora autologin PAM configuration](https://src.fedoraproject.org/rpms/sddm/raw/f44/f/sddm-autologin.pam), -[SDDM authentication implementation](https://github.com/sddm/sddm/blob/v0.21.0/src/helper/backend/PamBackend.cpp). - -The existing CybexOS installer initially gives the disk and account the same -password. These passwords can later diverge; the keyring is another separately -encrypted store. Migration must preserve existing keyrings and must never -silently reset them. The current installer promises protected keyrings with -possible first-use unlocking; it does not qualify an end-to-end boot-password -handoff. See [the installer contract](../image/INSTALLER.md). - -The standard cache expires after 2.5 minutes. Long boot delays, an unavailable -cache, a different recovery passphrase, a mismatched keyring password or token -unlock without the expected passphrase can therefore require manual keyring -unlock. Preserve that fallback. Do not persist passwords or make the cache -unlimited to conceal these cases. Multiple encrypted volumes also require a -test because the password loader selects the last cached password. -[Password-cache documentation](https://github.com/systemd/systemd/blob/v259/man/systemd-ask-password.xml). - -## Once-per-boot policy - -| Event | Required result | -| --- | --- | -| Ordinary encrypted cold boot | LUKS unlock, automatic desktop, unlocked keyring when the matching boot password is available | -| Lock or suspend/resume | Existing Hyprlock authentication | -| Logout or compositor crash | Authenticated recovery login | -| Login-manager restart during this boot | No repeated automatic desktop entry | -| Missing keyring password/cache | Protected keyring remains locked until explicitly unlocked | -| Unencrypted installation | Existing normal-login policy remains in force | - -SDDM's `Relogin=false` handles session exit while its daemon remains alive. -However, a fresh daemon process treats startup as its first display again. -Thus it needs an additional once-per-boot guard for our stricter restart -policy. This is not a claim that our current GDM setup already guarantees -that restart behavior. -[SDDM display lifecycle](https://github.com/sddm/sddm/blob/v0.21.0/src/daemon/Display.cpp). - -SDDM clears that first-display flag **before** attempting autologin, so PAM or -early session failure does not authorize another attempt in the same daemon. -Greeter recovery also relies on Fedora's patch introduced in SDDM 0.21.0-12: -it maps every nonzero session exit or signal to a session error. Unpatched -0.21.0 can mistake launcher exit status 1 for an authentication error and leave -the greeter unavailable. Fedora 44's 0.21.0-13 includes the fix. The disposable -qualification deliberately injects exit status 1 to exercise this case; -source inspection and fixtures do not establish a successful boot result. -[First-attempt ordering](https://github.com/sddm/sddm/blob/v0.21.0/src/daemon/Display.cpp#L255-L269), -[Fedora patch](https://src.fedoraproject.org/rpms/sddm/raw/f44/f/sddm-0.21.0-fix-restart-greeter-when-helper-is-in-a-wrong-state.patch), -[Fedora package and changelog](https://src.fedoraproject.org/rpms/sddm/raw/f44/f/sddm.spec). - -Both CybexOS launchers run Hyprland once and stop the desktop user target when -it exits. They deliberately omit `start-hyprland`: its watchdog restarts a -crashed compositor inside the same login session, whereas this policy requires -returning to the password greeter. The watchdog can preserve a prior lock state, -but it does not implement that login policy. Launcher fixtures verify normal -exit, startup failure and a real SIGKILL without a second compositor start. -[Hyprland 0.56.2 watchdog](https://github.com/hyprwm/Hyprland/blob/v0.56.2/start/src/main.cpp). - -The shared root-owned `/usr/libexec/cybexos-login-prepare` helper runs before -SDDM. It reads `/etc/cybexos/login.json`, verifies the owner and root encryption -(including every member of a multi-device Btrfs filesystem), -and atomically consumes `/run/cybexos-login/autologin-used` before the first -automatic-login attempt. Subsequent daemon starts disable autologin. The marker -survives service stop/restart and disappears at reboot. Failed early attempts -also consume the marker. Concurrent preparation is serialized with a file lock. -A fresh password greeter defaults to the CybexOS session; an existing SDDM -session choice is preserved. This helper never reads or handles passwords. -The live ISO exception requires -both its boot command line and a root-owned live-account marker. - -SDDM 0.21 does not accept a `--config` file option. Its managed configuration -uses the highest-precedence `/etc/sddm.conf`, written atomically as a regular -file with restored SELinux labeling. Preparation first disables stale autologin -from a previous boot. A file under `/run` alone would not be read. [Configuration reader](https://github.com/sddm/sddm/blob/v0.21.0/src/common/ConfigReader.cpp). - -## Integration and acceptance - -Fedora 44 provides SDDM 0.21.0-13 and a generic Wayland greeter backend using -Weston. Use a packaged backend first. A custom Hyprland greeter/theme would be -additional work without improving ordinary automatic startup. -[Fedora SDDM](https://packages.fedoraproject.org/pkgs/sddm/sddm/fedora-44.html), -[generic Wayland backend](https://packages.fedoraproject.org/pkgs/sddm/sddm-wayland-generic/fedora-44.html). - -SDDM is Fedora-packaged, but should not be described as Fedora KDE's guaranteed -long-term default: Fedora documents a transition to Plasma Login Manager. -Package availability and upstream maintenance should be rechecked when the -replacement is qualified. [Fedora change](https://fedoraproject.org/wiki/Changes/PlasmaLoginManager). - -Workstation provisioning installs SDDM and the generic Weston greeter backend, -then selects SDDM for the next boot without restarting the current display -manager. The new boolean `desktop_autologin` setting accepts saved -`gdm_autologin` answers as a compatibility alias; an explicit new value wins. -Provisioning backs up the previous display-manager alias, PAM and managed -configuration once. Uninstall restores them without interrupting the session. -Existing GDM packages remain available for recovery; new images do not request -GDM. The existing session launcher and Hyprlock remain in use. - -The desktop RPM installs the same helper, PAM template and systemd drop-in. -Its post-transaction step applies the PAM template while preserving the original -SDDM-owned file. Fedora retains ownership of that PAM path. The service uses -`KeyringMode=inherit`; the authentication stack loads the systemd boot key before -calling GNOME Keyring, preserving Fedora account/SELinux/session bookkeeping. - -Qualify a disposable encrypted installation before changing the default: - -- Fresh boot creates/unlocks a protected login keyring without a second prompt; - existing keyrings and a synthetic saved secret survive repeated boots. -- Normal logout, Hyprland crash, SDDM crash/restart and early startup failure - cannot produce another unprotected automatic session. -- Missing/expired cache, password mismatch and recovery-key boot retain a - working manual unlock path, without resetting the keyring. -- Suspend/resume, alternate keyboard layouts, monitor/GPU behavior, portals - and account access work with SELinux enforcing. -- Both live-media startup and installed-system behavior work, including - unencrypted installs and rollback to the previous login manager. - -Removing GDM does not itself remove every GNOME package. Calendar still calls -GNOME Settings, which still pulls in settings-daemon. The former explicit -settings-daemon dependency and GNOME idle-power writes have been removed. Retain GOA, Evolution Data -Server and GNOME Keyring for the native account/calendar features. Measure the -replacement image's actual dependency and size reduction before claiming a -performance or footprint improvement. - -## Operation and recovery - -For checkout installations, configure `desktop_autologin` through the saved -installer configuration. Provisioning records the selected owner and choice in -`/etc/cybexos/login.json`; the helper makes the final eligibility decision at -boot. Image installations receive this policy from the confirmed installation -plan. Turning the policy off leaves normal password login available. - -The nonsecret decision can be inspected with: - -```bash -sudo cat /run/cybexos-login/status.json -systemctl show sddm.service -p KeyringMode -sudo journalctl -b -u sddm.service -``` - -Do not reset an existing keyring to resolve an unlock prompt. Its password may -differ from the disk/account password, or the brief boot cache may be missing. -Ordinary SDDM password login uses Fedora's existing PAM configuration, and -applications retain their normal keyring-unlock prompt. - -If a workstation migration needs recovery, log into a text console and select -the retained GDM service for the next boot with -`sudo systemctl enable --force gdm.service`. This requires the retained GDM -package; fresh SDDM-only images do not include it. The one-time login backups -are under `/var/lib/cybexos/backups/login/`. A full CybexOS uninstall restores -those original files and the original display-manager alias. Selecting a -next-boot manager does not terminate the running graphical session. diff --git a/docs/hardware-security.md b/docs/hardware-security.md new file mode 100644 index 00000000..3b2ef00a --- /dev/null +++ b/docs/hardware-security.md @@ -0,0 +1,110 @@ +# Hardware and security qualification + +The supported target is fresh x86_64 UEFI NixOS 26.05. Current implementation +status is developmental. This document distinguishes declarations from tested +hardware support; no physical hardware was qualified during this implementation. + +| Area | Native implementation | Qualification still required | +| --- | --- | --- | +| Generic desktop | Hyprland pinned by the documented compatibility overlay; Quickshell and Qt from machine Nixpkgs; PipeWire, portals, Bluetooth, Gutenprint/free HPLIP printing and scanning, Orca/AT-SPI/speech-dispatcher, IBus typing booster, firmware/microcode, NetworkManager | Installed compositor/titlebar runtime, real login, suspend/resume, external displays and peripheral matrix | +| Encryption | LUKS2 root, ext4, separate ESP, zram; runtime ancestry verification gates automatic login | Encrypted fresh install/first boot, wrong/missing unlock, every persistent storage branch and swap | +| Login/authentication | Password-only SDDM login; GNOME Keyring; fingerprint/FIDO2 alternatives for sudo, Polkit and lock with password fallback; no crash relogin | Physical readers/keys, PAM failure paths, keyring unlock and lock-before-suspend | +| Firewall | Enabled; LocalSend 53317 TCP/UDP and mDNS 5353 UDP; gaming ports explicitly opt-in | Packet-level tests, VPN routing, application/container interactions | +| NVIDIA | NixOS packaged driver, explicit open-module choice, modesetting/power options | Representative supported GPUs, hybrid graphics/PRIME choices, suspend, signing with Secure Boot | +| Dell XPS 2026 | Exact DMI/SKU 0DB9/0DBA, Panther Lake, panel EDID and sensor gates; latest Nixpkgs kernel, OLED quirks, haptics, speaker filter, adaptive thermald and Intel LPMd | Both SKUs, panel variants, audio routing/limiter, repeated suspend/hotplug, fingerprints | +| XPS processed camera | Pinned Intel HAL/bins/icamerasrc and PSYS companion; native ISYS/core/CVS stay upstream; private-layout guard before PSYS load | Current kernel BTF/private ABI, HAL paths, v4l2 relay, actual processed frames, camera privacy and suspend/resume | +| Secure Boot | Native Lanzaboote option and runtime signing-key path; firmware verification is never disabled | Signed installation media, safe enrollment, key backup/rotation/revocation, NVIDIA/camera module signing and physical boot qualification | + +Secure Boot installation currently stops before erasure. It is not supported +merely because Lanzaboote evaluates. Existing firmware verification must not be +disabled to get the installer to boot. Keys belong in +`/var/lib/nerdos/secrets/secureboot` with root-only permissions, outside the +store and source. Completing signed media, enrollment and module signing is a +release blocker, not an optional post-release improvement. +The kernel must enforce the intended signing/lockdown policy, and installation +media must authenticate its external compressed system payload as well as its +boot executable. A signed UKI alone does not establish either property. No +Secure Boot or tamper-resistance qualification is claimed. + +NVIDIA driver selection is independent of proprietary applications. Selecting +the driver permits only its required unfree NVIDIA packages even with the +proprietary application group disabled; the open kernel module still needs +proprietary userspace. PRIME offload keeps the integrated GPU as the desktop +renderer and exposes explicit offload for applications. + +The XPS camera recipes retain the processed ISP path rather than presenting a +raw sensor node as a working camera. A sensor name or loaded driver is not +capture qualification. The guarded external companion must never shadow an +upstream core/ISYS/CVS module. An absent/unreviewed BTF layout refuses loading. +Do not extend a reviewed layout signature based only on a matching kernel +version. Compare compiled structure sizes/offsets and test actual frames. +On the selected physical camera system, explicitly run +`xps-ipu7-camera-frame-check --device /dev/video50` with the actual processed +relay device. It skips warm-up buffers and requires complete, changing frames +in memory. It never saves images or runs automatically; a static scene can +require moving an object to prove freshness. Its source fixtures do not +qualify physical capture. +On the currently selected Linux 7.3 kernel, the compiled stock BTF reports an +`ipu7_bus_device` size of 1008 bytes with `auxdrv` at offset 872. Neither +reviewed signature accepts this layout; Linux 7.2.11 has the same mismatch. +A task-local unstripped PSYS build matched the stock layout for eight inspected +structures, but the ninth could not be parsed and no processed frames were +available. That probe does not authorize extending the signature. The managed +ABI service fails closed and blocks camera initialization and update health +confirmation on this configuration. This is a concrete XPS camera release +blocker, despite the complete XPS system building successfully. Do not disable +the guard or omit the camera profile to qualify a machine that has that camera. + +The relay retains the `ov08x40-uf` sensor, 180-degree rotation and 1920x1080 +NV12 output. Initialization acquires CVS before probing the sensor; suspend +quiesces the relay and resume reruns initialization without unloading upstream +core, ISYS or CVS. Raw ISYS nodes lose the desktop uaccess grant before seat +ACL processing. These declarations still require privacy and capture tests. + +Pinned vendor camera sources need requalification at every relevant kernel/HAL +change and should be removed when Nixpkgs supplies the full processed stack. + +The exact XPS profile enables adaptive thermald alongside Power Profiles +Daemon. Intel LPMd 0.1.0 is a narrowly pinned native package because the selected +Nixpkgs lacks it; remove the recipe when Nixpkgs supplies a maintained package. +Its upstream Panther Lake policy and platform checks remain intact: the M204 +profile does not restrict CPUs under PPD, but supplies the SoC power slider. +Do not infer measured power savings from the daemon being installed. LPMd, +thermald and camera initialization participate in system health checks when +applicable; power, thermal safety and repeated sleep require physical tests. + +AppArmor, restricted kernel inspection/ptrace, sudo authentication and Polkit +remain enabled. The replaced system qualified SELinux in enforcing mode. +Enabling AppArmor on NixOS does not reproduce that broad mandatory access +control policy: the current explicit application exception supports Chromium +user namespaces and is not equivalent service/application confinement. A +reviewed native confinement policy, enforced-profile/process coverage and +negative access tests are still required before release. Do not report the +AppArmor enable flag as security parity. This is a separate security release +blocker alongside Secure Boot. Login passwords are initialized through stdin in the target; +subsequent `passwd` changes persist across rebuilds. No login password/hash is +embedded in Nix source. Runtime VPN/credential files use root-private paths or +NetworkManager's persistent secret storage. Nix evaluation/build logs can contain +administrator-supplied module text: keep secrets out of declarations as well as +out of Git. The updater only exposes private logs after administrator authentication. + +Settings enrolls/removes fingerprints and security-key credentials through their +persistent APIs. Authentication policy belongs to personal NixOS modules: +set `nerdos.authentication.fingerprint = true;` or `false`, then preview with +`nerd update stage local` and apply the reviewed transaction. Settings displays +the active policy without offering a switch that bypasses the staged lifecycle. +Firmware has a separate explicit action in the Update drawer; its device writes +are outside Nix generations and cannot be undone by a system rollback. + +NetworkManager's OpenVPN, OpenConnect, SSH and maintained Libreswan plugins are +included. Libreswan supplies Cisco-compatible IKEv1/IPsec, as recommended by +[GNOME's security notice](https://blogs.gnome.org/mcatanzaro/2026/09/15/privilege-escalation-vulnerabilities-in-networkmanager-plugins/). +The archived networkmanager-vpnc 1.4.0 remains vulnerable to +CVE-2026-91840/91841 and is excluded; insecure packages are not globally +permitted. Real Cisco gateway authentication, routing, DNS and reconnect still +require qualification. The native wrapped T3 Code AppImage builds but needs runtime +qualification; native titlebar/compositor integration is still being completed. +Connected account services and the Hermes bridge have native declarations; +their real account/credential flows remain unqualified. Installing a related +command is not proof of product parity. See the current +[validation record](validation.md). diff --git a/docs/installation-parity.md b/docs/installation-parity.md deleted file mode 100644 index 07928c6d..00000000 --- a/docs/installation-parity.md +++ /dev/null @@ -1,217 +0,0 @@ -# Installation parity - -The ISO defines CybexOS's installed product. A checkout installation and an ISO -installation of the same release, on equivalent hardware with the same user -choices, must receive the same desktop, applications, account defaults, -authentication policy, services, firewall, and hardware configuration. - -Fresh installations use these ISO defaults: - -| Choice | Default on both paths | -| --- | --- | -| Applications | Every standard application group enabled | -| Personal defaults | Enabled, including Fish, Kitty, Git/SSH and browser preferences | -| Sudo and local Polkit | Password required | -| Docker administrator access | Sudo required | -| Desktop automatic login | Enabled only after complete root encryption is verified | -| Next boot | Graphical login through SDDM; convergence leaves the current session running | -| Additional local-network firewall ports | Disabled; LocalSend and mDNS (printer and scanner discovery) remain open | -| Files SMB workgroup | `WORKGROUP`; explicit per-user workgroups take precedence | -| Machine identity | Preserve the identity already configured by Fedora/Anaconda | - -`inventory/group_vars/all.yml` is the common default input. -`image/installation_policy.py` defines the account policy used by both -`scripts/installer-defaults` and the packaged `cybexos-config` helper. The -checkout questionnaire starts from that policy instead of maintaining its own -booleans. It uses the ISO's boot-time encryption verifier, including every -Btrfs member. Unverifiable or mixed encrypted/plaintext storage disables -automatic login. Real installs run this read-only probe with administrator -access; `./install --check` never elevates or installs dependencies and cannot -enable autologin if its access is insufficient to verify encryption. - -Saved choices take precedence. Re-running or updating an installation must not -silently turn on personal defaults or passwordless access for an older account. -Docker membership uses the same ownership-aware grant/revoke policy on checkout, -ISO configuration and RPM reconciliation. An explicit saved choice for the -selected account can revoke Docker access even on an older ISO without an -ownership marker. Automatic convergence without that choice removes only -CybexOS-owned grants; independently administered access and unrelated groups -are preserved. Changes take effect in new login sessions. When repair first -records an older installation's choices, it retains existing Docker access -until the administrator explicitly changes it with `cybex configure`. -`--reconfigure` offers the saved boolean choices as its defaults. An explicit -identity change in the checkout questionnaire still applies that choice. -Neither parity nor a release update authorizes repartitioning an existing -Fedora installation or resetting user settings to match a clean account. -Shell preferences, including wallpaper selection, are seeded only if the -entire settings file is absent. An explicit empty wallpaper, sparse overrides, -unknown fields and future or corrupt documents remain unchanged on both paths; -concurrent creation of a settings file takes precedence over seeding. -Fastfetch, Voxtype, Oh My Posh, MIME associations, and npm configuration are -seeded only when absent, as on the ISO. Managed Fish, Kitty, Git and SSH fragments remain updateable while their bytes -match the shared ownership ledger. Conflicting edits and deletions are preserved -on both paths. Git/Kitty includes precede user values; SSH fallbacks follow them. -The default web apps (Slack, WhatsApp, YouTube, Google Photos and X) are seeded -once per account by the same helper, `cybexos-webapp seed`: the checkout's -dotfiles role runs it, and the ISO's `cybexos-user-init` runs it at -installation and login. The helper records each default it seeds, so a web app -the user removed is never added back on either path. - -The checkout path installs onto existing Fedora and retains source-release -updates and uninstall; the ISO uses Anaconda for disk/account creation and RPM -delivery for desktop updates and repair. These mechanisms are distinct from the -installed policy. - -`image/system_packages.py` reads the system selections and exclusions from -`image/live.ks` and the runtime requirements from the desktop RPM spec. After -the shared repositories are configured, checkout installs that contract, -including its package groups and version constraints. The local delivery RPMs -are supplied by checkout's application and desktop roles. Both paths disable -weak dependencies and use the complete shared locale and firmware baseline; -only device-specific configuration is gated on detecting the device. The -application builder leaves system composition to the ISO compose step. -Checkout resolves the ISO's full codec providers and `ocl-icd` in its first -base transaction, before desktop dependencies can select alternative providers. - -Installer-only paths keep that policy. An unattended seed's `config` choices -(and the choices a factory reset keeps for the next owner) pass through -`installation_policy.choices`/`apply_choices`, the function the checkout -installer's saved-choice merge uses; `test_installation_parity.py` checks that -the same explicit choices produce the same configuration on both paths. The -new-owner setup provisions its account with the same fresh-account -`cybexos-configure-installed --offline` run as Anaconda's in-target hook. The -first-start, new-owner and factory-reset units ship only in the ISO's RPM -(with the graphical new-owner window and the `cage` package it needs): a -checkout installation runs on an existing Fedora system with its own account, -never has OEM state or a factory recovery point, and `cybex factory-reset` -explains that. Factory-point retention lives in the shared -`cybexos-system-snapshot`, so both paths prune identically. Comparing a development checkout with an older ISO is not a -parity test: both artifacts must come from the same revision, with completed -hardware setup and equivalent application choices. - -## Required checks for changes and releases - -Run `./tests/run` and `python3 -B image/check-source`. Both required CI jobs run -`image/test_installation_parity.py`. It executes the real checkout questionnaire -and non-interactive dry run, then compares the complete generated configuration -with ISO provisioning and target finalization for encrypted and plaintext -installations. It also checks saved opt-outs, existing personal files, mixed or -unverifiable Btrfs, and the policy module shipped in the repair payload. - -The existing image package, desktop payload, installed policy and user parity -tests cover package selections and shared task/template sources. Extend those -checks whenever a package, setting, service or hardware path changes; a new -feature cannot be added to only one installer's schema. Hardware roles run -unchanged on both paths: `image/test_nvidia.py` checks that the checkout and -the ISO's boot-time hardware setup apply the same NVIDIA role, payload and -session environment. - -Recovery boot hooks preserve existing upstream directory permissions and -ownership on both paths. Fedora's -[`grub2-common-1:2.12-66.fc44` RPM](https://kojipkgs.fedoraproject.org/packages/grub2/2.12/66.fc44/noarch/grub2-common-2.12-66.fc44.noarch.rpm) -(SHA-256 `8482c540ca4badf57eaee30576af74f238bc23eabfd456498e00aa1a29d111b0`) -owns `/etc/grub.d` as `0700 root:root`; checkout must not force it to `0755`. -Checkout creates missing hook directories and manages its own root-owned files. -The image RPM owns the GRUB hook and vendor kernel-install hook, without -claiming their upstream parent directories. `image/test_update_recovery.py`, -also run by the source parity gate, executes the real Ansible tasks twice and -compares the RPM assembly with existing restricted and missing directories, -including preservation of administrator ownership. - -Release changes must pass the source and image gates from the same Git revision -and the existing generic Fedora and ISO installation/upgrade qualification -gates. Build and qualify a new ISO/RPM when its payload changes. Retain the -revision and artifact digests in the qualification evidence. Fixture checks -compare the installation contract; they are not evidence that fresh physical -or VM installations have been performed. Do not use an older ISO qualification -as evidence for a newer checkout. - -## Real installed-outcome gate - -`image/release-gate` requires two full checkout installations, `plain-us` and -`plain-nl`, in addition to the existing four graphical ISO installations and -prior-release RPM upgrade/recovery check. They use a checksum-pinned Fedora 44 -Cloud image, the same QEMU hardware/UEFI configuration as the ISO guests, all -normal feature defaults, the public `./install --non-interactive` entry point, -and real SDDM password login after reboot. `tests/fedora-vm-convergence` remains -a separate convergence/uninstall test; its feature opt-outs cannot satisfy -this gate. - -Before invoking the public installer, qualification updates only its newly -created Cloud guest to the current Fedora OS baseline, removes the explicitly -listed preexisting Cloud packages, selects the generic Fedora release identity, -reboots onto the current kernel, and uses -DNF's supported old-installonly cleanup. The report records that preparation. -The public installer does not prune users' old kernels or package inventories. -The test asserts the installer's graphical boot target and SDDM enablement -instead of setting them after installation. - -The builder includes the complete reviewed source set in `source.tar.gz` -alongside its ISO/RPM artifacts. A canonical digest covers file names, content -and executable bits, including intentional non-ignored working-tree changes. -It is embedded in the ISO's existing `build.json`. The builder verifies that -the archived content matches, so an edit during source capture aborts the -build. Checkout qualification extracts this exact archive, validates its -checksum and content, and requires the ISO qualification to identify that -exact source and ISO digest. Extraction rejects links, traversal and duplicate -paths. The runner's newer checkout cannot substitute for the tested source. - -Each guest produces `outcomes-fresh.json`, then saves explicit shell/input -preferences, a valid personal Hyprland override and unknown future settings -fields. It reapplies its own installation path, reboots, verifies those values -survived and produces `outcomes-saved.json`. Captures require a running desktop -and exactly one Quickshell process owned by `quickshell.service`. The managed -Settings lifecycle test exercises Network, Sound, Online Accounts, Keyboard, -Touchpad and Region, including watcher cleanup and the current QML journal. - -`image/installed_outcomes.py` compares effective shell/input settings, saved -installer choices, login policy, actual sudo authorization, Polkit policy, -account groups/shell, required RPM versions, Flatpaks, application commands and -associations, service enablement/activation, firewall policy, SELinux, recovery -support, default boot target, filesystem and personal-file identities. Target -dependencies and per-user unit links can provide equivalent session activation; -masked/missing units and inactive services remain differences. The UTC timezone -alias is canonicalized for comparison without changing the saved choice. -It retains the complete RPM -inventories. Every additional package difference needs a reasoned entry in -`image/parity-exceptions.json`; required package/version differences always -fail. Exceptions cover the ISO delivery RPMs, Cloud provisioning tools and three -exact Brave prerelease key records. The signed `brave-keyring` package imports -them in its background postinstall script on running Fedora; the initial ISO -chroot lacks `timeout` at that point. Both paths have the same keyring payload -and pinned stable repository. The exception includes each full fingerprint and -RPM release; unknown keys still fail, and family-wide signing-key exceptions -are rejected. Review new actual baseline differences before extending that file. - -`parity-fresh.json` and `parity-saved.json` name mismatches. The release gate -embeds passed same-source checkout evidence in both plain ISO reports. -`image/prepare-github-release` also rejects missing, stale or fixture-only -parity evidence when invoked independently. - -To rerun checkout comparison against a completed candidate (its corresponding -ISO qualification must have used `--capture-outcomes`): - -```sh -image/qualify-checkout --execute-vm --scenario plain-us \ - --artifacts /path/to/build/artifacts \ - --iso-results /path/to/qualification/plain-us \ - --output /path/to/new-task-specific-checkout-output -``` - -The runner removes task-owned VM disks, SSH keys, cloud seeds, transient logs -and screenshots on success/failure, retaining compact reports. -`--keep-artifacts` retains unresolved diagnostics, but never the cloud seed or -SSH private key. Testing OS ISOs still use `/data/pxe/iso` and the existing -checksum/iVentoy workflow. Existing Fedora hosts are never repartitioned. - -The saved-choice ISO step uses `cybexos-configure-installed --offline` with -the prepared application payload, so outbound networking remains blocked -through reconfiguration. Checkout qualification runs the public installer -with its normal repository access. It prepares the shared codec repositories -and selects the ISO's final providers before base and desktop transactions, -avoiding dependencies left behind by replacing interim Fedora codecs. - -`image/test_installed_outcomes.py` tests content identities, archive validation, -comparison guards and guest workflow construction without booting a VM. These -source tests do not qualify an installation or imply the expanded matrix ran. -New release evidence must come from executing the gate. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..36521931 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,253 @@ +# Fresh installation + +Boot a completed NerdOS x86_64 ISO in UEFI mode. The ISO starts only the +installer: there is no live desktop, display manager, live account or sudo. The +boot menu offers: + +- **Install NerdOS VERSION**, the default. +- **Install NerdOS VERSION - safe graphics**: `nomodeset`, so the screen draws on + the firmware framebuffer (simpledrm) when a native display driver fails. +- **Install NerdOS VERSION - text mode**: the terminal installer on tty1. +- **Verbose boot diagnostics**: the default installer with kernel messages. + +Every entry starts the same fresh installer; none starts a disk operation by +itself. The upstream **Options** submenu, firmware setup and shutdown entries +remain. PXE uses the same ISO. iVentoy's NixOS hook needs the upstream scripted +live initrd; installed machines use systemd stage one. A successful virtual-CD +boot does not establish a successful iVentoy boot. + +## The installer screen + +tty1 shows `nerdos-installer`, a Rust and Slint kiosk drawn by Slint's software +renderer straight to KMS/DRM, with keyboard, mouse and touch input through +libinput. It needs no compositor, browser, Qt or GPU userspace, so one image +draws through amdgpu, i915/xe, nouveau, virtio-gpu or the firmware framebuffer, +and the medium carries no separate NVIDIA kernel. The 1280×800 design canvas is +zoomed to the largest connected mode (1920×1200 → 1.5, 2880×1800 → 2.25) and +centred. It runs as the unprivileged `nerdos-installer` user in a logind session +on seat0, the same unit shape as NixOS's `services.cage`. If no display device +works after a few attempts, it switches tty1 to the text installer. + +The screen owns no state. `nerdos-installerd` (root) keeps the non-secret draft, +Wi-Fi, time zone detection, the keyboard layout and diagnostics, and drives the +one disk-writing worker, `nerdos-install --json-ui`, over its private protocol. +The screen receives bounded JSON snapshots (`nerdos.installer-state.v1`) over +`/run/nerdos-installer/control.sock` (mode 0660, group `nerdos-installer`). A +screen restart, whether for a keyboard change, a crash or Ctrl+Alt+Backspace +(which Slint's KMS backend treats as quit), resumes on the same step. The service +unit uses `KillMode=process`, and the worker keeps installing if its output is +lost, so a restarted screen or service never interrupts an installation. + +The flow is **Welcome → Disk → You → Review → Installing → Done**: + +- **Welcome** sets the language and keyboard and shows the system check: + firmware (UEFI, Secure Boot), network, release verification, disks, graphics + and memory. An unsigned development ISO says so; **Use this test build** (or + Enter) is the explicit consent. Without a network, **Connect to Wi-Fi** opens + the network sheet, which uses the desktop's own NetworkManager helper. +- **Disk** lists usable whole disks with model, size and contents; the only + usable disk is preselected. **More options** shows serial numbers and the + disks that cannot be used, with the reason. Encryption (LUKS2) is on by + default; the layout (16 GiB ESP, 64 GiB minimum) is the shared module contract. +- **You** asks for an optional name, a username, the password twice, the time + zone and the computer name. Names follow the rules below. +- **Review** names the disk and what is on it, and summarizes every choice with + **Change** or **Customize** (application groups, and the NVIDIA driver when an + NVIDIA GPU is detected). Changes made here are reviewed again automatically. + Only clicking **Erase … & install** authorizes erasure; Enter never does, and + there is no serial or ERASE typing. The worker binds that click to the reviewed + plan with a fresh token and rechecks the disk's identity before writing. +- **Installing** reports the worker's six real stages and the elapsed time, + without an invented percentage or estimate. **Show installation details** + opens the most recent 256 KiB of the private log `/run/nerdos-installer.log`, + refreshed every two seconds; it can be saved to another USB stick (the medium + itself is read-only). Arrow keys, Page Up/Down, Home and End scroll the log. + Refresh keeps your reading position; End resumes following new output. The + log belongs to this boot only. +- **Done** offers restart or power off; installation never reboots by itself. + +A stop before any disk change explains the cause and the next steps: Secure Boot +(with **Restart into firmware settings**; the installer never turns Secure Boot +off), a legacy BIOS boot, an unauthenticated medium or a missing network. A +failure after the disk was changed says so and opens the log. The menu (⋯) has +the installation log, a recovery shell on tty2 (Alt+F1 returns), the text +installer, restart into firmware settings, restart and power off. + +### Names and passwords + +The username field accepts only what the worker accepts, `[a-z][a-z0-9-]{0,30}`. +Uppercase becomes lowercase as you type and accents fold to their base letter; +any other character, and a leading digit or hyphen, is refused with a one-line +reason. A name typed under **Your name** suggests the username until you edit +it. Reserved names are refused before review: `root`, `nixos`, and every user and +group name of the installed product, recorded on the medium in +`/etc/nerdos/installer-reserved-names.json`. The worker validates again. The +optional name becomes the account's description; it may not contain colons, +commas, backslashes, double quotes, dollar signs, backticks or control +characters. The computer name follows `USERNAME-nerdos` until edited and has the +same character rules. + +One password of at least eight characters, without character-class rules, sets +both the login and the LUKS2 key, so it is typed twice. It is held only in the +screen and service memory until the final click, then passed to the worker over +its private stdin; it never appears in a snapshot, the draft, a log or an +argument. First desktop login requires the password; automatic login is a +later, explicit machine choice and requires verified encryption. + +Changing the keyboard writes `/run/nerdos-installer/keyboard.env` and loads the +matching console keymap; the screen restarts with the new layout, because +Slint's KMS backend reads the XKB layout when it starts, and clears any typed +password so it is retyped with the layout that will unlock the disk. + +### Time zones + +The zone list comes from the medium's own `zone.tab`: one familiar entry per +country, so Amsterdam stays Amsterdam although tzdata merges its data with +Brussels. The medium and the installed system use the same tzdata, from the one +tested Nixpkgs revision. Search matches city, country, tzdata's notes ("most of +Ukraine"), retired names through backward links (Kiev → Kyiv, Calcutta → +Kolkata) and offsets (`utc+9`). Fixed offsets are opt-in and labelled with the +true sign: `Etc/GMT+5` is UTC−05:00. Each row shows the live local time, computed +from the medium's TZif files; the selected zone also shows its next clock change. +The world map shades day and night; clicking it selects the nearest zone. The +selection is always a zone ID, never a position in a filtered list. + +The service suggests a zone from the public IP address through ipapi.co, with a +bounded timeout, once a network is available. A suggestion never replaces a zone +chosen by hand; **Detect again** asks once more. + +### Network + +The medium and fresh installed systems start with Google DNS (`8.8.8.8`, +`8.8.4.4` and their IPv6 equivalents); the initial wired profile ignores DNS +advertised by DHCP, and Wi-Fi joined in the installer inherits it. The worker +checks verified HTTPS access to the download service before setup and again +before erasing the disk. A failure stops before any disk change and offers a +retry; the private log records the resolver stub, upstream servers, routing +domains and the result. These checks never rewrite DNS or disconnect a PXE +interface. NetworkManager and Tailscale share systemd-resolved on installed +systems; signing in to Tailscale can add MagicDNS. See Tailscale's +[Linux DNS guidance](https://tailscale.com/kb/1188/linux-dns). + +### Recovery and other interfaces + +The serial console and tty2 are root shells on the physical medium; there is no +SSH, live account or sudo. `nmtui` configures the network. `nerdos-install +--terminal` is the same installer as text; the text-mode boot entry runs it on +tty1, and the screen's menu switches tty1 to it after cancelling the idle +graphical worker. `nerd install`, `./install` and `./bootstrap` enter the same +fresh installer and refuse outside the installation medium. Only one installer +can run at a time. + +### Offline payload + +The medium carries the free part of the default desktop as a store payload: +the system profile, `/etc`, kernel modules, firmware, systemd and the boot loader +installer of a representative installation. `nixos-install` copies those paths +from the medium instead of downloading them. The machine-specific initrd and +configuration are built during installation; the unfree application groups +(proprietary applications, Steam) and NVIDIA drivers still download. Release +authentication always needs the internet. + +### Design reference and previews + +`installer/ui-reference/index.html` is the design reference for the screen. +`installer-preview` renders every screen with the real UI and software renderer: + +```sh +installer/nerdos-installerd --dump-static > static.json # with lifecycle/ on PYTHONPATH +cd installer/kiosk && cargo run --features preview --bin installer-preview -- ../../static.json /tmp/renders +``` + +New menubars enable Model Usage and leave Hermes Agent, Remote Server, T3 Code, +GitHub and Fusebox available but hidden. Installing connected-service support +does not enable their widgets. Saved widget choices remain personal. LAN +discovery is enabled by default: Avahi browses and publishes local addresses, +`.local` resolution is available, and the firewall admits the discovery and +selected application ports. Bibata Modern Classic is the default pointer across +the installed desktop, GTK/Qt/XWayland and SDDM; user cursor preferences remain +overrides. + +The ISO retains its tested flake input sources. A fresh machine's first lock +starts with that exact Nixpkgs revision and content hash, retaining the selected +stable branch as its upstream update source. Installation never resolves a newer +branch head implicitly. Later upstream-only updates advance this machine-owned +input independently of NerdOS. This does not make installation fully offline: +release authentication and packages not bundled on the medium require internet. + +Use a backed-up target disk of at least 64 GiB. Installation erases only the +explicitly selected whole disk after its graphical confirmation. Its serial is +checked automatically; it is never a typing exercise. Mounted disks, +active swap and changed identities are rejected. Passwords use private input, +not arguments or configuration source. The installer creates a 16 GiB ESP and +an ext4 root, optionally under LUKS2, with zram rather than persistent plaintext +swap. Root stays locked; the chosen wheel user authorizes administration. +The EFI partition accommodates the full early graphics firmware and ten retained +boot generations, including room to copy the next generation before cleanup. +Its layout and minimum disk size come from the shared Nix module contract. + +`nixos-generate-config` records actual hardware and filesystems. DMI/PCI/ACPI/ +EDID detection selects narrowly scoped XPS/NVIDIA declarations. The machine +flake follows the stable Nixpkgs input and pins the authenticated NerdOS exact +revision/NAR identity. The complete target is built and installed through the +shared modules. First login installs missing vendor-skill discovery symlinks +and never overwrites a pre-existing personal skill. + +Production media install only their own exact tested source. Before asking for +choices, the installer fetches the immutable signed manifest for the medium's +`/etc/nerdos/release.json` version (`releases/download/vVERSION/manifest.json`) +over public HTTPS, verifies its Ed25519 signature and supported metadata, and +requires its version, exact revision and NAR to equal the medium's. A channel is +never consulted to select a newer release; the medium's channel policy still +applies (stable rejects a prerelease medium). Any mismatch stops before disk +erasure and vendor evaluation. The medium's channel is written to `machine.nix` +as an explicit `nerdos.updates.channel`, so a later stable vendor release does +not move a beta machine; the vendor default is `beta` for prerelease versions and +`stable` otherwise, and the administrator may change it. + +Production installation is blocked until owner-controlled trust keys and an +authenticated published release exist. For isolated development qualification, +`nerdos-install --development-source /path/to/source` explicitly selects an +unreleased content-addressed test source. The graphical welcome on an unsigned +development ISO explains this limitation and offers the bundled test build, +requiring explicit consent (default: no) before disk selection. Automated use +still requires `--development-source`. Such a result is not an authenticated +release qualification. + +Secure Boot is not disabled. Native Lanzaboote configuration is provided, but +fresh enrollment/signed installation media and out-of-tree-module signing have +not yet been qualified; the installer currently refuses Secure Boot installation +before erasure. See [hardware blockers](hardware-security.md). + +Automated isolated installation accepts a non-secret JSON plan with `disk`, +`serial`, `hostname`, `username`, `timezone`, `locale`, `keyboard`, `encrypted`, +`autoLogin`, `secureBoot`, `nvidiaOpen`, optionally `fullName`, `features` and +`desktopKeyboard`, plus `{"password":"…"}` JSON through `--secrets-fd`. Stop +`nerdos-installer-ui.service` and `nerdos-installerd.service` first; stopping the +service cancels its idle worker and releases the installer lock. The same +validation and disk-erasure guard run in every mode. No installer reboots +automatically. + +For direct ISO testing without network boot, run the qualification tool in an +isolated KVM environment with QEMU, OVMF, Python pexpect and Pillow available: + +```sh +scripts/qualify-installations /path/to/completed.iso --interactive \ + --applications standard --case both --scenario lifecycle \ + --work-dir /path/to/task/vms --screenshots /path/to/task/screenshots \ + --output /path/to/task/installations.json +``` + +This boots the ISO as a virtual optical disk and installs into disposable disks. +`--interactive` installs through the real tty1 screen with QMP keyboard and +pointer input: Enter for development consent and navigation, a pointer click on +the encryption switch (plain case), typing a name and `NerdQA` (the service must +receive `nerdqa`), both passwords, and a pointer click on **Erase … & install**. +The serial console only observes the service's snapshots; QA-only choices (the +installed serial console, the smaller application set) go through the same +service. It checks plain and encrypted installed password login, then exercises +the staged updater and rollback. `--applications minimal` explicitly selects fewer optional +groups; it does not qualify the standard application selection. VM files are +removed when the tool exits. Screenshots and JSON evidence are retained only +when requested. `--serial-console` is an installer option for this isolated test; +normal interactive installations do not enable a serial console. diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md index 0f92a67b..f0145492 100644 --- a/docs/integration-boundaries.md +++ b/docs/integration-boundaries.md @@ -3,13 +3,13 @@ The public QML singletons and bridge protocol remain compatible. Internally, transport lifetime, domain state and view code have separate owners: -- **Hermes:** `cybex_hermes/protocol.py` owns errors, wire frames and limits; +- **Hermes:** `nerdos_hermes/protocol.py` owns errors, wire frames and limits; `auth.py` owns origin-scoped HTTP credentials and authenticated transport; `registry.py` owns atomic conversation metadata; `gateway.py` owns bounded local-client delivery and the reconnecting local upstream. `hermes_bridge.py` coordinates the conversation domain and keeps its public imports for tools - and existing tests. Modules never import the bridge entrypoint. Both install - paths must ship the package beside the executable. + and existing tests. Modules never import the bridge entrypoint. The native Nix package + ships the Python package beside the executable. Authentication/status replies do not wait for optional discovery or the conversation list. Discovery shares in-flight work and caches results for the current origin and sign-in session; replacing that session cancels stale @@ -26,17 +26,18 @@ transport lifetime, domain state and view code have separate owners: feedback. The RPC facade forwards its existing command methods and properties, preserving callers. Request acceptance still does not resolve provider approvals; a disconnected or partially accepted batch never replays. -- **Fusebox:** `scripts/fusebox.py` owns the management key file, the +- **Fusebox:** `desktop/quickshell/scripts/fusebox.py` owns the management key file, the authenticated transport, reconnects and the allowlist of what reaches QML. `Fusebox.qml` owns connection state, the request history, fault notifications and one-shot breaker requests through `CommandRequest`; `FuseboxHelpers.js` owns the dashboard-equivalent rules. Popovers and Settings retain presentation only. -- **Updates:** `UpdateLogReader.qml` owns bounded byte-range transport and - run/offset validation. `Updates.qml` owns transaction state and parses only - accepted log data. The privileged updater still owns the transaction; a shell - reload does not stop it. Log responses for another run or an earlier offset - trigger a fresh read instead of modifying the current transaction. +- **Updates:** `desktop/quickshell/Common/Updates.qml` invokes the supported + `nerd update` CLI and polls durable transaction status. The privileged updater + owns staging and activation; reloading the shell does not interrupt its + systemd worker. Apply requests name the reviewed transaction and token. + Independent source errors remain visible instead of implying that an + unreachable channel is current. `CommandRequest` has `command`, `running`, `stdinEnabled`, `inputText`, `timeoutMs`, `killGraceMs` and `timeoutMessage` inputs. Set the command and then @@ -46,8 +47,8 @@ transport lifetime, domain state and view code have separate owners: partial output before hanging. A second expiry sends SIGKILL. Do not launch the next command while `running` is true. No command is retried by the transport. -The real-engine lifecycle fixture covers output, missing executables, -SIGTERM-resistant timeouts, reuse after timeout and stdin. It runs in CI with an -isolated HOME/session when no other Quickshell is active. Node behavior tests -exercise GitHub queue priority and T3 partial-batch, approval and expiry rules; -the existing Hermes HTTP/WebSocket fixtures exercise the split Python modules. +Node behavior tests exercise GitHub queue priority, T3 partial-batch and +approval rules, and the Hermes/Fusebox presentation boundaries. They do not +establish real subprocess interruption, remote HTTP/WebSocket behavior or +installed-service qualification. Exercise those paths in isolated runtime tests +before release. diff --git a/docs/iso-releases.md b/docs/iso-releases.md deleted file mode 100644 index 035f3d5b..00000000 --- a/docs/iso-releases.md +++ /dev/null @@ -1,282 +0,0 @@ -# ISO and desktop RPM releases - -CybexOS ISO releases and source archive releases share a version tag but have -separate build gates and artifacts. A trusted Debian 13 PXE runner builds the -ISO and desktop RPM and exercises the installer in disposable guests. It -transfers only the build artifacts and qualification reports. A separate -GitHub-hosted signing job prepares the signed desktop assets; private signing -material never goes to the PXE host. Signed RPM repository metadata is deployed -to GitHub Pages after the GitHub Release has been published and confirmed -immutable. - -The desktop update endpoint is -[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). -Pages serves `update-channel.json`, the public key, signed release and -`repodata`; the RPMs themselves are assets of the matching immutable GitHub -Release. The channel configuration is [stable.json](../image/channels/stable.json), -which pins signing fingerprint -`16C60642B7278AECE3A933C354220839FDF7099E`. - -## Repository and runner setup - -The GitHub repository already has immutable releases enabled and GitHub Pages -configured. The Pages site is empty until a release is published. The -`desktop-release` Actions environment and its `CYBEXOS_RPM_SIGNING_KEY` secret -are configured. The secret contains the RPM signing subkey and is used only by -the GitHub-hosted Fedora 44 signing job. The PXE runner receives no private -signing material. The primary private key remains in a protected local -keyring; move it to offline custody before public release. Never print or copy -signing material into the checkout or expose it in workflow arguments or -logs. - -Before the first public release: - -- The repository includes its MIT license. This covers repository code; it - does not settle redistribution rights for third-party software and bundled - assets. Complete that audit before public distribution; see - [licensing and asset provenance](licensing.md). -- Use the trusted Debian 13 x86_64 PXE operator account with `/dev/kvm`, - at least 180 GiB staging space and 24 GiB available RAM, the - `image/build --preflight` dependencies, a Chromium browser, write access to - `/data/pxe/iso`, and an active `iventoy.service`. The workflow provides Node - 24 and isolated `playwright-core`. The operator also needs authenticated - `gh` access with permission to manage this repository's runners, Python - 3.12 or newer, the GitHub runner's native .NET dependencies, and a reachable - systemd user manager. Keep signing material on GitHub's hosted signing job. -- Repository variable `CYBEXOS_BASELINE_ISO` is configured as - `/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. Keep this older - supported ISO and its matching `.sha256` sidecar in place. The qualification - job requires a regular, checksum-verified ISO under `/data/pxe/iso`. -- Start an ephemeral runner only for the reviewed queued run below, and pass all release gates. The qualification - has to pass on the exact release build; configuring Pages, the baseline - variable, and signing environment alone does not make a public desktop - channel available. - -The PXE machine does not keep a public-repository runner listening. Once a -reviewed `release.yml` or `desktop-release.yml` run has its qualification job -queued, use a clean checkout whose `HEAD` exactly matches that run. From the -unprivileged PXE operator's authenticated session: - -```bash -gh auth status --hostname github.com -systemctl --user show --property=Version --value -run_id=REVIEWED_RUN_ID -./image/release-runner --run-id "$run_id" -./image/release-runner --run-id "$run_id" --execute \ - --work-root /data/cybexos-runners -``` - -The first helper command only validates the API run, job, source commit and -checkout. `--execute` downloads the current Linux x64 runner with the SHA-256 -pin supplied by GitHub's API and registers one ephemeral runner. Its only -label is `cybexos-iso-RUN_ID`; it has no generic `self-hosted`, `linux`, `x64`, -or `cybexos-iso` labels. The workflow requests that exact run-specific label. -The helper refuses pull requests, other source repositories, non-release -workflows, refs outside `main` or version tags, dirty or mismatched checkouts, -an existing runner for the run, or another queued job targeting its label. -No runner has been registered by this setup; registration is an explicit -operator action when a reviewed job is queued. - -The listener runs in a transient user systemd service with a seven-hour limit -(`--timeout` accepts 60 seconds through eight hours). Cancellation allows -120 seconds for job cleanup, then systemd terminates the complete service -cgroup, including build and VM processes that created separate sessions. -The helper stops and verifies this exact unit before unregistering the runner -and removing its unique task directory. If it cannot confirm the unit stopped, -it reports and retains that directory for diagnosis instead of deleting live -VM files. A failed API cleanup reports the exact registration to remove. -The registration token stays out of command arguments and logs, and the runner -gets an isolated home and environment without the operator's GitHub tokens. - -This is a trusted-code runner, not a sandbox. Do not approve or queue untrusted -workflows while it is active: someone allowed to execute a workflow could -intentionally request the known run-specific label. Signing keys are available -only to the separate GitHub-hosted `sign` job and never to this PXE listener. - -The release checks the source contract, image-source contract, and a -twice-converged generic Fedora VM before calling -[desktop-release.yml](../.github/workflows/desktop-release.yml) on the trusted -PXE runner. Its `qualify` job builds the ISO with the public stable channel, -publishes the completed testing ISO and checksum to iVentoy, and runs five -QEMU guests with isolated disposable disks. It uploads only the raw build -artifacts and bounded qualification reports as `cybexos-qualified-desktop` for -two days. The `qualify` job also retains compact qualification evidence for -fourteen days. The separate GitHub-hosted `sign` job downloads that exact -artifact and produces `cybexos-desktop-release`, retaining the prepared assets -for two days. The signing job needs a -Docker-capable GitHub-hosted runner with at least 24 GiB free staging space. - -| Scenario | Encryption | Keyboard and locale | Timezone | -| --- | --- | --- | --- | -| `encrypted-us` | LUKS | US / `en_US.UTF-8` | UTC | -| `plain-us` | Off | US / `en_US.UTF-8` | UTC | -| `encrypted-nl` | LUKS | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | -| `plain-nl` | Off | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | - -Each of the four fresh scenarios boots the exact candidate ISO and completes -the graphical installer. A fifth guest uses the older baseline ISO, creates a -recovery point, applies the exact candidate desktop RPM, then boots and restores -that pre-upgrade point and verifies that the baseline RPM is active again. This -older-image upgrade and recovery qualification is separate from the four -fresh-install scenarios. - -Release admission uses the versioned scenario-specific evidence contract in -`image/qualification_contract.py`. Every encrypted fresh scenario must include -its executed keyring, cold-boot, logout/crash/restart and authentication recovery -checks. The existing encrypted-US upgrade guest must additionally qualify public -updater commit, interruption after applying, automatic pre-login recovery and a -further settled boot, including root/package/vendor identity, personal-file -preservation and empty worker cgroups. The producer and verifier reject missing -checks or incompatible execution modes. Old compact reports cannot validate a -new release by copying only their `passed` status. - -A release is rejected unless qualification reports identify the SHA-256 of the -exact ISO and candidate RPM and all five scenarios pass. Reports are retained -as bounded workflow artifacts; the desktop assets are retained briefly for the -publishing job. Do not treat source fixtures, a local RPM build, or an earlier -ISO boot as a substitute for these release gates. - -## Published assets and ISO reconstruction - -The publishing job creates the tagged source archive and its offline-verifiable -provenance bundle, then uploads those with the desktop release assets and -`SHA256SUMS` to a draft GitHub Release. It publishes the draft after uploading -all assets, then polls until GitHub marks the release immutable; failure to -confirm immutability fails the workflow after publication. Only after this job -passes does a stable tag deploy signed metadata to Pages. A prerelease tag -publishes downloadable assets but does not update the stable Pages repository. -The ISO is split into files below GitHub's per-asset size limit. `assets/` also -contains the part manifest, original ISO SHA-256 file, reconstruction script, -qualification reports, signed RPMs, the ISO's SPDX SBOM, and -`desktop-SHA256SUMS`. - -Download `desktop-SHA256SUMS` and every file it names into one directory, then -verify and reconstruct without replacing an existing output: - -```bash -sha256sum -c desktop-SHA256SUMS -python3 reconstruct-iso.py CybexOS-Live-44-BUILD-ID.iso.parts.json -sha256sum -c CybexOS-Live-44-BUILD-ID.iso.sha256 -``` - -The reconstruction tool checks each ordered part against the JSON manifest, -then checks the complete ISO size and SHA-256 before publishing the output. -It refuses to overwrite an existing ISO. Keep the manifest, parts, and helper -in the same directory. Check the signing fingerprint independently before -trusting the RPM or channel metadata. - -After the immutable GitHub Release is available, the Pages job deploys the -signed metadata snapshot. Metadata points each package URL at that release's -RPM asset, so Pages never needs to host the large RPM. Publication is ordered: -the Pages deployment waits until the immutable Release and RPM assets exist. -Until both jobs complete successfully, the channel is not ready for enrollment. - -## Software bill of materials - -Each image build writes an SPDX 2.3 JSON SBOM beside the ISO and names it after -the ISO: `CybexOS-Live-44-BUILD-ID.spdx.json`. `image/compose` records the -finished image root in `rpm-inventory.tsv` (the `RPM_QUERYFORMAT` in -[`image/sbom.py`](../image/sbom.py)) and `flatpak-inventory.tsv`. When the -artifacts reach the host, `image/build` generates the SBOM from those files, -`build-provenance.json` and the inventory, and lists it in `SHA256SUMS`. The -SBOM contains: - -- a root `CybexOS` package with the version, Git revision, source content - digest, build ID, and the ISO filename and SHA-256; -- every installed RPM, with epoch, version, release, architecture, declared - license, source RPM, vendor, and a `pkg:rpm` package URL. A License tag that - is not a valid SPDX expression becomes a `LicenseRef-` entry holding the - verbatim text; -- every installed Flatpak application and runtime, with its ref, origin, and - full OSTree commit; -- the downloads from `inventory/group_vars/all.yml` and the apps role defaults - that the image bundles: GitHub release applications, Nerd Fonts, font files - and archives, Omawrite, source builds, the LazyVim starter, and vendor - repository keys. Each has its URL and its SHA-256 or Git commit. - -The `cybexos-desktop` and `cybexos-offline-apps` entries each have the SHA-256 -of their unsigned RPM that `release.json` records as `unsigned_input_sha256`. -Bundled applications, toolchains and fonts belong to the offline package in -the SPDX containment relationships. Generation is deterministic: -the timestamp and namespace come from build provenance, and packages and -relationships are sorted. The release gate requires one SBOM in the build -artifacts. Before it signs anything, `prepare-github-release` checks that the -SBOM describes the exact qualified ISO and both candidate RPMs. It then copies the -SBOM into `assets/` and `desktop-SHA256SUMS`. From a checkout, check a -downloaded SBOM against the ISO checksum file: - -```bash -python3 image/sbom.py verify CybexOS-Live-44-BUILD-ID.spdx.json \ - --iso-checksum CybexOS-Live-44-BUILD-ID.iso.sha256 -``` - -The publishing job adds the SBOM file as a second subject of the release -provenance attestation, so the source archive's bundle also verifies it. It -also creates an SPDX attestation (predicate type -`https://spdx.dev/Document/v2.3`) whose subject is the ISO digest and whose -predicate is the SBOM. That bundle is published as -`CybexOS-Live-44-BUILD-ID.iso.sbom.sigstore.jsonl`. Verify both offline, the -second against the reconstructed ISO: - -```bash -gh attestation verify CybexOS-Live-44-BUILD-ID.spdx.json \ - --bundle cybexos-VERSION.tar.zst.sigstore.jsonl \ - --repo DigitalPals/CybexOS \ - --signer-workflow DigitalPals/CybexOS/.github/workflows/release.yml \ - --source-ref refs/tags/vVERSION --deny-self-hosted-runners -gh attestation verify CybexOS-Live-44-BUILD-ID.iso \ - --bundle CybexOS-Live-44-BUILD-ID.iso.sbom.sigstore.jsonl \ - --predicate-type https://spdx.dev/Document/v2.3 \ - --repo DigitalPals/CybexOS \ - --signer-workflow DigitalPals/CybexOS/.github/workflows/release.yml \ - --source-ref refs/tags/vVERSION --deny-self-hosted-runners -``` - -The workflow verifies the archive and SBOM provenance offline before it -publishes. It checks the SBOM attestation's subject digest and predicate type -from the bundle, because the publishing job has only the ISO parts. - -## Enroll the installed RPM channel - -An ISO built without `--update-channel` bundles a disabled channel. The release -gate explicitly enables the pinned stable channel. Check the installed system -with read-only diagnostics; channel status describes local configuration and -does not test remote availability: - -```bash -cybex doctor --json -cybex update-channel status --json -``` - -For enrollment, obtain `update-channel.json` and `CYBEXOS-desktop.asc` from the -same published release's verified assets, keeping them together in one -folder. Review the URL and compare the configuration's full fingerprint with -the independently trusted value above. Validate connectivity and signatures -without changing the system: - -```bash -cybex update-channel enroll /path/to/release/update-channel.json \ - --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E --check -``` - -When the check succeeds, explicitly enable the repository: - -```bash -sudo cybex update-channel enroll /path/to/release/update-channel.json \ - --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E -cybex update-channel status --json -``` - -Enrollment verifies the public key fingerprint and signed repository metadata, -then pins the public key and repository configuration locally. It does not -install an update. The ordinary system package update path can install the -signed desktop RPM once the channel is ready. `cybex update --check` is a -source-checkout command and is not supported for ISO installations. Key -rotation requires a separately reviewed migration; do not enroll a different -fingerprint as a routine update. - -The current GitHub repository has not published its first public release and -the Pages endpoint is still empty. The repository has selected the MIT -License for its code, but the bundled software and asset redistribution audit -still applies. The explicitly started ephemeral runner and complete release workflow must -pass before signed desktop metadata is available to users. diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index b3488cf6..b7b15b10 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -6,13 +6,17 @@ the shortcut sheet. It lists the keybindings Hyprland actually has, read with fall out of date, and a failed read is shown as an error. A binding appears in the sheet when it has a description. Descriptions use -the form `Group: Label`, for example `Apps: Terminal`. CybexOS's own groups +the form `Group: Label`, for example `Apps: Terminal`. NerdOS's own groups are Shell, Apps, Web apps, Development, Windows, Workspaces, Clipboard, Capture and Dictation, in that order. New groups follow them, and a description without a group is listed under Other. Several bindings with the same description show as alternatives, and numbered bindings such as `Switch to workspace 1` to `10` are shown as one row. +`Super+I` (**Shell: Assistant**) talks to the NerdOS Assistant: tap it to +type a question, or hold it, speak, and release to send. See +[NerdOS Assistant](assistant.md). + `Super+Tab` (**Windows: Overview**) opens the window overview: every workspace's windows as live thumbnails, with arrow-key, filter and drag-to-move controls. See [Window overview, dock and top processes](overview-and-dock.md). @@ -28,14 +32,14 @@ whose keys do not fit beside its label puts them on the lines below it. ## Web apps -The Web apps group opens the web apps CybexOS installs for every account: +The Web apps group opens the web apps NerdOS installs for every account: `Super+M` Slack, `Super+W` WhatsApp, `Super+Y` YouTube, `Super+Shift+P` Google -Photos and `Super+Shift+X` X. Each binding runs `cybexos-webapp launch ID`, +Photos and `Super+Shift+X` X. Each binding runs `nerdos-webapp launch ID`, which opens the site in Brave Origin's app mode when Brave Origin is installed and in Firefox otherwise. Remove one with **Remove web app…** in the launcher -or `cybex webapp remove ID`; it is not added back by later updates, and its +or `nerdos-webapp remove ID`; it is not added back by later updates, and its shortcut then shows how to reinstall it. Add other sites with **Install web -app…** in the launcher or `cybex webapp install --url ADDRESS`. Installed web +app…** in the launcher or `nerdos-webapp install --url ADDRESS`. Installed web apps appear in the launcher's Apps tab. ## Clipboard history @@ -89,13 +93,13 @@ it in a notification. `Escape` cancels. ## Your own shortcuts -Add personal bindings to `~/.config/cybexos/hypr/user.lua`. CybexOS loads it +Add personal bindings to `~/.config/nerdos/hypr/user.lua`. NerdOS loads it after its own configuration and never changes it. Give each binding a `description` so it appears in the sheet: ```lua local function bind(keys, dispatcher, description) - -- Unbinding first replaces a CybexOS binding on the same keys and keeps + -- Unbinding first replaces a NerdOS binding on the same keys and keeps -- configuration reloads from stacking duplicates. hl.unbind(keys) return hl.bind(keys, dispatcher, { description = description }) @@ -135,7 +139,7 @@ These are in the sheet too; this is what they do. - **Touchpad gestures.** Swipe three fingers left or right to switch workspace, or up to open the overview. Turn the workspace swipe off in **Settings → Touchpad → Swipe between workspaces**. A gesture of your own in - `user.lua` on the same fingers and direction replaces the CybexOS one: + `user.lua` on the same fingers and direction replaces the NerdOS one: ```lua hl.gesture({ fingers = 3, direction = "horizontal", action = "move" }) diff --git a/docs/launcher-ibus-focus-investigation.md b/docs/launcher-ibus-focus-investigation.md deleted file mode 100644 index 9ee610b7..00000000 --- a/docs/launcher-ibus-focus-investigation.md +++ /dev/null @@ -1,160 +0,0 @@ -# Launcher typing and IBus focus handoff - -Investigated on 2026-10-17 at CybexOS source revision `6f4bdca`, using -Hyprland 0.56.2 (`efb50993780079460b0cbed1363e2166a2de1d9f`), -Quickshell 0.2.1 (`dacfa9d`), Qt 6.11.2 and IBus 1.5.34 on Fedora 44. - -The fault is in Hyprland's focus-grab path. Activating the launcher's -`HyprlandFocusGrab` before the overlay maps can change seat keyboard focus -without updating the input-method relay. IBus consequently retains the -previous application's context and keyboard grab. Its printable-key commits -are discarded, while its virtual keyboard can still forward navigation keys. - -The local fix lets the exclusive layershell surface obtain keyboard focus -first. `LauncherWindow.qml` observes the backing window's `Window.active` -notification and then enables the dismissal grab. Readiness is latched until -close so a focus loss cannot delete the grab before its `cleared` event. -Reopening during the exit animation also handles an already-active window. -After the grab reports active, layershell keyboard mode becomes OnDemand. -Hyprland 0.56 forces pointer hit tests onto exclusive layers even outside their -geometry (`InputManager.cpp`, the `m_exclusiveLSes` branch). Switching only -after the grab is established keeps keyboard ownership and permits outside -clicks to clear the grab. Keeping Exclusive throughout prevented dismissal in -the live check. -The search remains an editable native Qt `TextInput`; applications and the -launcher retain IBus, Compose, preedit, and dead-key support. - -Protocol evidence was captured with a temporary runtime drop-in for -`cybexos-input-method.service`, setting `WAYLAND_DEBUG=client` and redirecting -stderr to a task-owned file. The service was restarted before capture and -again after removing the drop-in. Hyprland logging settings were untouched. -The excerpts below contain only the test's synthetic input. - -Before the fix, a native Qt fixture accepted exactly `hello` through IBus. -After clearing it, opening the launcher left `CurrentInputContext` at -`/org/freedesktop/IBus/InputContext_3`. The fixture received -`wl_keyboard.leave` at 07:13:14.364, but no `zwp_text_input_v3.leave` until -the launcher closed. There was no IME `deactivate`, `activate`, or grab release -between opening and typing. All five characters disappeared in this captured -run, and the fixture remained empty. The relevant IBus trace was: - -```text -07:13:14.364957 zwp_input_method_v2#34.done() -07:13:14.364967 zwp_input_method_v2#34.done() -07:13:14.781402 launcher open; CurrentInputContext = InputContext_3 -07:13:14.834857 zwp_input_method_keyboard_grab_v2#39.key(..., 35, 1) -07:13:14.837043 -> zwp_input_method_v2#34.commit_string("h") -07:13:14.837058 -> zwp_input_method_v2#34.commit(13) -07:13:14.996168 -> zwp_input_method_v2#34.commit_string("e") -07:13:15.153150 -> zwp_input_method_v2#34.commit_string("l") -07:13:15.298776 -> zwp_input_method_v2#34.commit_string("l") -07:13:15.455469 -> zwp_input_method_v2#34.commit_string("o") -07:13:16.263956 launcher OCR: Search applications; previous app: empty -07:13:16.342171 zwp_input_method_v2#34.deactivate() [launcher closing] -07:13:16.342217 zwp_input_method_v2#34.done() -07:13:16.342309 -> zwp_input_method_keyboard_grab_v2#39.release() -``` - -With the focus gate, the same transition released the old grab and created a -new launcher context before typing. The old fixture stayed empty and OCR -read `hello` in the launcher: - -```text -07:13:49.783058 before open; CurrentInputContext = InputContext_3 -07:13:49.929896 zwp_input_method_v2#34.deactivate() -07:13:49.929938 zwp_input_method_v2#34.done() -07:13:49.930028 -> zwp_input_method_keyboard_grab_v2#39.release() -07:13:49.930641 zwp_input_method_v2#34.activate() -07:13:49.930646 zwp_input_method_v2#34.done() -07:13:50.300399 launcher open; CurrentInputContext = InputContext_4 -``` - -The source explains the missing notification: - -- [`CSeatManager::refocusGrab()` in 0.56.2](https://github.com/hyprwm/Hyprland/blob/efb50993780079460b0cbed1363e2166a2de1d9f/src/managers/SeatManager.cpp#L582) - calls `setKeyboardFocus()` directly. That updates seat focus and emits - `m_events.keyboardFocusChange`, but not the global keyboard-focus event. - `CFocusGrab::start()` installs the seat grab first; its later - `refocusKeyboard()` returns when that surface already has seat focus. -- [`CFocusState::rawSurfaceFocus()`](https://github.com/hyprwm/Hyprland/blob/efb50993780079460b0cbed1363e2166a2de1d9f/src/desktop/state/FocusState.cpp#L223) - returns immediately when the seat already focuses the requested surface. - When the layer subsequently maps, this skips its focus-state update and - global keyboard-focus notification. -- [`CInputMethodRelay`](https://github.com/hyprwm/Hyprland/blob/efb50993780079460b0cbed1363e2166a2de1d9f/src/managers/input/InputMethodRelay.cpp#L9) - listens to that global event. Its normal `onKeyboardFocus()` and - `CTextInput::leave()` deactivation path is therefore bypassed. -- [`IBus 1.5.34 input_method_done_v2()`](https://github.com/ibus/ibus/blob/1.5.34/client/wayland/ibuswaylandim.c#L2327) - acts on pending activate/deactivate flags. The missing compositor - `deactivate` never sets the flag. The captured close and fixed-open - sequences show that IBus releases its grab when deactivation is delivered. - -Upstream searches covered issues and commits in both projects. Hyprland -[#16359](https://github.com/hyprwm/Hyprland/issues/16359) reports a related -0.56.2 focus-grab/text-input mismatch for an unmapped popup. It was closed by -the issue-policy bot without a technical fix; its popup-selection problem is -related evidence, not an exact match for this layer-surface reproduction. -Hyprland main at -[`25f09b26`](https://github.com/hyprwm/Hyprland/blob/25f09b26f9db7dbca4a4b3c265fdb96802db122c/src/managers/SeatManager.cpp) -still uses direct seat focus in `refocusGrab()`. - -IBus [#2742](https://github.com/ibus/ibus/issues/2742) was fixed by -[`5f2c6c4`](https://github.com/ibus/ibus/commit/5f2c6c444d5d01d147829647ca423bf2056a7752), -destroying the virtual keyboard on deactivation. That behavior is already -present in 1.5.34. Its subsequent Sway zero-size-keymap fix, -[`8757b87`](https://github.com/ibus/ibus/commit/8757b8741ce942a0a9ddfc48eaec4a2668d14861), -is present too. IBus [#2870](https://github.com/ibus/ibus/issues/2870) and -[#2872](https://github.com/ibus/ibus/issues/2872) concern monitor-hotplug serial -and modifier state; the captured launcher failure has no such hotplug or -keymap transition. No upstream fix for this exact missing handoff was found. -A compositor fix should synchronize focus state and the input-method relay -when a seat grab redirects keyboard focus. - -The opt-in live regression is: - -```bash -python3 -B tests/launcher-input-live.py -``` - -It requires the managed shell and IBus services, `python3-evdev`, user access -to `/dev/uinput`, Qt's `qml` runner, `grim`, and `tesseract`. It verifies a -native Qt control accepts exactly `hello` and enables text-input-v3, runs at -least ten launcher trials, and repeats the control and launcher check with -`QT_IM_MODULE=compose` on the fixture to verify the non-IBus path. The launcher -keeps its normal input module. Each injected key is guarded by service PID, -fixture keyboard-focus events, launcher-layer ownership, and current window -and authentication-surface checks. No Enter is injected. It also checks -outside-click dismissal using a disposable mouse and an exposed area of the -fixture, then restores the pointer and original window focus. All fixture -processes, input devices, screenshots and logs are cleaned on exit. - -Separate workstation checks verified Caps Lock produces `Aa` when toggled -between two `a` presses, and Right Ctrl, apostrophe, `e` produces `é` in both -the IBus-active fixture and launcher. Escape cleared the query and then closed -the launcher. The user's blur preference was preserved in `user.lua`; a -personal `compose:rctrl,lv3:ralt_switch` override supplies the requested Caps -Lock/Compose mapping. Its original 127-byte file is backed up beside it as -`user.lua.before-caps-lock-20261017`. - -Both installation paths already consume the shared launcher source. The -desktop-payload regression packages and compares `LauncherWindow.qml`, -`LauncherView.qml`, and `shell.qml` against the checkout. These are source and -payload fixture checks; no new RPM/ISO was built and no end-to-end installation -qualification is claimed. A release containing this change requires rebuilding -and qualifying its installed payload from the release's source revision. - -Final validation on the completed workaround: - -- The retained live regression passed 10/10 IBus-active opens, the 1/1 - non-IBus control, and outside-click dismissal from both fixture modes. -- `./tests/run`: 16/17 stages passed, including 1,373 JavaScript tests and QML - lint. The Python stage reproduced only the two failures disclosed before - this task: report redaction's `xps`/`XPS 14` collision, and the hardware - package parity fixture's root-owned temporary JSON permission error. -- `python3 -B image/check-source`: the new launcher payload comparison - passed. The 519-test image suite reproduced the same hardware-package - permission error; the 14 installer JavaScript tests passed. -- Every live run used `tests/lib/quickshell-live`. The final service was - active with its MainPID as the only `qs` process, and its current invocation - had no QML errors. Temporary input-method drop-ins, fixture windows, - uinput devices, screenshots, source downloads and diagnostic logs were - removed. The small personal configuration backup is intentionally retained. diff --git a/docs/licensing-audit.md b/docs/licensing-audit.md deleted file mode 100644 index 0a94fd36..00000000 --- a/docs/licensing-audit.md +++ /dev/null @@ -1,439 +0,0 @@ -# Third-party redistribution audit - -This is an engineering inventory of the third-party components that CybexOS -installs, and of what a public ISO would redistribute. It records the license -tags, upstream license files, and vendor terms that could be checked, and marks -everything else **Unverified**. It is not a legal opinion and grants no rights. -The owner, with legal review where needed, must decide every item before a -public ISO, desktop RPM, or source archive is published. See -[licensing and asset provenance](licensing.md) for the repository's own license. - -## Audit basis - -- Repository state of 2026-10-13, based on commit `b0a97b7`. -- Package selections were read from `image/applications` (`package_names()` and - `PACKAGE_TASKS`), `image/live.ks`, `roles/base/defaults/main.yml`, - `roles/apps/defaults/main.yml`, `roles/apps/tasks/*.yml`, and - `roles/desktop/tasks/main.yml`. Pinned downloads were read from - `inventory/group_vars/all.yml`. -- License tags and source repositories come from a `dnf repoquery` of Fedora - 44, RPM Fusion free and nonfree, the Brave, 1Password, ChatGPT and Tailscale - repositories, and the `sdegler/hyprland` COPR on 2026-10-13. A tag is the - packager's declaration, not a review of the package contents. -- Upstream licenses were read from the pinned upstream repositories. Vendor - terms were read from the vendors' public pages on 2026-10-13; quotations are - excerpts. -- Evidence of what an image actually contained comes from the retained package - list and payload file manifest of the 2026-09-25 build - `20260925T140839Z-5f33cb32` (revision `a2d31c2c13b5`). That build predates - later selections, including `gstreamer1-plugin-openh264` and the XPS - packages, so it does not describe the current payload. No current image was - built or inspected for this audit. - -"Bundled" means the bytes are inside the ISO: an RPM composed into the image, -the Flatpak installation copied from the builder, or the offline application -seed (`/opt/cybexos-apps`, `/opt/cybexos-builds`, `/usr/local/share/fonts`, -`/usr/share/cybexos/user-seed`). "Downloaded" means the user's machine fetches -the component from its upstream at installation or first use. - -## Distribution channels - -| Channel | Third-party content it redistributes | -| --- | --- | -| Public Git repository and source archive | Wallpapers, brand SVGs, Plymouth artwork, the Tabler icon font subset, vendored MIT code (Omarchy adapter, Model Usage, T3 Code-derived logic), and repository keys for RPM Fusion and ChatGPT | -| `cybexos-desktop` RPM | The repository content above that the desktop needs, including all wallpapers in the user seed and the Plymouth theme | -| ISO | Everything above, every composed RPM and its dependencies, the system Flatpak installation, and the offline application seed | -| Source checkout (`./install`) | Repository content only; every application, font, and toolchain is downloaded by the user's machine from its upstream | - -The checkout path therefore redistributes much less than the ISO. It still -automates license acceptance for the Android SDK (see below) and installs the -same repository assets. - -## RPM packages by repository - -| Repository | Packages CybexOS selects | License tags (repoquery) | ISO | Checkout | -| --- | --- | --- | --- | --- | -| Fedora 44 (`fedora`, `updates`) | About 300 named packages plus dependencies: the shared baseline, desktop, applications, developer tools, fonts, firmware, Docker (`moby-engine`), Podman, Java 25 | Fedora-approved licenses; firmware uses `LicenseRef-Fedora-Firmware` | Bundled | Downloaded | -| Fedora Cisco OpenH264 (`fedora-cisco-openh264`) | `openh264`, a possible provider of the library that `gstreamer1-plugin-openh264` needs; Fedora's `noopenh264` stub is the other | `BSD-2-Clause`; Cisco binary, see codecs below | Repository enabled in `image/build.repo`; selected provider **Unverified** | Downloaded from Cisco | -| RPM Fusion free | `rpmfusion-free-release`, `ffmpeg`, `ffmpeg-libs`, `x264-libs`, `x265-libs` | `GPLv2+`, `GPLv3+`, `GPLv2+ and BSD` | Bundled | Downloaded | -| RPM Fusion nonfree | `rpmfusion-nonfree-release`, `steam`, `intel-media-driver` | `Steam License Agreement`; `MIT and BSD` | Bundled (`intel-media-driver` on every image, not only XPS hardware) | Downloaded (`intel-media-driver` only on detected XPS hardware) | -| Brave | `brave-origin`, `brave-keyring` | `Multiple, see https://brave.com/` | Bundled | Downloaded | -| 1Password | `1password`, `1password-cli` | `LicenseRef-1Password-Proprietary` | Bundled | Downloaded | -| OpenAI ChatGPT | `chatgpt` | `Proprietary` | Bundled | Downloaded | -| Tailscale | `tailscale` | `MIT`; upstream source is BSD-3-Clause | Bundled | Downloaded | -| COPR `sdegler/hyprland` | `hyprland`, `hypridle`, `hyprlock`, `hyprpicker`, `hyprsunset`, `xdg-desktop-portal-hyprland`; it also offers `cliphist`, `satty`, `nwg-look` and `matugen`, while `fuzzel` and `matugen` are deliberately installed from Fedora | BSD-3-Clause, MIT, GPL and similar | Bundled | Downloaded | -| Local build repository | `cybexos-desktop` (this repository), `mdview` (DigitalPals/mdview), `voxtype` (MIT) pinned GitHub release RPMs | `mdview`: no license file in its repository, **Unverified** | Bundled | Downloaded | - -The 2026-09-25 package list contained 1,700 packages. A complete per-package -license report for the actual release payload has not been produced. - -## Proprietary and non-RPM applications - -| Component | License or terms | ISO | Checkout | Status | -| --- | --- | --- | --- | --- | -| Brave Origin | Brave's terms grant "a personal, non-exclusive license to install and use the executable code version of the Brave browser" and no right to use Brave's marks; source parts are MPL-2.0 | Bundled | Downloaded | No redistribution grant located | -| 1Password and CLI | Proprietary RPM tag; the individual terms allow use "only for your own non-commercial use", the business terms (4.2) prohibit distributing "any part or content of the Services" | Bundled | Downloaded | No redistribution grant located | -| ChatGPT desktop | Proprietary RPM tag; OpenAI's terms page refused automated access | Bundled | Downloaded | Terms **Unverified**; no grant located | -| Spotify (`com.spotify.Client`) | Flathub declares `LicenseRef-proprietary` (Spotify end-user agreement), and the Flatpak fetches Spotify's binary as extra data at install time. The agreement (section 3) says "You agree that you will not redistribute, sell or transfer the Spotify Service or the Content." | Bundled: the image copies the builder's installed Flatpaks, including the downloaded binary | Downloaded by Flatpak | Conflicts with the quoted terms | -| Steam | RPM Fusion's spec states "Redistribution and repackaging for Linux is allowed, see license file"; the shipped license file is the Steam Subscriber Agreement, whose section 2.G restricts distribution without Valve's written consent. No explicit launcher grant was found. | Launcher and bootstrap bundled; client and games downloaded at first launch | Downloaded | **Unverified** | -| Android SDK (command-line tools, platform-tools, build-tools, platforms) | Android SDK License Agreement 3.4: "you may not copy (except for backup purposes), modify, adapt, redistribute ... the SDK or any part of the SDK"; 2.1 requires agreeing before use. `android-sdk-update` runs `yes \| sdkmanager --licenses`, and the 2026-09-25 seed contained more than 11,700 SDK files and `licenses/android-sdk-license`. | Bundled in the user seed | Downloaded; acceptance automated | Conflicts with the quoted terms; acceptance is made on the user's behalf on both paths | -| Claude Code 2.1.247 | Upstream `LICENSE.md`: "© Anthropic PBC. All rights reserved. Use is subject to Anthropic's Commercial Terms of Service." Those terms (D.4) prohibit reselling the Services without approval and contain no redistribution grant. | Bundled in the user seed | Downloaded from Anthropic | No redistribution grant located | -| Codex CLI | Apache-2.0 (`openai/codex`); tracks npm `latest`, so each image may contain a different version | Bundled | Downloaded | Redistributable with notices | -| OpenCode 1.18.27 | MIT (`anomalyco/opencode`) | Bundled | Downloaded | Redistributable with notices | -| T3 Code Nightly | MIT (`pingdotgg/t3code`); the AppImage also contains Electron and Chromium components with their own notices, **Unverified** | Bundled | Downloaded | Redistributable with notices | -| Rust nightly toolchain | MIT or Apache-2.0; Rust trademark policy not reviewed | Bundled | Downloaded | Redistributable with notices | -| Voxtype `ggml-base.bin` model | Whisper weights are MIT (`openai/whisper`); download source and checksum are not pinned in the inventory | Bundled | Downloaded at install | Record source and checksum | -| LocalSend, ProtonUp-Qt (Flathub) | Apache-2.0; GPL-3.0 | Bundled | Downloaded | Redistributable; GPL source obligation | -| Flatpak runtimes | Mostly free software. A Freedesktop runtime may pull the `org.freedesktop.Platform.openh264` extension, which downloads Cisco's binary as extra data. | Bundled with the whole system Flatpak tree; contents **Unverified** | Downloaded | Inspect the built image | -| lazygit, lazydocker, oh-my-posh | MIT | Bundled | Downloaded | Redistributable with notices | -| Bun | MIT; statically links LGPL-2 JavaScriptCore, with upstream relinking instructions | Bundled | Downloaded | Redistributable; LGPL obligations | -| balena CLI | Apache-2.0; the standalone bundle contains Node.js and many npm packages, notices **Unverified** | Bundled | Downloaded | Redistributable with notices | -| Omawrite | MIT (`omacom/omawrite`), built from source | Bundled | Built locally | Redistributable with notices | -| awww, wayfreeze | GPL-3.0; AGPL-3.0; built from pinned source | Bundled | Built locally | Corresponding source must be offered | -| LazyVim starter | Apache-2.0 | Bundled | Downloaded | Redistributable with notices | - -Downloaded only, never bundled: the Steam client and games, Wallhaven images -in the welcome window, the Firefox 1Password extension, Distrobox images, and -the XPS IPU7 camera stack (including `intel/ipu7-camera-bins`), which is fetched -only on detected XPS hardware. The optional private hooks, off by default, -download the Conthrax font; they are not part of the ISO. - -## Fonts - -| Font | Source | License | Notes | -| --- | --- | --- | --- | -| JetBrainsMono and FiraCode Nerd Fonts 3.5.1 | `ryanoasis/nerd-fonts` release archives | OFL-1.1 for patched fonts | The archive `LICENSE` is kept beside the fonts | -| iA Writer Mono, Duo, Quattro, Duospace | `iaolo/iA-Fonts`, pinned commits | OFL-1.1, Reserved Font Names "iA Writer" and "Plex" | License installed beside each font | -| Figtree 1.2, Geist Mono 1.5.0, Urbanist 1.321 | Pinned upstream commits | OFL-1.1 | License installed beside each font | -| Google Sans Flex | `end-4/google-sans-flex`, an unofficial mirror | Mirror README says OFL-1.1, but its `LICENSE` is the unfilled OFL template without a copyright line. Google's `google/fonts` repository (`ofl/googlesansflex`) has an `OFL.txt` with a copyright notice and a `TRADEMARKS.md`. | Source from the official repository | -| OPPO Sans 4.0 | OPPO archive, pinned by SHA-256 | OPPO Sans Fonts License Agreement | See below | -| Tabler outline subset | `@tabler/icons-webfont` 3.48.0, in the shell | MIT | License shipped in `assets/tabler/` | -| Fedora font packages | Fedora 44 | OFL-1.1 for most; `OFL-1.1-RFN` (Liberation); MIT (`fontawesome-fonts-all`); CC-BY-4.0 (`fontawesome-fonts-web`); Apache-2.0 (Open Sans); `GPL-1.0-or-later` (Madan); `GPL-2.0-only WITH Font-exception-2.0` (PakType Naskh Basic); `LicenseRef-Fedora-UltraPermissive` (Symbola) | Redistributed under Fedora's packaging | - -The OPPO Sans agreement grants a revocable license to "use, copy, merge, embed, -bundle, redistribute and/or sell unmodified copies of OPPO Sans Fonts with any -software except for fonts software", on conditions that include: "YOU shall -make a prominent notice in the software to state that OPPO Sans Fonts are -used", no modification, no stand-alone redistribution, and retaining the -copyright notice and agreement. The agreement file is installed beside the -font, but no notice inside CybexOS was found. - -## Wallpapers and artwork - -The former seven undocumented bundled wallpapers, including the character -images and default photograph, and the three web derivatives have been removed -from the current distributable checkout. They are replaced by original -procedural landscapes generated by `scripts/generate-wallpapers.py`. The native -default is `moon-mountains.png`; the web's matching PNGs are rendered from the -same palette/geometry at 1280, 2560 and 5120 pixels. All ten files have byte and -generator digests and truthful authorship-method records in -`assets/PROVENANCE.json`. The original artwork uses the repository MIT license; -this says nothing about the removed files' rights. Git history and previously -published releases are not rewritten or cleared by the replacement. - -Installers retain existing personal files and saved choices. Only the fresh -payload changes on both paths via `assets/desktop-contract.json` and the shared -wallpaper source directory. No migration deletes a user's old wallpapers. - -The Plymouth theme in `roles/boot/files` (logo, entry, bullet and progress -images, and the script) is shipped in the desktop RPM. The checkout role also -uses the pinned `DigitalPals/omarchy-cybex` repository, which has no license -file. The Cybex wordmark SVGs are in the welcome window, the live installer, -and the browser demo. These files are below the 1 MiB inventory threshold and -have no recorded provenance; the owner should confirm authorship. - -## Brand icons and trademarks - -The shell's product marks are listed with their sources in -`roles/desktop/files/quickshell/assets/README.md` and -`THIRD_PARTY_LICENSES.md`: Simple Icons (CC0) for Claude, Gemini, GitHub, Kimi, -Tailscale, WhatsApp and YouTube; Tabler (MIT) for OpenAI and Slack; MIT -sources for Grok and T3 Code; the Unlicense for Fusebox; and Fedora Design -artwork (CC BY-SA 4.0) for Fedora. The Model Usage widget redistributes the -Claude and Codex marks from Omarchy (MIT), and the browser demo serves -Fedora, Claude and Codex marks. These licenses cover copyright only. The -trademark guidelines of the individual brands were not reviewed -(**Unverified**). - -Fedora's [trademark guidelines](https://docs.fedoraproject.org/en-US/legal/trademarks/) -allow software that combines Fedora with other software to use the "Fedora -Remix" marks provided that "the fedora-logos, fedora-release, and -fedora-release-notes RPM packages are removed" (replacements must not contain -Fedora trademarks) and that a notice prominently states that the software is -not provided or supported by the Fedora Project and links to -. Their general conditions add that "no other -Fedora Trademarks are used for remix products". Findings: - -- `image/live.ks` installs `generic-logos` and `generic-release-notes`, and - `fedora-logos` was absent from the 2026-09-25 package list. -- `image/live.ks` also installs `fedora-release-common` and - `fedora-release-identity-basic`, which set the operating system name to - Fedora Linux. The 2026-09-25 list also contained the `fedora-release` - metapackage. Fedora's `generic-release` replaces them with a placeholder - identity (`ID=generic`, `ID_LIKE=fedora`), but CybexOS code checks for - `ID=fedora` in `cybexos-update-channel`, `cybexos-major-upgrade`, and the - shell's system information. A dedicated release package needs engineering - work. -- The menubar's Control Center button always shows the Fedora logo - (`Bar/Modules/Control.qml`), and the drawer shows it on Fedora systems. -- No "not provided or supported by the Fedora Project" notice was found in the - README, welcome window, or installer. - -No trademark registration or clearance search for the CybexOS or Cybex name -and wordmark is recorded. "Cybex" is also used as a brand by unrelated -companies. Bundled vendor applications (Brave, 1Password, ChatGPT, Spotify, -Steam, Firefox and others) carry their owners' marks; whether a third-party ISO -may redistribute them under those marks is **Unverified**. - -## Firmware - -- `linux-firmware` (`GPL-1.0-or-later AND GPL-2.0-or-later AND MIT AND - LicenseRef-Fedora-Firmware`) and its split packages (`amd-gpu-firmware`, - `intel-gpu-firmware`, `nvidia-gpu-firmware`, Wi-Fi, Bluetooth, audio, - microcode, and the XPS NPU and vision firmware) are bundled from Fedora. - `LicenseRef-Fedora-Firmware` is Fedora's identifier for firmware it accepts - as redistributable without modification; its definition was not re-read for - this audit. `linux-firmware-whence` was in the 2026-09-25 package list. -- `nvidia-gpu-firmware` ships `LICENCE.nvidia`. Its section 2.1.2 allows the - firmware to be "copied and redistributed solely for use on operating systems - distributed under the terms of an OSI-approved open source license ..., - provided that the binary files thereof are not modified, and Customer - provides a copy of this license with the SOFTWARE." -- The image is composed with `%packages --excludedocs`. RPM normally keeps - `%license` files with that option, but whether `/usr/share/licenses` is - complete in the built image is **Unverified**. -- Proprietary NVIDIA drivers are not bundled. On machines with an NVIDIA GPU, - the `nvidia` hardware role downloads `akmod-nvidia`, - `xorg-x11-drv-nvidia-cuda-libs` and `libva-nvidia-driver` from RPM Fusion - nonfree after installation and builds the module locally (see - [NVIDIA graphics](nvidia.md)), so the user's machine, not the ISO, receives - NVIDIA's software. Re-audit if the ISO ever composes these packages. - -## Codecs and patents - -These are patent-licensing questions, separate from copyright. Fedora omits -these codecs from its own media. - -- RPM Fusion's `ffmpeg`, `x264-libs`, and `x265-libs` (H.264 and HEVC) and - `intel-media-driver` are composed into every image. Whether CybexOS may - distribute them, and from which jurisdiction, needs a legal decision. -- Cisco's [OpenH264 binary license](http://www.openh264.org/BINARY_LICENSE.txt) - provides patent coverage only when the binary is "separately downloaded to an - end user's device" and "not integrated into or combined with third party - software prior to being downloaded". It also requires user control and the - attribution "OpenH264 Video Codec provided by Cisco Systems, Inc." Composing - `openh264`, or copying the Flatpak `openh264` extension, into the ISO would - fall outside that coverage. The installed system's enabled Fedora Cisco - repository can download it instead. - -## Source-code obligations - -The ISO distributes GPL, LGPL and AGPL binaries from Fedora, RPM Fusion and the -COPR, plus source-built `awww` (GPL-3.0) and `wayfreeze` (AGPL-3.0), Quickshell -(LGPL-3.0), and Bun's LGPL JavaScriptCore. A distributor of these binaries has -source obligations of its own. The release workflow publishes only the -CybexOS source archive. Whether pointing to upstream sources is sufficient is -for legal review; publishing the corresponding sources for each ISO avoids the -question. - -## Blockers before public ISO distribution - -1. **Wallpapers — engineering replacement completed.** The undocumented - images are absent from the new source/native/web payloads. Original - reproducible landscapes replace them; this does not clear earlier releases - or approve the remaining third-party artwork, software or trademarks. -2. **Bundled proprietary applications.** No redistribution permission was - located for Spotify, the Android SDK, Claude Code, 1Password, ChatGPT or - Brave Origin, and the quoted Spotify and Android SDK terms conflict with - bundling. The Android SDK license is accepted on the user's behalf. -3. **Cisco OpenH264.** If the image composes `openh264` or the Flatpak - `openh264` extension, Cisco's patent coverage does not apply to those - copies. -4. **Fedora trademarks.** The image keeps `fedora-release` packages, the shell - shows the Fedora logo, and the required notice is missing. -5. **Patent-encumbered codecs** from RPM Fusion need a documented legal - decision. -6. **Corresponding source** for GPL, LGPL and AGPL components is not published - with the ISO. -7. **OPPO Sans** requires a prominent in-software notice that does not exist. - -Items needing confirmation rather than known conflicts: Steam's launcher -redistribution, vendor trademark use, the Flatpak runtime contents, retained -license files under `--excludedocs`, `mdview` and Plymouth artwork licenses, -and per-brand icon guidelines. - -## Recommended actions - -1. Keep the original wallpaper generator and complete native/web provenance - inventory synchronized. The replacement is complete; review any additional - asset against the actual distribution inventory before publication. -2. Stop bundling proprietary applications whose terms do not allow it. - Download them at first boot or first use from the vendor repositories the - image already configures with pinned keys, or obtain written permission - and record it in the repository. Keep the offline payload for software - whose license allows it. -3. Download the Android SDK at first use and ask the user to accept Google's - terms, instead of running `yes | sdkmanager --licenses` on both paths. -4. Exclude Cisco's `openh264` and the Flatpak `openh264` extension from the - image; install them after installation from Cisco's repository. -5. Ship a CybexOS release-identity package in place of `fedora-release*`, - update the `ID=fedora` checks, replace the Fedora logo in the menubar, and - add the required Fedora notice to the README, welcome window, and About - page. Use "Fedora Remix" wording only as the guidelines allow. -6. Generate a license report from each build's package list (name, version, - repository, License tag), publish it with the release, and fail the build - on unreviewed proprietary tags or repositories. -7. Publish source RPMs and source archives for every composed package and - source-built tool with each ISO, or obtain legal advice on an alternative. -8. Add a third-party notices page (for example under Settings → About) that - includes the OPPO Sans notice, and the OpenH264 attribution if it is used. -9. Source Google Sans Flex from `google/fonts` with its official `OFL.txt`. - Pin the Codex CLI version and the Voxtype model checksum so each image's - payload can be reviewed. -10. Add licenses to `DigitalPals/mdview` and `DigitalPals/omarchy-cybex`, and - record the provenance of the Plymouth artwork and Cybex wordmark. -11. Run a trademark clearance check for CybexOS and Cybex, and review the - brand guidelines for each product mark in the shell. -12. Have the owner and legal counsel review this list, and record each - decision here before the first public ISO. - -## Not verified - -- OpenAI's terms for the ChatGPT desktop application. -- An explicit Valve grant for redistributing the Steam launcher. -- Trademark policies of Brave, 1Password, OpenAI, Spotify, Valve, Mozilla, - Rust, and each brand in the shell. -- Which OpenH264 provider the current image composes, and the Flatpak runtime - and extension contents. -- The complete contents and notices of the T3 Code AppImage, balena CLI - bundle, and Android SDK components that may fall under the SDK's - open-source exception (section 3.5). -- The license of `DigitalPals/mdview` and of the Plymouth artwork and wordmark. -- Retention of `/usr/share/licenses` in the built image. -- The current revision's payload; no current ISO was built for this audit. - - -## Public distribution gate - -The historical package counts and September image cited above remain audit -leads, not evidence for a new release. No current ISO has been built for this -implementation. `assets/distribution-review.json` explicitly lists unresolved -channels and contains **no approved release records**. The MIT code license, -Fedora RPM License tags and SPDX `NOASSERTION` fields are never distribution -approval. - -`image/distribution_review.py` separates internal capture from PUBLIC review: - -- `source`: full extracted source-file/link inventory and the actual archive's - byte digest. The release workflow makes that archive reproducible for the - same revision so retrying publication preserves its identity. -- `desktop-rpm` and `offline-rpm`: the actual signed RPM byte digest plus every - header payload path, SHA-256, mode and symlink target, queried without - installing or running the RPM. Header capture runs in the existing hosted - Fedora signing container; the public job rechecks the signed artifact's - digest and size before using that evidence, so it needs no older Ubuntu RPM - parser and cannot reuse it with replaced package bytes. -- `iso`: every prepared release asset's bytes, the SPDX packages of the actual - composed root, and the current `rpm-inventory.tsv`, `flatpak-inventory.tsv`, - `package-manifest.json`, `source-build-provenance.json` and - `build-provenance.json`. It verifies the ISO parts against their manifests, - recomputes the complete ISO digest without writing another ISO, checks the - SPDX subject, and requires the source-toolchain artifact digest in the SBOM. -- `web`: every file/link in the compiled output, including generated CSS/JS, - fonts, notices, logos and wallpaper derivatives. Reviewing `web/public` - alone does not qualify the compiled web distribution. - -Every review selects exactly one channel and the SHA-256 of its entire -canonical inventory. Each component records an explicit approved decision, -license or verified grant, obligations to satisfy, trademark review status, -and a repository evidence file with its SHA-256. Its exact item list, or -compact prefix selectors with explicit exclusions, must cover the entire -inventory once. Prefixes apply only to that captured inventory: adding, -removing or changing any item invalidates the approval. Group items by their -actual rights holder/terms; use separate groups for original code, each -third-party component, fonts and marks. A reviewer must inspect opaque bundled -applications and dependency notices; a byte inventory is not a legal opinion. - -### Supplying verified records - -1. Before the final candidate build, collect verifiable license texts, source - offers, notices, vendor grants and trademark decisions under an appropriate - repository documentation directory. Include who made each decision and its - scope. Do not commit private correspondence or secrets; retain a public - reference or a non-sensitive verified decision record instead. Changing - these files later changes the source candidate and requires a new build. -2. Build/qualify the final candidate internally from one revision. Local source - and image gates and the `desktop-release.yml` build/sign/qualification jobs - do not require public review. They do not grant permission to distribute. -3. Run `capture`, writing output outside the candidate directory. Examples: - - ```bash - python3 -B image/distribution_review.py capture --channel source \ - --root /review/extracted-source --artifact /review/cybexos-VERSION.tar.zst \ - --output /review/source-distribution-inventory.json - python3 -B image/distribution_review.py capture --channel desktop-rpm \ - --artifact /review/cybexos-desktop-VERSION.x86_64.rpm \ - --output /review/desktop-distribution-inventory.json - python3 -B image/distribution_review.py capture --channel offline-rpm \ - --artifact /review/cybexos-offline-apps-VERSION.x86_64.rpm \ - --output /review/offline-distribution-inventory.json - python3 -B image/distribution_review.py capture --channel iso \ - --root /review/publication/assets --sbom /review/publication/assets/NAME.spdx.json \ - --output /review/iso-distribution-inventory.json - ``` - - The publishing job retains `public-distribution-inventories` before checking - approval, so an unresolved review fails publication with reviewable output. - Review actual artifacts from that same build, never substitute an old audit - or a different build's SBOM. Their transient CI retention is not long-term - legal evidence; retain the needed records through the owner's review process. -4. Prepare an **external** review JSON file using schema 1 and - `"publication": "PUBLIC"`, with a `reviews` array. For each channel include - its printed `inventorySha256`, `decision`, `reviewedBy`, timezone-qualified - `reviewedAt`, and `components`. Each component needs `name`, `decision`, - `license`, `obligations`, `trademarks` (`reviewed` or `not-applicable`), - `evidence: {"path": "docs/...", "sha256": "..."}`, and `items` (an exact - array of inventory keys). Large groups may use - `"items": {"prefixes": ["file:PATH/"], "excludePrefixes": ["file:PATH/OTHER/"]}`. - `UNKNOWN`, `NOASSERTION`, unresolved decisions, empty evidence and missing - coverage fail. Only the owner/reviewer may set `decision` to `approved` - after verifying the rights and satisfying obligations. No engineering tool - manufactures those decisions. -5. Check locally with the same capture arguments, replacing `capture` with - `check-public` and adding `--reviews /review/owner-review.json`. This always - captures the candidate again. Do not put that approval inside the reviewed - source archive: doing so creates a self-referential digest. Commit evidence - first; supply the final inventory-bound decisions outside the payload. -6. Repository operators must configure the GitHub `public-distribution` - environment with required owner reviewers and store the reviewed JSON in - `CYBEXOS_PUBLIC_DISTRIBUTION_REVIEW` as an environment variable (not a secret). - Compact groups keep this within GitHub variable limits. Then rerun only the - publishing job against the same retained qualified artifacts. It requires - all four release channels before attesting or publishing anything; the - subsequent Pages publication depends on that job. Missing records fall back - to the checked-in unresolved manifest and fail closed. Successful releases - publish and attest the inventories and review record alongside the compiler - evidence, SBOM, source, RPMs and ISO parts. - -For the browser demo, run its normal build then `npm run review:inventory` -inside `web/` to capture `dist/`. Before any public hosting, run the matching -`check-public --channel web --root web/dist --output /review/web.json --reviews -/review/owner-review.json` from the repository root. `npm run review:public` -uses the checked-in unresolved manifest by default and fails until an actual -review is supplied. Preserve the exact reviewed compiled output for hosting; -rebuilding it requires recapture/review. The existing web CI only builds and -tests internally; it does not publish or imply approval. The gate cannot -control a person manually uploading files outside these workflows. - -### Decisions still required - -Owner/legal review remains required for proprietary application redistribution -(Spotify, Android SDK, Claude Code, 1Password, ChatGPT, Brave Origin), Android -SDK acceptance, Steam launcher terms, the actual OpenH264 provider and Flatpak -extensions, codec patent treatment, corresponding-source offers for copyleft -components, OPPO Sans notice/conditions, Google Sans Flex provenance, `mdview` -and Plymouth rights, wordmark authorship, notices for bundled app dependencies, -firmware terms, and Fedora/vendor trademarks. Resolve each against the current -channel's actual payload, or remove it from that public channel in a separate -product change. These decisions were not invented by the wallpaper replacement -or by adding the gate. diff --git a/docs/licensing.md b/docs/licensing.md index 1743e025..c2a8446f 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -1,94 +1,22 @@ -# Licensing and asset provenance - -The repository's original code and configuration are licensed under the MIT -License; see the repository-root `LICENSE`. That grant applies to CybexOS -copyrighted code and does not replace licenses or permissions for third-party -software, assets, product names, or marks included in a source archive, ISO, or -RPM. - -## Third-party software and branding - -Files copied or downloaded from other projects remain under their upstream -terms. In particular: - -- pinned single-file fonts install their verified upstream license text beside - the font; -- the OPPO Sans archive supplies its own font license agreement, which the role - preserves beside the installation; -- the Cybex role checks out a pinned upstream artwork revision and then overlays - repository-local theme files; and -- product names, logos, and brand SVGs may also be subject to trademark rules, - independently of the MIT License. - -A public source archive, desktop RPM, or ISO bundles more than original CybexOS -code. Complete and document the software and asset redistribution audit for the -actual release payload before public distribution. The MIT License does not -itself grant rights to redistribute third-party packages, artwork, fonts, -wallpapers, or trademarks. - -## Repository assets - -The bundled wallpaper collection is installed into `~/Pictures/Wallpapers`. -The installer selects the mountain wallpaper when no wallpaper is configured, -while preserving existing wallpaper selections and custom folders. - -The seven native wallpapers and three responsive web variants are original -procedural landscapes. `python3 -B scripts/generate-wallpapers.py` recreates -them from geometric shapes using Python's standard library, without input -images, downloaded assets, or generative image services. `--check` compares all -rendered pixels and recorded artifact digests/provenance; zlib implementations -can encode identical pixels into different PNG bytes. `assets/PROVENANCE.json` records every native and web -wallpaper regardless of size, its dimensions, byte digest, generator digest, -palette and authorship method. Newly authored files do not claim a historical -introduction commit. These original assets use the repository's MIT license. - -The former undocumented bundled JPEGs and web derivatives have been removed -from the distributable tree. Installers copy new wallpapers only when absent -and retain saved selections and user-owned files, including earlier wallpapers -already in a user's home. This replacement does not rewrite Git history or -assert rights in earlier distributions. - -`tests/repository-policy.py` verifies complete native/web wallpaper coverage, -checksums and generator identity. This is distinct from the owner review of -other third-party software and marks needed for public distribution. - -## Redistribution audit - -[The redistribution audit](licensing-audit.md) inventories the third-party -packages, applications, fonts, wallpapers, marks, and firmware in each release -channel, records what could and could not be verified, and lists the blockers -before a public ISO. It is an engineering inventory for owner and legal review, -not a legal opinion. Public publication additionally requires a channel-specific, -reviewed distribution manifest bound to the actual artifact inventories; see -[the distribution review process](licensing-audit.md#public-distribution-gate). -Internal builds, fixture tests and ISO qualification remain available while -review is unresolved. - -## Built-in theme palettes - -The [built-in themes](shell-appearance.md#themes) under -`roles/desktop/files/quickshell/themes/` are data files: the colour values -of published palettes, mapped onto the shell's roles, with no code, artwork -or images from those projects. Each `theme.json` names its source in its -`author`, `license` and `source` fields; `tests/theme-packs.py` requires an -MIT license and an https source for every built-in theme. - -| Theme | Palette | Author | License | Source | -| --- | --- | --- | --- | --- | -| CybexOS | The shell's own defaults | CybexOS | MIT | this repository | -| Nord | Polar Night, Snow Storm, Frost, Aurora | Arctic Ice Studio and Sven Greb | MIT | | -| Gruvbox | gruvbox dark (medium) | Pavel Pertsev | MIT | | -| Tokyo Night | Tokyo Night (night) | Enkia | MIT | | -| Catppuccin Mocha | Mocha | Catppuccin | MIT | | -| Catppuccin Latte | Latte | Catppuccin | MIT | | -| Rosé Pine | Rosé Pine (main) | Rosé Pine | MIT | | -| Everforest | Everforest dark (medium) | Sainnhe Park | MIT | | -| Kanagawa | Kanagawa wave | Tommaso Laurenzi | MIT | | - -Where a palette has no colour for a role (Rosé Pine has no green; it uses -foam), the nearest named colour of the same palette is used. Roles a theme -leaves out are derived by the shell and are not part of the upstream palette. -Theme names identify the palettes they reproduce; they are their projects' -names, used descriptively. User themes installed with `cybex theme install` -keep their own license terms; the installer records where each came from but -does not establish redistribution rights. +# Artwork and licensing + +NerdOS glasses, outlined wordmarks, icons, favicons and ASCII logos were supplied +as original project artwork in October 2026. They are distributed under the +repository MIT license. [Brand usage](../assets/brand/USAGE.md) specifies colour, +spacing, minimum sizes and hinted small variants. [Source and deployment +mapping](../assets/brand/README.md) identifies each deployment copy; +`assets/brand/kit-sha256.json` records their original byte identities. + +The wordmark and descriptor use outlined Space Grotesk and JetBrains Mono +lettering, both SIL Open Font License 1.1. Copyright/licence notices are included +in `assets/brand/FONT-LICENSES/`. Font binaries are not bundled by the brand kit. +The browser separately bundles Figtree/JetBrains Mono with their existing notices. + +Existing procedural wallpaper provenance, generator digests, pinned third-party +design references and historical patch authors remain unchanged. Third-party +marks and notices retain their original owners. See the Quickshell +`assets/THIRD_PARTY_LICENSES.md`, Model Usage notices and [web assets](../web/ASSETS.md). + +John must perform a trademark search for **NerdOS**; no clearance is asserted. +The existing owner distribution review still applies to the actual release +closure and compiled browser output. diff --git a/docs/measurements/efficiency-2026-10-15.json b/docs/measurements/efficiency-2026-10-15.json deleted file mode 100644 index 13ca8728..00000000 --- a/docs/measurements/efficiency-2026-10-15.json +++ /dev/null @@ -1,1383 +0,0 @@ -{ - "artwork_lossless": [ - { - "bytes": 2136966, - "candidates": [ - { - "bytes": 2114005, - "file": "01-black-widow-warrior-with-katana-ks-progressive.jpg", - "sha256": "42f63a048ca56e193f5243f915aeef67cee6471f2f3c69ad8fa9b03b4afc64fe" - }, - { - "bytes": 2149891, - "file": "01-black-widow-warrior-with-katana-ks-baseline.jpg", - "sha256": "de40862efac4c82ea96bbf5180c67dc97f0770043468e3dcb31eabf910926207" - } - ], - "source": "01-black-widow-warrior-with-katana-ks.jpg" - }, - { - "bytes": 4651376, - "candidates": [ - { - "bytes": 4631500, - "file": "05-joker-chaos-in-a-purple-suit-nq-progressive.jpg", - "sha256": "101dbdd19259601c29cb65339cafc4061d707e10e31cb3a8c86f2c81eba8eaf0" - }, - { - "bytes": 4798737, - "file": "05-joker-chaos-in-a-purple-suit-nq-baseline.jpg", - "sha256": "be490579639d52954c9eb414188e9932f9523d3744fc729162a1331658e86644" - } - ], - "source": "05-joker-chaos-in-a-purple-suit-nq.jpg" - }, - { - "bytes": 6352485, - "candidates": [ - { - "bytes": 6307720, - "file": "diana-prince-amazonian-legend-hn-progressive.jpg", - "sha256": "3e86df25a8fb3215ec8402568a844f36dc8e867b68ed2ae6d9b17fbc9d46797b" - }, - { - "bytes": 6708684, - "file": "diana-prince-amazonian-legend-hn-baseline.jpg", - "sha256": "d5cdb50fa037878f7af719d6a9750913cb96417405dacc200ff538e97e160368" - } - ], - "source": "diana-prince-amazonian-legend-hn.jpg" - }, - { - "bytes": 7256499, - "candidates": [ - { - "bytes": 7118373, - "file": "joker-ghost-in-dark-rt-progressive.jpg", - "sha256": "4f886a8aa3c46a1a8dc2a855a32fa10867268df7e0a22086f2fd14d38fc4c432" - }, - { - "bytes": 7408322, - "file": "joker-ghost-in-dark-rt-baseline.jpg", - "sha256": "afb095ddea3cccc012da27aab0ae5c5608af01142d447f733f55356695c1daa5" - } - ], - "source": "joker-ghost-in-dark-rt.jpg" - }, - { - "bytes": 1838747, - "candidates": [ - { - "bytes": 1816984, - "file": "snow-capped-mountains-with-full-moon-lo-progressive.jpg", - "sha256": "77a782d73220b7fac27c5cd933a9b4c828bc13257cb0e4d6ce0fa1d7af60a2f5" - }, - { - "bytes": 1865433, - "file": "snow-capped-mountains-with-full-moon-lo-baseline.jpg", - "sha256": "7850e3f857c44e585e5080cc99c8b707b2765ea121755d2fe1d2b9e1d282f9d6" - } - ], - "source": "snow-capped-mountains-with-full-moon-lo.jpg" - }, - { - "bytes": 2082942, - "candidates": [ - { - "bytes": 2026297, - "file": "spider-man-guardian-of-the-skyline-yx-progressive.jpg", - "sha256": "0bc48a0edb1495bf189b750354967b128be7f4d11bfe39d377e26fc9274fac5d" - }, - { - "bytes": 2082879, - "file": "spider-man-guardian-of-the-skyline-yx-baseline.jpg", - "sha256": "b9ef1b887a9d570481e3654d115e1881e37c768961c93d892bd7fc30731e3cc2" - } - ], - "source": "spider-man-guardian-of-the-skyline-yx.jpg" - }, - { - "bytes": 3963498, - "candidates": [ - { - "bytes": 3922044, - "file": "spider-man-the-hero-beyond-the-stars-cx-progressive.jpg", - "sha256": "eb741559491d07e157c262494ece78ad104c24c7574ca47f04debb64bf2c42cb" - }, - { - "bytes": 4122232, - "file": "spider-man-the-hero-beyond-the-stars-cx-baseline.jpg", - "sha256": "2c0207f177ad12f36e8ea2fe4446da5fc5732f62e675ae8510776aadd627661c" - } - ], - "source": "spider-man-the-hero-beyond-the-stars-cx.jpg" - } - ], - "baseline_payload": { - "opt/cybexos-apps": { - "bytes": 485608784, - "files": 44858 - }, - "opt/cybexos-builds": { - "bytes": 12213834, - "files": 5 - }, - "seed_components": { - ".config": { - "bytes": 0, - "files": 0 - }, - ".local": { - "bytes": 1668397370, - "files": 7647 - }, - ".npm-global": { - "bytes": 0, - "files": 0 - }, - ".rustup": { - "bytes": 1428749636, - "files": 66102 - }, - "Android": { - "bytes": 489063621, - "files": 11726 - }, - "Pictures": { - "bytes": 0, - "files": 0 - } - }, - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/user-seed": { - "bytes": 3586210627, - "files": 85475 - }, - "usr/share/fonts/cybexos": { - "bytes": 6468676, - "files": 20 - } - }, - "baseline_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "cleanup": { - "final_iso_hardlinked_to_artifact_bundle": true, - "first_attempt_removed_disposable_artifacts_before_refresh_retry": true, - "iventoy_refresh_list_running_service": "passed", - "preexisting_images_preserved": true, - "refresh_retry_report": "/data/cybexos-efficiency-20261015-yCv5AF/reports/cleanup-refresh-retry.json", - "removed_served_isos": [ - "CybexOS-Live-44-20261015T135845Z-a65b1c60.iso", - "CybexOS-Live-44-20261015T163902Z-263beebe.iso", - "CybexOS-Live-44-20261015T174513Z-aab97405.iso", - "CybexOS-Live-44-20261015T181251Z-4dff45c4.iso" - ], - "removed_task_entries": [], - "retained_directory": "/data/cybexos-efficiency-20261015-yCv5AF", - "retained_disk_bytes": 10007286784, - "retained_logical_bytes": 10058779495, - "retained_reason": "Final complete ISO/RPM/source/SBOM bundle and compact qualification/provenance evidence for review.", - "status": "passed", - "task_owned_mounts": "none after cleanup", - "task_owned_processes": "none after cleanup", - "test_container_image_removed": true - }, - "compression_payload": { - "measurements": [ - { - "bytes": 1496461312, - "compress_seconds": 55.47804500899974, - "compression": "xz", - "extract_seconds": 6.546740841999963, - "run": 1 - }, - { - "bytes": 1571086336, - "compress_seconds": 33.241616783000154, - "compression": "zstd", - "extract_seconds": 4.855884554999648, - "run": 1 - } - ], - "processors": 8, - "scope": "SquashFS compression and extraction; not boot or installation time", - "source": { - "bytes": 4457839657, - "files": 131381, - "sha256": "fbfd61d3d0a26215be68195c5f5fa6ef01dcb5d3eec3d9cdc2ecb8a42a8b6ede" - }, - "status": "passed" - }, - "compression_rust_docs": { - "measurements": [ - { - "bytes": 58544128, - "compress_seconds": 8.530532367993146, - "compression": "xz", - "extract_seconds": 1.648576760198921, - "run": 1 - }, - { - "bytes": 60223488, - "compress_seconds": 8.299263254040852, - "compression": "zstd", - "extract_seconds": 2.1048905889037997, - "run": 1 - }, - { - "bytes": 60223488, - "compress_seconds": 8.823862102115527, - "compression": "zstd", - "extract_seconds": 2.125140634132549, - "run": 2 - }, - { - "bytes": 58544128, - "compress_seconds": 11.00238976906985, - "compression": "xz", - "extract_seconds": 2.183411896927282, - "run": 2 - } - ], - "processors": 8, - "scope": "SquashFS compression and extraction; not boot or installation time", - "source": { - "bytes": 739153743, - "files": 65926, - "sha256": "81f92b29c550e6863413a55e20c0c6e07775bcc2ea373ab0a4c48314ace58194" - }, - "status": "passed" - }, - "date": "2026-10-15", - "final_artifacts": { - "baseline_upstream_comparison": { - "flatpak_inventories_match": true, - "imported_signing_key_inventories_match": true, - "imported_signing_keys_after": 10, - "imported_signing_keys_before": 10, - "other_rpm_identities_match": true, - "rpm_identity_differences": [ - { - "after": [ - "1", - "0.0.0~dev", - "1.20261015183819.g5ce58a3ec8cf.dirty.fc44" - ], - "arch": "x86_64", - "before": [ - "1", - "0.0.0~dev", - "1.20261015131239.g5ce58a3ec8cf.fc44" - ], - "name": "cybexos-desktop" - }, - { - "after": [ - "1", - "1.4.0", - "1.fc44" - ], - "arch": "x86_64", - "before": null, - "name": "cybexos-offline-apps" - } - ], - "rpm_packages_after": 2260, - "rpm_packages_before": 2259 - }, - "build_id": "20261015T183818Z-25fb4f79", - "desktop_rpm": { - "bytes": 29830738, - "filename": "cybexos-desktop-0.0.0~dev-1.20261015183819.g5ce58a3ec8cf.dirty.fc44.x86_64.rpm", - "sha256": "ee019fe04267802824b4123e5038f06e2bd4d6e61530add62362c109817db577" - }, - "directory": "/data/cybexos-efficiency-20261015-yCv5AF/optimized-build-qualified-r6/artifacts", - "iso": { - "bytes": 7996391424, - "path": "/data/pxe/iso/CybexOS-Live-44-20261015T183818Z-25fb4f79.iso", - "sha256": "fea4ac94242869d7e35f528e5fd40549cce87981ece02ce95d594d2901fb19e2" - }, - "logical_bytes": 10057712493, - "offline_payload": { - "bytes": 3676758076, - "files": 64619, - "groups": { - "opt/cybexos-apps": { - "bytes": 485608784, - "files": 44858 - }, - "opt/cybexos-builds": { - "bytes": 12213964, - "files": 7 - }, - "usr/local/bin": { - "bytes": 0, - "files": 0 - }, - "usr/local/libexec": { - "bytes": 39053, - "files": 6 - }, - "usr/local/share/applications/omawrite.desktop": { - "bytes": 332, - "files": 1 - }, - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/final-seed": { - "bytes": 9455393, - "files": 14 - }, - "usr/share/cybexos/offline-inputs.json": { - "bytes": 112, - "files": 1 - }, - "usr/share/cybexos/seed-groups.json": { - "bytes": 27, - "files": 1 - }, - "usr/share/cybexos/user-seed": { - "bytes": 2847057353, - "files": 19548 - }, - "usr/share/icons/hicolor/scalable/apps/omawrite.svg": { - "bytes": 0, - "files": 0 - }, - "usr/share/licenses/cybexos-fonts": { - "bytes": 89851, - "files": 20 - } - }, - "inputs_sha256": "515765a7601038f478ad55bfbba9ce00f7ed7e6ce7f6d491ad6508063e75648e", - "version": "1.4.0" - }, - "offline_rpm": { - "bytes": 1641308348, - "filename": "cybexos-offline-apps-1.4.0-1.fc44.x86_64.rpm", - "sha256": "a2efda36c8a3e02b95048e41c4e2e7a16ec3341612c6274f7c73028c887d186c" - }, - "scope": "Final build after the installation parity corrections, qualified through actual ISO/source installation and encrypted upgrade/recovery. Earlier paired benchmark artifacts remain identified separately.", - "source_archive_sha256": "737c1b826cbc38a1e10acf42b6c89d192c559c7d4dddf5a00ec467847903f501" - }, - "iso_package_comparison": { - "flatpak_inventories_match": true, - "measurement": "Compared complete composed-image RPM name/architecture/epoch/version/release inventories and full Flatpak deployment records, including commits.", - "other_rpm_identities_match": true, - "rpm_identity_differences": [ - "cybexos-desktop: baseline monolithic build versus current split build", - "cybexos-offline-apps: added independent companion package" - ], - "rpm_packages_after": 2260, - "rpm_packages_before": 2259 - }, - "isos": { - "after": { - "bytes": 7996391424, - "file": "CybexOS-Live-44-20261015T135845Z-a65b1c60.iso", - "sha256": "4aace4005196dab0db00eac0cfc980508420bc1fb26aca73c0312cb1693007b0", - "source_archive_bytes": 32277823, - "source_archive_sha256": "83ce60619c2dbe6577043b83cdbfa94d24d2593abdddbae4fa9bfa671f9211d2", - "source_content_sha256": "b41f64ba782ece5db5144bb29fdb776db9038bcde80d6cfd1a7aca9e87c1054e", - "source_dirty": true, - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c" - }, - "before": { - "bytes": 8071483392, - "file": "CybexOS-Live-44-20261015T131239Z-f3ae6fb5.iso", - "sha256": "9a847aca4ef91a5a48de33b77efd45befb97b7b6bf70f0f668cd6e847b819818", - "source_archive_bytes": 33331560, - "source_archive_sha256": "5da368adb0467b5ab925360ff9571f02e4db076ae1d4d48ce73590196bbf8d9c", - "source_content_sha256": "1149a9bd1fb81ecf55503d895e8afd91c3c7eb0edbb2fab6f471c4a44f1df4c6", - "source_dirty": false, - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c" - }, - "measurement": "Complete xz ISOs from clean baseline and optimized snapshots at the same base revision; all upstream RPM identities and Flatpak deployment records match. Build and installation times are not compared.", - "saved_bytes": 75091968, - "saved_percent": 0.9303366475910355 - }, - "model_usage_sharing": { - "automatic_polling_after_last_awake_consumer_removed": "paused", - "changed_credentials": { - "legacy_activity_helpers": 1, - "quota_helpers": 1 - }, - "initial_legacy_activity_helpers": 1, - "initial_matching_consumers": 2, - "initial_quota_helpers": 1, - "manual_refresh_after_unplug": { - "legacy_activity_helpers": 1, - "quota_helpers": 1 - }, - "measurement": "Actual Quickshell QML engine with two matching consumers and counting quota/activity child helpers; no external network/API requests.", - "scope": "Controlled engine lifecycle, credential isolation and invocation counts; not a physical multi-monitor CPU or external API benchmark." - }, - "optimized_payload": { - "offline": { - "bytes": 3676758933, - "files": 64619, - "groups": { - "opt/cybexos-apps": { - "bytes": 485608784, - "files": 44858 - }, - "opt/cybexos-builds": { - "bytes": 12213964, - "files": 7 - }, - "usr/local/bin": { - "bytes": 0, - "files": 0 - }, - "usr/local/libexec": { - "bytes": 39020, - "files": 6 - }, - "usr/local/share/applications/omawrite.desktop": { - "bytes": 332, - "files": 1 - }, - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/final-seed": { - "bytes": 9455393, - "files": 14 - }, - "usr/share/cybexos/offline-inputs.json": { - "bytes": 112, - "files": 1 - }, - "usr/share/cybexos/seed-groups.json": { - "bytes": 27, - "files": 1 - }, - "usr/share/cybexos/user-seed": { - "bytes": 2847058243, - "files": 19548 - }, - "usr/share/icons/hicolor/scalable/apps/omawrite.svg": { - "bytes": 0, - "files": 0 - }, - "usr/share/licenses/cybexos-fonts": { - "bytes": 89851, - "files": 20 - } - }, - "inputs_sha256": "9867d5da2c6bf3db04cb597992103a83dcab5f199f3fd298c3221f8115eb5a5d", - "version": "1.0.0" - }, - "rust_docs": [], - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/user-seed": { - "bytes": 2847058243, - "files": 19548 - }, - "usr/share/cybexos/user-seed/.rustup": { - "bytes": 689595743, - "files": 175 - }, - "usr/share/fonts/cybexos": { - "bytes": 0, - "files": 0 - } - }, - "plugin_discovery": { - "measurement": "Actual user-plugins helper scans while Appearance settings remain open; first second excluded", - "runs": [ - { - "scans_after_first_second": 5, - "steady_window_seconds": 10, - "total_scans": 6, - "variant": "baseline" - }, - { - "scans_after_first_second": 0, - "steady_window_seconds": 10, - "total_scans": 1, - "variant": "optimized" - } - ] - }, - "qualification": { - "checkout_download_cache": { - "scope": "Verified RPM downloads only; normal metadata/checksum/signature checks run. The builder also reused this task-built compiler/Cargo cache. GitHub metadata used existing authenticated access after anonymous rate limiting. No complete-install/build speed claim.", - "status": "imported", - "verified_rpm_downloads": 2255 - }, - "checkout_installation": { - "applications_manifest_sha256": "2dacee3ab1d499194071b9de0c5491415ca503f3087f3f00369f460ae23d3dc4", - "checks": [ - "full-checkout-installation", - "captured-installed-outcomes-fresh", - "captured-installed-outcomes-saved", - "native-settings-lifecycle", - "saved-choice-reconfiguration", - "full-graphical-session", - "same-source-content", - "exact-final-application-contract", - "installed-parity-fresh", - "installed-parity-saved" - ], - "comparison_policy": { - "comparison_helper_sha256": "5ab5322b8b33ce14926b0482dfa633df6489f3d6e5daad8a3283286f39b3af78", - "policy_sha256": "382e0676809aadc531c0200051137fb736d5fc2ba44a3aa94e0728dd3eac7f95", - "reviewed_signing_key_evidence": "/data/cybexos-efficiency-20261015-yCv5AF/reports/brave-keyring-difference.json", - "scope": "Three exact Brave prerelease key identities added to the reviewed exceptions after capture. No changes to installed product inputs or capture data." - }, - "fedora_baseline": { - "installed_kernel_core": [ - "7.2.10-200.fc44.x86_64" - ], - "installed_packages": [ - "7zip.x86_64=26.04-1.fc44", - "NetworkManager-libnm.x86_64=1:1.56.1-2.fc44", - "NetworkManager.x86_64=1:1.56.1-2.fc44", - "aardvark-dns.x86_64=2:1.17.1-1.fc44", - "alternatives.x86_64=1.33-5.fc44", - "ansible-core.noarch=2.20.7-1.fc44", - "audit-libs.x86_64=4.2.1-1.fc44", - "audit-rules.x86_64=4.2.1-1.fc44", - "audit.x86_64=4.2.1-1.fc44", - "authselect-libs.x86_64=1.7.1-1.fc44", - "authselect.x86_64=1.7.1-1.fc44", - "bash-completion.noarch=1:2.17-2.fc44", - "bash.x86_64=5.3.9-3.fc44", - "btrfs-progs.x86_64=7.1-1.fc44", - "bzip2-libs.x86_64=1.0.8-23.fc44", - "c-ares.x86_64=1.34.8-1.fc44", - "ca-certificates.noarch=2026.2.90_v9.0.317-1.fc44", - "catatonit.x86_64=0.2.1-5.fc44", - "checkpolicy.x86_64=3.11-1.fc44", - "chrony.x86_64=4.9-1.fc44", - "composefs-libs.x86_64=1.0.8-5.fc44", - "conmon.x86_64=2:2.2.1-2.fc44", - "container-selinux.noarch=4:2.251.0-1.fc44", - "containers-common-extra.noarch=5:0.67.2-1.fc44", - "containers-common.noarch=5:0.67.2-1.fc44", - "coreutils-common.x86_64=9.10-5.fc44", - "coreutils.x86_64=9.10-5.fc44", - "cpio.x86_64=2.15-9.fc44", - "cracklib-dicts.x86_64=2.10.3-1.fc44", - "cracklib.x86_64=2.10.3-1.fc44", - "crun.x86_64=1.28-1.fc44", - "crypto-policies-scripts.noarch=20251128-3.git19878fe.fc44", - "crypto-policies.noarch=20251128-3.git19878fe.fc44", - "cryptsetup-libs.x86_64=2.8.8-1.fc44", - "curl.x86_64=8.18.0-14.fc44", - "cyrus-sasl-gssapi.x86_64=2.1.28-35.fc44", - "cyrus-sasl-lib.x86_64=2.1.28-35.fc44", - "dbus-broker.x86_64=37-8.fc44", - "dbus-common.noarch=1:1.16.2-1.fc44", - "dbus-libs.x86_64=1:1.16.2-1.fc44", - "dbus.x86_64=1:1.16.2-1.fc44", - "device-mapper-event-libs.x86_64=1.02.212-2.fc44", - "device-mapper-event.x86_64=1.02.212-2.fc44", - "device-mapper-libs.x86_64=1.02.212-2.fc44", - "device-mapper-persistent-data.x86_64=1.3.3-1.fc44", - "device-mapper.x86_64=1.02.212-2.fc44", - "diffutils.x86_64=3.12-5.fc44", - "dnf-data.noarch=4.24.0-3.fc44", - "dnf5-plugins.x86_64=5.4.6.0-1.fc44", - "dnf5.x86_64=5.4.6.0-1.fc44", - "dns-root-data.noarch=2026260100-2.fc44", - "dracut-config-generic.x86_64=108-8.fc44", - "dracut.x86_64=108-8.fc44", - "e2fsprogs-libs.x86_64=1.47.3-4.fc44", - "e2fsprogs.x86_64=1.47.3-4.fc44", - "efi-filesystem.noarch=6-6.fc44", - "efibootmgr.x86_64=18-11.fc44", - "efivar-libs.x86_64=39-12.fc44", - "elfutils-debuginfod-client.x86_64=0.196-1.fc44", - "elfutils-default-yama-scope.noarch=0.196-1.fc44", - "elfutils-libelf.x86_64=0.196-1.fc44", - "elfutils-libs.x86_64=0.196-1.fc44", - "expat.x86_64=2.8.5-1.fc44", - "fedora-gpg-keys.noarch=44-2", - "fedora-release-common.noarch=44-18", - "fedora-release-identity-basic.noarch=44-18", - "fedora-release.noarch=44-18", - "fedora-repos.noarch=44-2", - "file-libs.x86_64=5.46-10.fc44", - "file.x86_64=5.46-10.fc44", - "filesystem.x86_64=3.18-52.fc44", - "findutils.x86_64=1:4.10.0-7.fc44", - "firewalld-filesystem.noarch=2.4.4-1.fc44", - "firewalld.noarch=2.4.4-1.fc44", - "fmt.x86_64=11.2.0-4.fc44", - "freetype.x86_64=2.14.3-1.fc44", - "fuse-common.x86_64=3.18.3-1.fc44", - "fuse3-libs.x86_64=3.18.3-1.fc44", - "fuse3.x86_64=3.18.3-1.fc44", - "gawk.x86_64=5.3.2-3.fc44", - "gdbm-libs.x86_64=1:1.23-11.fc44", - "gdbm.x86_64=1:1.23-11.fc44", - "gettext-envsubst.x86_64=0.26-5.fc44", - "gettext-libs.x86_64=0.26-5.fc44", - "gettext-runtime.x86_64=0.26-5.fc44", - "git-core-doc.noarch=2.56.0-1.fc44", - "git-core.x86_64=2.56.0-1.fc44", - "git.x86_64=2.56.0-1.fc44", - "glib2.x86_64=2.88.4-1.fc44", - "glibc-all-langpacks.x86_64=2.43-9.fc44", - "glibc-common.x86_64=2.43-9.fc44", - "glibc-gconv-extra.x86_64=2.43-9.fc44", - "glibc.x86_64=2.43-9.fc44", - "gmp.x86_64=1:6.3.0-5.fc44", - "gnulib-l10n.noarch=20241231-2.fc44", - "gnupg2-dirmngr.x86_64=2.4.9-16.fc44", - "gnupg2-gpg-agent.x86_64=2.4.9-16.fc44", - "gnupg2-gpgconf.x86_64=2.4.9-16.fc44", - "gnupg2-keyboxd.x86_64=2.4.9-16.fc44", - "gnupg2-verify.x86_64=2.4.9-16.fc44", - "gnupg2.x86_64=2.4.9-16.fc44", - "gnutls.x86_64=3.8.13-1.fc44", - "gpg-pubkey.(none)=36f612dcf27f7d1a48a835e4dbfcf71c6d9f90a6-6786af3b", - "gpgme.x86_64=2.0.1-5.fc44", - "graphite2.x86_64=1.3.14-20.fc44", - "grep.x86_64=3.12-3.fc44", - "groff-base.x86_64=1.23.0-12.fc44", - "grub2-common.noarch=1:2.12-66.fc44", - "grub2-efi-ia32.x86_64=1:2.12-66.fc44", - "grub2-efi-x64.x86_64=1:2.12-66.fc44", - "grub2-pc-modules.noarch=1:2.12-66.fc44", - "grub2-pc.x86_64=1:2.12-66.fc44", - "grub2-tools-efi.x86_64=1:2.12-66.fc44", - "grub2-tools-extra.x86_64=1:2.12-66.fc44", - "grub2-tools-minimal.x86_64=1:2.12-66.fc44", - "grub2-tools.x86_64=1:2.12-66.fc44", - "grubby.x86_64=8.40-88.fc44", - "gzip.x86_64=1.14-2.fc44", - "harfbuzz.x86_64=14.1.0-2.fc44", - "hiredis.x86_64=1.2.0-8.fc44", - "hostname.x86_64=3.25-4.fc44", - "ima-evm-utils-libs.x86_64=1.6.2-8.fc44", - "inih.x86_64=62-2.fc44", - "iproute.x86_64=6.17.0-2.fc44", - "iptables-libs.x86_64=1.8.11-13.fc44", - "iputils.x86_64=20250605-2.fc44", - "jansson.x86_64=2.14-4.fc44", - "json-c.x86_64=0.18-8.fc44", - "kbd-legacy.noarch=2.9.0-4.fc44", - "kbd-misc.noarch=2.9.0-4.fc44", - "kbd.x86_64=2.9.0-4.fc44", - "kernel-core.x86_64=7.2.10-200.fc44", - "kernel-modules-core.x86_64=7.2.10-200.fc44", - "keyutils-libs.x86_64=1.6.3-7.fc44", - "kmod-libs.x86_64=34.2-4.fc44", - "kmod.x86_64=34.2-4.fc44", - "krb5-libs.x86_64=1.22.2-4.fc44", - "less.x86_64=704-4.fc44", - "libacl.x86_64=2.4.0-1.fc44", - "libaio.x86_64=0.3.111-23.fc44", - "libarchive.x86_64=3.8.7-1.fc44", - "libassuan.x86_64=2.5.7-5.fc44", - "libattr.x86_64=2.6.0-1.fc44", - "libbasicobjects.x86_64=0.1.1-61.fc44", - "libblkid.x86_64=2.41.5-1.fc44", - "libbpf.x86_64=2:1.6.3-2.fc44", - "libbrotli.x86_64=1.2.0-3.fc44", - "libcap-ng.x86_64=0.9.6-1.fc44", - "libcap.x86_64=2.78-1.fc44", - "libcbor.x86_64=0.13.0-2.fc44", - "libcollection.x86_64=0.7.0-61.fc44", - "libcom_err.x86_64=1.47.3-4.fc44", - "libcomps.x86_64=0.1.24-1.fc44", - "libcurl.x86_64=8.18.0-14.fc44", - "libdhash.x86_64=0.5.0-61.fc44", - "libdnf.x86_64=0.76.0-1.fc44", - "libdnf5-cli.x86_64=5.4.6.0-1.fc44", - "libdnf5.x86_64=5.4.6.0-1.fc44", - "libeconf.x86_64=0.7.9-3.fc44", - "libedit.x86_64=3.1-59.20260512cvs.fc44", - "libevent.x86_64=2.1.13-1.fc44", - "libfdisk.x86_64=2.41.5-1.fc44", - "libffi.x86_64=3.5.2-2.fc44", - "libfido2.x86_64=1.16.0-5.fc44", - "libfsverity.x86_64=1.6-4.fc44", - "libgcc.x86_64=16.2.1-2.fc44", - "libgcrypt.x86_64=1.12.2-1.fc44", - "libgomp.x86_64=16.2.1-2.fc44", - "libgpg-error.x86_64=1.58-2.fc44", - "libicu.x86_64=77.1-3.fc44", - "libidn2.x86_64=2.3.8-3.fc44", - "libini_config.x86_64=1.3.1-61.fc44", - "libkcapi-hasher.x86_64=1.5.0-7.fc44", - "libkcapi-hmaccalc.x86_64=1.5.0-7.fc44", - "libkcapi.x86_64=1.5.0-7.fc44", - "libksba.x86_64=1.6.7-5.fc44", - "liblastlog2.x86_64=2.41.5-1.fc44", - "libldb.x86_64=2:4.24.7-1.fc44", - "libmnl.x86_64=1.0.5-9.fc44", - "libmodulemd.x86_64=2.15.3-1.fc44", - "libmount.x86_64=2.41.5-1.fc44", - "libndp.x86_64=1.9-5.fc44", - "libnetfilter_conntrack.x86_64=1.1.1-1.fc44", - "libnfnetlink.x86_64=1.0.1-32.fc44", - "libnftnl.x86_64=1.3.1-2.fc44", - "libnghttp2.x86_64=1.68.0-5.fc44", - "libnghttp3.x86_64=1.18.0-1.fc44", - "libnl3.x86_64=3.12.0-3.fc44", - "libnsl2.x86_64=2.0.1-5.fc44", - "libnvme.x86_64=1.16.2-1.fc44", - "libpath_utils.x86_64=0.2.1-61.fc44", - "libpipeline.x86_64=1.5.8-4.fc44", - "libpkgconf.x86_64=2.5.1-1.fc44", - "libpng.x86_64=2:1.6.58-1.fc44", - "libpsl.x86_64=0.21.5-7.fc44", - "libpwquality.x86_64=1.4.5-15.fc44", - "libref_array.x86_64=0.1.5-61.fc44", - "librepo.x86_64=1.21.2-1.fc44", - "libseccomp.x86_64=2.6.1-2.fc44", - "libsecret.x86_64=0.21.8.2-2.fc44", - "libselinux-utils.x86_64=3.11-2.fc44", - "libselinux.x86_64=3.11-2.fc44", - "libsemanage.x86_64=3.11-1.fc44", - "libsepol.x86_64=3.11-1.fc44", - "libsmartcols.x86_64=2.41.5-1.fc44", - "libsolv.x86_64=0.7.40-1.fc44", - "libss.x86_64=1.47.3-4.fc44", - "libssh-config.noarch=0.12.2-1.fc44", - "libssh.x86_64=0.12.2-1.fc44", - "libsss_certmap.x86_64=2.13.1-2.fc44", - "libsss_idmap.x86_64=2.13.1-2.fc44", - "libsss_nss_idmap.x86_64=2.13.1-2.fc44", - "libsss_sudo.x86_64=2.13.1-2.fc44", - "libstdc++.x86_64=16.2.1-2.fc44", - "libtalloc.x86_64=2.4.4-1.fc44", - "libtasn1.x86_64=4.21.0-1.fc44", - "libtdb.x86_64=1.4.15-1.fc44", - "libtevent.x86_64=0.17.1-4.fc44", - "libtextstyle.x86_64=0.26-5.fc44", - "libtirpc.x86_64=1.3.8-0.fc44", - "libtool-ltdl.x86_64=2.5.4-10.fc44", - "libunistring.x86_64=1.1-11.fc44", - "liburing.x86_64=2.13-2.fc44", - "libusb1.x86_64=1.0.30-1.fc44", - "libuuid.x86_64=2.41.5-1.fc44", - "libverto.x86_64=0.3.2-12.fc44", - "libxcrypt.x86_64=4.5.2-3.fc44", - "libxkbcommon.x86_64=1.13.1-2.fc44", - "libxml2.x86_64=2.12.10-6.fc44", - "libyaml.x86_64=0.2.5-18.fc44", - "libzstd.x86_64=1.5.7-5.fc44", - "lmdb-libs.x86_64=0.9.34-2.fc44", - "lua-libs.x86_64=5.4.8-5.fc44", - "lvm2-libs.x86_64=2.03.38-2.fc44", - "lvm2.x86_64=2.03.38-2.fc44", - "lz4-libs.x86_64=1.10.0-4.fc44", - "lzo.x86_64=2.10-16.fc44", - "man-db.x86_64=2.13.1-3.fc44", - "mokutil.x86_64=2:0.7.2-3.fc44", - "mpdecimal.x86_64=4.0.1-3.fc44", - "mpfr.x86_64=4.2.2-3.fc44", - "mtools.x86_64=4.0.49-3.fc44", - "nano.x86_64=8.7.1-2.fc44", - "ncurses-base.noarch=6.6-1.fc44", - "ncurses-libs.x86_64=6.6-1.fc44", - "ncurses.x86_64=6.6-1.fc44", - "netavark.x86_64=2:1.17.2-1.fc44", - "nettle.x86_64=3.10.1-3.fc44", - "nftables-services.noarch=1:1.1.6-2.fc44", - "nftables.x86_64=1:1.1.6-2.fc44", - "ngtcp2-crypto-ossl.x86_64=1.22.1-1.fc44", - "ngtcp2.x86_64=1.22.1-1.fc44", - "npth.x86_64=1.8-4.fc44", - "numactl-libs.x86_64=2.0.19-4.fc44", - "openldap.x86_64=2.6.15-1.fc44", - "openssh-clients.x86_64=10.2p1-14.fc44", - "openssh-server.x86_64=10.2p1-14.fc44", - "openssh.x86_64=10.2p1-14.fc44", - "openssl-libs.x86_64=1:3.5.9-1.fc44", - "openssl.x86_64=1:3.5.9-1.fc44", - "os-prober.x86_64=1.81-11.fc44", - "p11-kit-trust.x86_64=0.26.5-1.fc44", - "p11-kit.x86_64=0.26.5-1.fc44", - "pam-libs.x86_64=1.7.2-2.fc44", - "pam.x86_64=1.7.2-2.fc44", - "parted.x86_64=3.6-15.fc44", - "passt-selinux.noarch=0^20261002.gcba3570-1.fc44", - "passt.x86_64=0^20261002.gcba3570-1.fc44", - "pcre2-syntax.noarch=10.47-1.fc44.1", - "pcre2.x86_64=10.47-1.fc44.1", - "perl-AutoLoader.noarch=0:5.74-525.fc44", - "perl-B.x86_64=0:1.89-525.fc44", - "perl-Carp.noarch=1.54-521.fc44", - "perl-Class-Struct.noarch=0:0.68-525.fc44", - "perl-Data-Dumper.x86_64=2.192-523.fc44", - "perl-Digest-MD5.x86_64=2.59-521.fc44", - "perl-Digest.noarch=1.20-521.fc44", - "perl-DynaLoader.x86_64=0:1.57-525.fc44", - "perl-Encode.x86_64=4:3.21-521.fc44", - "perl-Errno.x86_64=0:1.38-525.fc44", - "perl-Error.noarch=1:0.17030-3.fc44", - "perl-Exporter.noarch=5.79-521.fc44", - "perl-Fcntl.x86_64=0:1.20-525.fc44", - "perl-File-Basename.noarch=0:2.86-525.fc44", - "perl-File-Path.noarch=2.18-522.fc44", - "perl-File-Temp.noarch=1:0.231.200-2.fc44", - "perl-File-stat.noarch=0:1.14-525.fc44", - "perl-FileHandle.noarch=0:2.05-525.fc44", - "perl-Getopt-Long.noarch=1:2.58-521.fc44", - "perl-Getopt-Std.noarch=0:1.14-525.fc44", - "perl-Git.noarch=2.56.0-1.fc44", - "perl-HTTP-Tiny.noarch=0.096-1.fc44", - "perl-IO-Socket-IP.noarch=0.43-522.fc44", - "perl-IO-Socket-SSL.noarch=2.098-2.fc44", - "perl-IO.x86_64=0:1.55-525.fc44", - "perl-IPC-Open3.noarch=0:1.24-525.fc44", - "perl-MIME-Base32.noarch=1.303-25.fc44", - "perl-MIME-Base64.x86_64=3.16-521.fc44", - "perl-Net-SSLeay.x86_64=1.94-12.fc44", - "perl-POSIX.x86_64=0:2.23-525.fc44", - "perl-PathTools.x86_64=3.94-521.fc44", - "perl-Pod-Escapes.noarch=1:1.07-521.fc44", - "perl-Pod-Perldoc.noarch=3.28.01-522.fc44", - "perl-Pod-Simple.noarch=1:3.47-4.fc44", - "perl-Pod-Usage.noarch=4:2.05-521.fc44", - "perl-Scalar-List-Utils.x86_64=5:1.70-2.fc44", - "perl-SelectSaver.noarch=0:1.02-525.fc44", - "perl-Socket.x86_64=4:2.043-1.fc44", - "perl-Storable.x86_64=1:3.37-522.fc44", - "perl-Symbol.noarch=0:1.09-525.fc44", - "perl-Term-ANSIColor.noarch=5.01-522.fc44", - "perl-Term-Cap.noarch=1.18-521.fc44", - "perl-TermReadKey.x86_64=2.38-27.fc44", - "perl-Text-ParseWords.noarch=3.31-521.fc44", - "perl-Text-Tabs+Wrap.noarch=2024.001-521.fc44", - "perl-Time-HiRes.x86_64=4:1.9778-521.fc44", - "perl-Time-Local.noarch=2:1.350-521.fc44", - "perl-URI.noarch=5.36-1.fc44", - "perl-Unicode-Normalize.x86_64=1.32-521.fc44", - "perl-base.noarch=0:2.27-525.fc44", - "perl-constant.noarch=1.33-522.fc44", - "perl-if.noarch=0:0.61.000-525.fc44", - "perl-interpreter.x86_64=4:5.42.3-525.fc44", - "perl-lib.x86_64=0:0.65-525.fc44", - "perl-libnet.noarch=3.15-522.fc44", - "perl-libs.x86_64=4:5.42.3-525.fc44", - "perl-locale.noarch=0:1.13-525.fc44", - "perl-mro.x86_64=0:1.29-525.fc44", - "perl-overload.noarch=0:1.40-525.fc44", - "perl-overloading.noarch=0:0.02-525.fc44", - "perl-parent.noarch=1:0.244-521.fc44", - "perl-podlators.noarch=1:6.0.2-522.fc44", - "perl-vars.noarch=0:1.05-525.fc44", - "pinentry.x86_64=1.3.2-3.fc44", - "pkgconf-m4.noarch=2.5.1-1.fc44", - "pkgconf-pkg-config.x86_64=2.5.1-1.fc44", - "pkgconf.x86_64=2.5.1-1.fc44", - "podman-sequoia.x86_64=0.3.2-2.fc44", - "podman.x86_64=5:5.8.7-1.fc44", - "policycoreutils.x86_64=3.11-2.fc44", - "popt.x86_64=1.19-10.fc44", - "prefixdevname.x86_64=0.2.0-8.fc44", - "procps-ng.x86_64=4.0.6-1.fc44", - "protobuf-c.x86_64=1.5.2-2.fc44", - "psmisc.x86_64=23.7-7.fc44", - "publicsuffix-list-dafsa.noarch=20260116-1.fc44", - "python-pip-wheel.noarch=26.0.1-3.fc44", - "python3-argcomplete.noarch=3.6.3-4.fc44", - "python3-attrs.noarch=25.4.0-2.fc44", - "python3-audit.x86_64=4.2.1-1.fc44", - "python3-cffi.x86_64=2.0.0-3.fc44", - "python3-charset-normalizer.noarch=3.4.4-2.fc44", - "python3-cryptography.x86_64=50.0.0-1.fc44", - "python3-dbus.x86_64=1.4.0-9.fc44", - "python3-distro.noarch=1.9.0-11.fc44", - "python3-dnf.noarch=4.24.0-3.fc44", - "python3-firewall.noarch=2.4.4-1.fc44", - "python3-gobject-base.x86_64=3.56.3-1.fc44", - "python3-hawkey.x86_64=0.76.0-1.fc44", - "python3-idna.noarch=3.18-1.fc44", - "python3-jinja2.noarch=3.1.6-7.fc44", - "python3-jsonschema-specifications.noarch=2024.10.1-7.fc44", - "python3-jsonschema.noarch=4.23.0-7.fc44", - "python3-libcomps.x86_64=0.1.24-1.fc44", - "python3-libdnf.x86_64=0.76.0-1.fc44", - "python3-libdnf5.x86_64=5.4.6.0-1.fc44", - "python3-libs.x86_64=3.14.8-1.fc44", - "python3-libselinux.x86_64=3.11-2.fc44", - "python3-libsemanage.x86_64=3.11-1.fc44", - "python3-markupsafe.x86_64=3.0.2-7.fc44", - "python3-nftables.noarch=1:1.1.6-2.fc44", - "python3-packaging.noarch=25.0-8.fc44", - "python3-policycoreutils.noarch=3.11-2.fc44", - "python3-psutil.x86_64=7.2.2-1.fc44", - "python3-pycparser.noarch=2.22-8.fc44", - "python3-pyyaml.x86_64=6.0.3-3.fc44", - "python3-referencing.noarch=0.36.2-6.fc44", - "python3-requests.noarch=2.33.1-1.fc44", - "python3-resolvelib.noarch=1.0.1-12.fc44", - "python3-rpds-py.x86_64=0.29.0-6.fc44", - "python3-rpm.x86_64=6.0.2-1.fc44", - "python3-setools.x86_64=4.6.0-6.fc44", - "python3-six.noarch=1.17.0-8.fc44", - "python3-urllib3.noarch=2.8.0-1.fc44", - "python3.x86_64=3.14.8-1.fc44", - "qrencode-libs.x86_64=4.1.1-12.fc44", - "readline.x86_64=8.3-4.fc44", - "rootfiles.noarch=9.0-6.fc44", - "rpm-build-libs.x86_64=6.0.2-1.fc44", - "rpm-libs.x86_64=6.0.2-1.fc44", - "rpm-plugin-selinux.x86_64=6.0.2-1.fc44", - "rpm-sequoia.x86_64=1.10.3-1.fc44", - "rpm-sign-libs.x86_64=6.0.2-1.fc44", - "rpm.x86_64=6.0.2-1.fc44", - "rsync.x86_64=3.5.1-1.fc44", - "samba-common.noarch=2:4.24.7-1.fc44", - "samba-core-libs.x86_64=2:4.24.7-1.fc44", - "samba-ndr-libs.x86_64=2:4.24.7-1.fc44", - "sdbus-cpp.x86_64=2.2.1-2.fc44", - "sed.x86_64=4.9-7.fc44", - "selinux-policy-targeted.noarch=44.11-1.fc44", - "selinux-policy.noarch=44.11-1.fc44", - "setup.noarch=2.15.0-28.fc44", - "shadow-utils-subid.x86_64=2:4.19.0-7.fc44", - "shadow-utils.x86_64=2:4.19.0-7.fc44", - "shim-ia32.x86_64=16.1-7", - "shim-x64.x86_64=16.1-7", - "sqlite-libs.x86_64=3.51.2-2.fc44", - "sssd-client.x86_64=2.13.1-2.fc44", - "sssd-common.x86_64=2.13.1-2.fc44", - "sssd-kcm.x86_64=2.13.1-2.fc44", - "sssd-krb5-common.x86_64=2.13.1-2.fc44", - "sudo.x86_64=1.9.17-10.p2.fc44", - "systemd-libs.x86_64=259.9-1.fc44", - "systemd-oomd-defaults.noarch=259.9-1.fc44", - "systemd-pam.x86_64=259.9-1.fc44", - "systemd-resolved.x86_64=259.9-1.fc44", - "systemd-shared.x86_64=259.9-1.fc44", - "systemd-sysusers.x86_64=259.9-1.fc44", - "systemd-udev.x86_64=259.9-1.fc44", - "systemd.x86_64=259.9-1.fc44", - "tar.x86_64=2:1.35-9.fc44", - "tpm2-tss.x86_64=4.1.3-9.fc44", - "tzdata.noarch=2026e-1.fc44", - "unbound-libs.x86_64=1.26.1-1.fc44", - "unzip.x86_64=6.0-69.fc44", - "userspace-rcu.x86_64=0.15.6-1.fc44", - "util-linux-core.x86_64=2.41.5-1.fc44", - "util-linux.x86_64=2.41.5-1.fc44", - "vim-data.noarch=2:9.2.1129-1.fc44", - "vim-minimal.x86_64=2:9.2.1129-1.fc44", - "which.x86_64=2.25-1.fc44", - "whois-nls.noarch=5.6.6-1.fc44", - "xfsprogs.x86_64=7.1.1-1.fc44", - "xkeyboard-config.noarch=2.47-1.fc44", - "xxhash-libs.x86_64=0.8.4-1.fc44", - "xz-libs.x86_64=1:5.8.2-2.fc44", - "xz.x86_64=1:5.8.2-2.fc44", - "yajl.x86_64=2.1.0-40.fc44", - "zchunk-libs.x86_64=1.5.4-1.fc44", - "zlib-ng-compat.x86_64=2.3.3-3.fc44", - "zram-generator-defaults.noarch=1.2.1-5.fc44", - "zram-generator.x86_64=1.2.1-5.fc44", - "zstd.x86_64=1.5.7-5.fc44" - ], - "running_kernel": "7.2.10-200.fc44.x86_64", - "scope": "Disposable Fedora OS preparation before the public CybexOS installer; no product payload imported" - }, - "fedora_cloud_sha256": "28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f", - "iso_sha256": "fea4ac94242869d7e35f528e5fd40549cce87981ece02ce95d594d2901fb19e2", - "outcome_captures": { - "fresh": { - "checkout_capture_sha256": "21042e71dc83eb536f34ad9f0c90f077ec024ddb2875e2f18b0ba7c81ae41bee", - "iso_capture_sha256": "00c39ab95eebfb34357c783609e568ba082144f5dfb50e4ad0c1dc2451569945" - }, - "saved": { - "checkout_capture_sha256": "358db8dee1b457e7535d06903aefde4cf60baea7ed0334e0289aad0bdbb9a5ce", - "iso_capture_sha256": "f443473fbe278a68c7a4e89c8c469d89e261df0819266274d712f9c8b083ef61" - } - }, - "report": "/data/cybexos-efficiency-20261015-yCv5AF/qual-checkout-qualified-r6/checkout-qualification.json", - "scenario": "plain-us", - "scope": "Actual public checkout installation and saved-choice reconfiguration on pinned Fedora Cloud, compared with real fresh and saved installations of the exact matching ISO. Every package/version delta is explicit and reasoned; unknown signing keys fail. QEMU, not physical hardware or Secure Boot.", - "source_archive_sha256": "737c1b826cbc38a1e10acf42b6c89d192c559c7d4dddf5a00ec467847903f501", - "source_content_sha256": "09120eb1c604e1c178a6c27b89894bb2d58746a5bf8798f733728cec2085441d", - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "status": "passed" - }, - "encrypted_upgrade_recovery": { - "boot_entry": "install", - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_offline_rpm_sha256": "a2efda36c8a3e02b95048e41c4e2e7a16ec3341612c6274f7c73028c887d186c", - "candidate_rpm_sha256": "ee019fe04267802824b4123e5038f06e2bd4d6e61530add62362c109817db577", - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "installed-rpm-upgrade", - "recovery-boot-restore" - ], - "delivered_candidate_rpms_match": true, - "firmware": "uefi", - "iso_sha256": "d506b10555b2c422eae181c09c106e495db5551b9317eeb975c56669a01961a2", - "network": "outbound-blocked", - "report": "/data/cybexos-efficiency-20261015-yCv5AF/qual-qualified-r6-upgrade/qualification.json", - "scenario": "encrypted-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "status": "passed" - }, - "final_web_service": { - "health": "healthy", - "image": "sha256:6ed4a27a6161daf0f2aacaa6ce9b8c89f0f566dc30f0fc269c0b55a017903712", - "image_bytes": 104205776, - "rebuild_restart": "completed", - "responses": [ - { - "bytes": 20588, - "path": "/", - "status": 200 - }, - { - "bytes": 90336, - "path": "/wallpapers/moon-mountains-1280.webp", - "status": 200 - }, - { - "bytes": 250438, - "path": "/wallpapers/moon-mountains-2560.webp", - "status": 200 - }, - { - "bytes": 678532, - "path": "/wallpapers/moon-mountains-5120.webp", - "status": 200 - } - ], - "status": "passed" - }, - "image_source_gate": "passed", - "image_unit_tests": { - "run": 518, - "skipped": 2, - "status": "passed" - }, - "initial_checkout_attempt": { - "checks": [ - "full-checkout-installation" - ], - "error": "Installed outcome capture failed: Traceback (most recent call last):\n File \"\", line 234, in \n File \"\", line 223, in main\n File \"\", line 87, in capture\n File \"\", line 43, in command\nRuntimeError: Installed outcome read failed: rpm -q --qf \u2026 alsa-sof-firmware\n", - "fedora_cloud_sha256": "28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f", - "scenario": "plain-us", - "scope": "Full checkout installation and graphical session on disposable QEMU Fedora Cloud; no physical hardware qualification", - "source_archive_sha256": "83ce60619c2dbe6577043b83cdbfa94d24d2593abdddbae4fa9bfa671f9211d2", - "source_content_sha256": "b41f64ba782ece5db5144bb29fdb776db9038bcde80d6cfd1a7aca9e87c1054e", - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "status": "failed" - }, - "installer_javascript_tests": { - "run": 14, - "status": "passed" - }, - "new_iso_plain_us": { - "boot_entry": "install", - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_offline_rpm_sha256": null, - "candidate_rpm_sha256": null, - "checks": [ - "installation-only-session", - "graphical-installer", - "plain-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "plain-btrfs", - "password-login", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password", - "installed-outcomes-captured", - "saved-choice-reconfiguration", - "native-settings-lifecycle" - ], - "firmware": "uefi", - "iso_sha256": "fea4ac94242869d7e35f528e5fd40549cce87981ece02ce95d594d2901fb19e2", - "network": "outbound-blocked", - "report": "/data/cybexos-efficiency-20261015-yCv5AF/qual-qualified-r6-plain-us/qualification.json", - "scenario": "plain-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_content_sha256": "09120eb1c604e1c178a6c27b89894bb2d58746a5bf8798f733728cec2085441d", - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "status": "passed" - }, - "optimized_artifacts": { - "checksums": "passed", - "iventoy_refresh_list_running_service": "passed", - "payload_credential_audit": { - "build_id": "20261015T183818Z-25fb4f79", - "matching_paths": [], - "regular_files_scanned": 65443, - "scope": "Exact existing GitHub credential bytes absent from assembled product payload; credential value never logged", - "status": "passed" - }, - "pxe_publication": "passed", - "sbom_iso_and_both_rpms_binding": "passed" - }, - "parity_correction": { - "changes": [ - "Reuse the ISO firmware/media baseline on generic hardware", - "Read ISO system selections, exclusions and desktop runtime requirements for checkout", - "Disable weak dependencies in Ansible and prerequisite/upstream RPM commands", - "Use the shared complete locale baseline", - "Resolve final codec providers before base/desktop transactions, avoiding nine orphaned dependencies", - "Select the ISO OpenCL dispatch provider in the first base transaction", - "Select graphical boot in shared policy; qualification asserts the installed result" - ], - "fixture_preparation": "Only the owned Fedora Cloud guest is updated to the current OS baseline, stripped of its explicitly reviewed preexisting Cloud packages and rebooted onto its current kernel before the public installer. Preparation is recorded in the checkout report.", - "rebuild": "20261015T183818Z-25fb4f79", - "regression": "Actual Ansible selection and boot-link fixtures, shared package reader tests, strict fresh/saved installed outcomes", - "status": "passed" - }, - "qml_lazy_popup": "passed actual headless Wayland lifecycle: creation, focus, retained drafts and view switching", - "resolved_harness_issues": [ - "Legacy desktop ISO boot did not start Anaconda after the installer-only default was added. Qualification now starts it when absent and preserves an already running installer; two regression cases pass.", - "Installed-console bootstrap now retries a visibly empty login prompt at most three times if the initial username input is lost during a VT switch; tests verify recovery and failure without sending a secret.", - "The network-blocked saved-choice ISO fixture invoked online reconfiguration, which attempted Fedora metadata downloads. It now uses the supported --offline policy mode; regression tests cover offline invocation and bounded redacted diagnostics.", - "Concurrent installer initialization can briefly lock keyboard selection. The browser driver now retries only the explicit busy response, with bounded attempts/deadline; four JavaScript regression cases cover recovery, exhaustion, other errors and an expired deadline." - ], - "reviewed_brave_keyring_difference": { - "comparison_policy_sha256": "382e0676809aadc531c0200051137fb736d5fc2ba44a3aa94e0728dd3eac7f95", - "decision": "Accept only these three full fingerprint/release identities as explained upstream-scriptlet differences. Preserve existing trust state and identical signed package payloads; unrecognized keys still fail. This does not enable any prerelease repository.", - "exact_checkout_only_rpm_identities": [ - "gpg-pubkey.(none)=56f49901ab19baf099a95a76c3de1dd4f661cdcb-63ab09ad", - "gpg-pubkey.(none)=8c1f16ab24df8f75c1cf56595929a141e0e87f1f-67d85d48", - "gpg-pubkey.(none)=b721e073b7ef8e56acc6b23ecbc67d2399225ccf-68908a61" - ], - "installed_product_payload_changed": false, - "iso_initial_chroot_evidence": "Running scriptlet: brave-keyring-1.19-1.noarch; /var/tmp/rpm-tmp.C7pbw1: line 1: timeout: command not found", - "package": "brave-keyring-1.19-1.noarch", - "package_prerelease_key_bytes_match_upstream_bundle": true, - "packaged_prerelease_keys_sha256": "1a96d8e20402915a6901faed67c4765250b5355742b3b3c9cea50837fd98701e", - "pinned_release_key_bundle_sha256": "bf978bd03e49f6006568d99f620763bf43a6455f6209dff791f52ebc6239f738", - "rpm_sha256": "f416dcf7f3c0c6c26c2c3c915e733ba2a93ae0cf21686b1bfc18fcae8907256b", - "signature_verification": "/data/cybexos-efficiency-20261015-yCv5AF/key-review/brave-keyring-1.19-1.noarch.rpm:\n Header V4 RSA/SHA512 Signature, key ID 20038257: OK\n Header SHA256 digest: OK\n Header SHA1 digest: OK\n Payload SHA256 digest: OK\n MD5 digest: OK", - "status": "reviewed", - "upstream_importer": "Imports every /etc/pki/rpm-gpg/RPM-GPG-KEY-brave* file; stable and prerelease key files are shipped by the same signed package.", - "upstream_postinstall": "timeout 10m sh -c \"while ! sh /usr/libexec/brave-key-updater >/dev/null 2>&1; do sleep 2; done\" &", - "verified_signer_fingerprint": "DBF1A116C220B8C7164F98230686B78420038257" - }, - "source_gate": "passed", - "source_snapshot": { - "build_id": "20261015T183818Z-25fb4f79", - "installed_product_unchanged_since_snapshot": true, - "post_snapshot_changes": [ - "docs/efficiency-review-2026-10-15.md", - "docs/installation-parity.md", - "docs/measurements/efficiency-2026-10-15.json", - "image/installed_outcomes.py", - "image/parity-exceptions.json", - "image/test_installed_outcomes.py" - ], - "source_content_sha256": "09120eb1c604e1c178a6c27b89894bb2d58746a5bf8798f733728cec2085441d", - "source_dirty": true, - "source_epoch": 1792063093, - "source_file_count": 1375, - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "subsequent_changes": "Documentation and qualification comparison tooling only; full source/image gates pass. Installed product inputs are unchanged; checkout installation uses the exact archived source. Comparison accepts only three explicitly reviewed Brave key identities and rejects family-wide key exceptions.", - "utc": "2026-10-15T18:38:19.004651+00:00" - }, - "source_unit_tests": { - "run": 1369, - "status": "passed" - }, - "status": "passed", - "upgrade_baseline": { - "iso": "CybexOS-Live-44-20261014T075500Z-8f671e4e.iso", - "scope": "Retained supported prior ISO; current split-RPM upgrade/recovery is tested again, not inferred from its old report.", - "sha256": "d506b10555b2c422eae181c09c106e495db5551b9317eeb975c56669a01961a2", - "source_revision": "b0a97b7256de3dca989b8a25b410ddd9c3f683dd" - }, - "web": "116 component tests, typecheck, lint, build and 2 Worker/asset tests passed; service rebuilt and healthy" - }, - "quickshell_two_output": { - "measurement": "Three alternating runs of two widget instances under headless Sway, fake quota process, software rendering. Ready signal measures QML construction, not complete desktop startup; RSS sampled after two seconds", - "runs": [ - { - "Pss": 92295168, - "Rss": 97222656, - "run": 1, - "startup_seconds": 0.4821630041114986, - "variant": "baseline" - }, - { - "Pss": 76975104, - "Rss": 80986112, - "run": 1, - "startup_seconds": 0.40476424200460315, - "variant": "optimized" - }, - { - "Pss": 77203456, - "Rss": 81158144, - "run": 2, - "startup_seconds": 0.40649795602075756, - "variant": "optimized" - }, - { - "Pss": 92192768, - "Rss": 97157120, - "run": 2, - "startup_seconds": 0.4617449170909822, - "variant": "baseline" - }, - { - "Pss": 92254208, - "Rss": 97222656, - "run": 3, - "startup_seconds": 0.4403403699398041, - "variant": "baseline" - }, - { - "Pss": 76684288, - "Rss": 80719872, - "run": 3, - "startup_seconds": 0.42707683402113616, - "variant": "optimized" - } - ] - }, - "reviewer": { - "model": "claude-fable-5-1", - "requested_effort": "max", - "scope": "Read-only verification of the initial audit; not a review of the final implementation" - }, - "rpm_payload_transaction": { - "duplicate_regular_file_owners": 0, - "monolithic_to_split_transaction": "passed (no scriptlets; not a boot/install qualification)", - "packages": { - "cybexos-desktop": { - "bytes": 29824379, - "files": 935 - }, - "cybexos-offline-apps": { - "bytes": 1641343352, - "files": 75284 - } - }, - "regular_files_covered": 65548, - "status": "passed" - }, - "rpms": { - "after_desktop_bytes": 29824379, - "after_offline_bytes": 1641343352, - "before_monolithic_bytes": 1739588415, - "measurement": "Actual same-base-revision RPM builds; total decrease 68,420,684 bytes. Shell update savings require reusing an unchanged offline package identity." - }, - "rust_docs": { - "archive_bytes": 24363664, - "files": 65926, - "installed_bytes": 739153743, - "seed_filesystem": "ZFS on thebeast; isolated documentation copy, not a complete installed login", - "seed_seconds": 8.577959778020158, - "sha256": "eef31ef06ee05903f3962a4861e38de8f2cbdfd023b057275d308e64d311c522", - "unpack_seconds": 9.30019044619985, - "url": "https://static.rust-lang.org/dist/2026-08-23/rust-docs-nightly-x86_64-unknown-linux-gnu.tar.xz" - }, - "sound": { - "measurement": "Real isolated PipeWire-Pulse, QML backend and pactl; first second excluded; real volume event at 5 seconds", - "runs": [ - { - "all_pactl_execs": 94, - "event_pactl_execs": 43, - "steady_pactl_execs": 40, - "steady_seconds": 4.000614839140326, - "variant": "baseline" - }, - { - "all_pactl_execs": 11, - "event_pactl_execs": 5, - "steady_pactl_execs": 0, - "steady_seconds": 4.000354326795787, - "variant": "optimized" - } - ] - }, - "source_build": { - "application": "Wayfreeze 0.2.1", - "initial_builder_setup_seconds": 84.725, - "measurement": "Forced same-source rebuild; baseline reinstalls DNF compilers each time; optimized reuses builder and Cargo caches; network variability included", - "revision": "59ac913ebb71920992c52dc1002535b733994397", - "runs": [ - { - "attempt": 1, - "exit": 0, - "seconds": 295.25670170318335, - "variant": "baseline" - }, - { - "attempt": 2, - "exit": 0, - "seconds": 97.66321819508448, - "variant": "baseline" - }, - { - "attempt": 3, - "exit": 0, - "seconds": 129.39154352108017, - "variant": "baseline" - }, - { - "attempt": 1, - "exit": 0, - "seconds": 2.8589652359951288, - "variant": "optimized" - }, - { - "attempt": 2, - "exit": 0, - "seconds": 2.4744310479145497, - "variant": "optimized" - }, - { - "attempt": 3, - "exit": 0, - "seconds": 2.7586984809022397, - "variant": "optimized" - } - ], - "unchanged_seconds": 0.00438 - }, - "web_clock": { - "after_unrelated_window_renders": 0, - "before_unrelated_window_renders": 20, - "measurement": "Paired React component test with identical clock ticks; not a browser CPU profile", - "seconds": 20 - }, - "web_image": { - "after_bytes": 104205923, - "after_node_modules_KiB": 20144, - "before_bytes": 803746314, - "before_node_modules_KiB": 498699, - "measurement": "docker image inspect Size; uncompressed image layers" - }, - "web_wallpaper": { - "browser_dpr": 2, - "browser_selected_bytes": 250438, - "original_bytes": 1838747, - "widths": { - "1280": 90336, - "2560": 250438, - "5120": 678532 - } - } -} diff --git a/docs/measurements/google-dns-tailscale-2026-10-23.md b/docs/measurements/google-dns-tailscale-2026-10-23.md new file mode 100644 index 00000000..0f77fa39 --- /dev/null +++ b/docs/measurements/google-dns-tailscale-2026-10-23.md @@ -0,0 +1,154 @@ +# Google DNS defaults and Tailscale investigation + +Fresh live and installed NerdOS systems now initialize an editable wired +NetworkManager profile with Google DNS: `8.8.8.8`, `8.8.4.4`, +`2001:4860:4860::8888` and `2001:4860:4860::8844`. DHCP still supplies addresses +and routes; its DNS servers are ignored on that initial profile. The profile +supports multiple wired adapters. New Wi-Fi connections created through the +desktop or installer inherit the selected provider, with Google as the first +Wi-Fi default when no physical profile exists. + +The shared module copies the wired profile into mutable state once, before +NetworkManager starts. It leaves existing profiles alone. Later selections, +including Automatic, are not restored to vendor values by the initializer. +Other network tools retain control of profiles they create. + +NetworkManager and Tailscale now share systemd-resolved, with the recommended +stub resolver and Google fallback servers. Tailscale's authentication and DNS +preferences are unchanged. The installer records upstream resolvers, DNS routing +domains, Tailscale's backend state and verified HTTPS connectivity. It suppresses +Tailscale's full status output, which can contain an authentication URL. The +connection-error screen also offers **Show installation details**. Network +checks do not rewrite profiles or disconnect interfaces. + +## What the Tailscale investigation established + +The previous live configuration used NetworkManager's `dns=default` with +openresolv. It had no configured static nameservers; Automatic accepted the +network's DHCP DNS. Tailscale was enabled on the ISO, but no authentication key +or automatic login was configured. The original machine was powered off, so +its actual resolver address and installation log could not be recovered. + +Tailscale can change DNS after sign-in. MagicDNS can use its local resolver at +`100.100.100.100`, and a tailnet's **Override DNS servers** setting can replace +local upstreams. That address is a normal Tailscale resolver, not evidence of +an incorrect DNS address by itself. A fresh client cannot assume that a +tailnet-only upstream is reachable before authentication. These mechanisms make +Tailscale relevant to investigate, but do not establish that it caused this +particular failure. + +Upstream recommends systemd-resolved for Linux DNS integration. The pinned +Tailscale 1.98.10 source skips authenticated network reconfiguration without a +network map and clears engine DNS in `NeedsLogin`. Both isolated boot checks +observed `NeedsLogin`, no Tailscale DNS servers, working Google DNS, and +Tailscale selecting `systemd-resolved` / `*dns.resolvedManager`. No tailnet +account was used, so signed-in MagicDNS, exit nodes and administrator DNS +overrides were not runtime-tested. + +Sources consulted: + +- [Tailscale: Linux DNS](https://tailscale.com/kb/1188/linux-dns). +- [Tailscale: why resolv.conf is overwritten](https://tailscale.com/kb/1235/resolv-conf). +- [Tailscale: DNS settings and overrides](https://tailscale.com/kb/1054/dns). +- [Tailscale 1.98.10 Linux DNS manager](https://github.com/tailscale/tailscale/blob/v1.98.10/net/dns/manager_linux.go). +- [Tailscale 1.98.10 backend state handling](https://github.com/tailscale/tailscale/blob/v1.98.10/ipn/ipnlocal/local.go). +- NetworkManager 1.56's packaged `NetworkManager.conf(5)`: global DNS + overrides connection DNS, so an unconditional global override would defeat + later network-widget choices. The implementation instead uses editable + connection settings. + +## Exact source and validation + +- Frozen source: `/nix/store/ni1rpimamdhb6620cs0kqyh4hzhbagln-source`. +- Source NAR: `sha256-wGhKUL/M4wMKjcTXZt/YSFxwhBR7vZkT7anEc0SqrGQ=`. +- Version `1.0.0-beta.2`, revision `uncommitted`. This development testing + image includes the checkout's pre-existing work. It is not a published or + authenticated release. +- All 526 Python discovery tests and the additional standalone helper suites + passed. JavaScript: 1,360 passed, 17 skipped. The focused installer/network + suite passed 45 tests. Native Qt/controller checks passed, including the new + preflight details action, persistent failure logs, timezone behavior and the + existing private review and close guard. +- The shared installation-contract build passed. All 36 completed-image/source + parity checks passed, including source NAR, installer frontend/QML, worker, + packages, release metadata and boot profiles. +- The actual staged updater passed the final source's complete, unchanged VM + test script: 139.14 seconds for the script, 162.23 seconds including boot and + cleanup. Driver: + `/nix/store/fscqndhvyr38k8d5ll1cxa35l519s0wh-nixos-test-driver-nerdos-lifecycle`. + Script SHA-256: + `9980537df40c4b0af54a433d48cc299f8c8289c2742be351a2b623da4591e8d6`. + The VM automatically obtained internet through the new Google profile; no + injected DHCP connection or substituted updater assertions were needed. + Software and maintenance candidates reached `ready`, and personal content, + configuration hashes, locks and the running maintenance policy were + preserved. Nothing was applied. +- In that VM, DHCP advertised `10.0.2.3`; active DNS used Google instead. + Selecting Automatic through the packaged helper survived restarting + NetworkManager and the initializer. Removing the initializer marker and + rerunning it also preserved the existing profile. +- The completed ISO booted through its actual UEFI bootloader in a disposable + KVM VM. Its declared bundled source has a different Nix store name but the + same independently verified NAR. Google DNS and verified HTTPS worked with + Tailscale signed out. A real JSON installer preflight reached `ready` and was + cancelled. After deliberately selecting the unreachable resolver + `192.0.2.53`, preflight stopped with an error before setup; its accessible + private log showed that resolver, `NeedsLogin`, and curl's resolving timeout + (exit 28). Restoring Google through the packaged helper restored HTTPS. + The disposable disk's layout remained unchanged. This check took 47.09 + seconds; it did not attempt a fresh installation. +- The live VM's Quickshell guard compared the authenticated user's service + MainPID with every shell process, including Qt-wrapped Quickshell, at the + start and end. Exactly one managed shell remained and its invocation's + journal passed the QML/runtime error check. +- Initial harness assertions were corrected to follow NixOS's `/etc/static` + indirection and the ISO's declared source path. Those corrections did not + change resolver behavior. The final script was checked byte-for-byte before + and after running. The writable snapshot retained its NAR, and all 1,476 + checkout entries matched the frozen source before adding this evidence file. + `git diff --check` passed. + +These checks qualify the tested DNS defaults, live preflight diagnostics and +local staging behavior. They do not establish the original machine's exact +failure, a completed fresh installation, physical PXE/hardware support, Secure +Boot, an applied update, or signed-in Tailscale DNS behavior. No running +workstation was deployed, partitioned or rebooted. + +## Retained PXE deliverable + +On **thebeast (`10.10.0.7`)**, retained for the next installation test: + +- `/data/pxe/iso/nerdos-1.0.0-beta.2-20261023-google-dns-c0684a50bfcc.iso` + (10,241,591,296 bytes; 9.54 GiB). +- The same path plus `.sha256` (123 bytes). +- ISO SHA-256: + `e7684cb092556929c564f5b256d98b2b6b26ed0ee4ae116a6557264a1b12f1c2`. + +The complete image and checksum were copied and verified outside the served +tree, then linked into place atomically with readable permissions. The served +checksum passed before refreshing iVentoy. Refresh returned +`{"result":"success"}`, then status moved from `refreshing` to `running`. +The image tree contained the new filename and all three previously listed +images. `iventoy.service` was active; no restart or existing-image removal was +needed. + +## Cleanup + +Removed `/tmp/nerdos-installer-dns-Z1iXsW` on the Beast (41 MiB) and +`/scratch/tmp/john/nerdos-installer-dns-apc3enme` locally (9.3 MiB), including +source snapshots, attempted-test logs, VM disks, firmware variables and +screenshots. Parity extraction removed its temporary tree. Removed the separate +copy-staging directory `/home/john/.cache/nerdos-google-dns-iy5om451`; its hard +links had already been added to the served directory. + +The cancelled first image build left no ISO output. Removed the final duplicate +ISO output through Nix after removing its task outlink: +`/nix/store/6lww3n927xd675h0a2d8q7lbhpj54g38-nerdos-1.0.0-beta.2-x86_64-linux.iso`. +Nix reported 9.5 GiB freed. Nix build dependencies remain cached. + +Final checks confirmed all three staging directories absent with zero retained +bytes, both task ISO store outputs absent, and no task processes or mounts on +either host. Neither host had a `~/.local/share/nerdos/images` directory. The +served ISO and 123-byte checksum remained present, with iVentoy active and PXE +status `running`. The only retained task artifacts are the testing ISO and +checksum listed above, together with this repository evidence record. diff --git a/docs/measurements/installer-assistant-2026-10-23.md b/docs/measurements/installer-assistant-2026-10-23.md new file mode 100644 index 00000000..012b44ff --- /dev/null +++ b/docs/measurements/installer-assistant-2026-10-23.md @@ -0,0 +1,104 @@ +# Installer details, timezone selection and assistant setup + +The reported live installer was switched off before its root-only log could +be collected. The photograph establishes `nixos-install` exit 1, without its +underlying cause. This work fixes access to diagnostics and the reported UI +problems; it does not identify or claim to fix that installation failure. + +## Source and scope + +The frozen development source is +`sha256-C2OV1dIgRHI47UaVIaIIt17ClWAGHK7f0fZABkVMkqM=` +(`/nix/store/iyzzhrv19cf5lbwvwd3jkqkkpnp4z580-source`). It includes the +pre-existing checkout changes. This evidence record was written afterward. +Tests ran on the Beast in isolated offscreen Qt, headless Sway and disposable +NixOS VM environments. No workstation was deployed or rebooted. No ISO was +built or changed, and the served PXE image list was left alone. + +## Behavior and checks + +- Installation details now stay in a selectable, scrollable dialog, with + explicit read errors and automatic refresh. A separate live-only read of + the fixed root log works while installation owns its operation lock and + after its worker exits. Output is bounded to the most recent 256 KiB and + rendered as plain text. The full log stays root-only. +- Region settings offer a searchable timezone selection. GeoIP uses HTTPS + through ipapi.co, times out, and leaves manual or reviewed choices alone. + Language and keyboard choices remain independent. The actual Qt network + client successfully detected `Europe/Amsterdam` from the build host. +- Dragging uses coordinates on a stationary surface, independent of the + character's translation, scale and rotation. The actual Quickshell engine + under isolated headless Sway passed three repeated drags with real + wlr-virtual-pointer motion/button input, checking the full saved distance. +- Assistant setup checks `claude auth status` / `codex login status` before + sending a prompt. Its own sign-in action launches the provider's native + login in the selected terminal, detects completion and preserves the draft. + Status probes discard their output; credentials stay with the provider. + The real QML/service integration passed against a scripted agent and a + capture terminal, including automatic sign-in detection, approvals, Undo + and widget creation. No real provider account or model was used. +- The native installer QML/controller test passed timezone filtering, + successful/unavailable/late GeoIP responses, manual override, review + preservation, failed diagnostics reads, recovery on refresh, persistent + plain-text details, account validation and the installation close guard. +- QML lint passed 376 product files and four harnesses with the existing + type/property warnings. The installer package built and its actual wrapper + loaded under an empty environment. Shared ISO/installed-system contract + evaluation/build passed; this is source parity, without a completed ISO. + +## Source regression suite + +`tests/run` passed on a writable copy with the same source NAR: 515 Python +discovery tests, the additional helper suites (21, 5 and 23 tests), and 1,360 +JavaScript tests passed (1,377 total, 17 skipped, no failures). The remaining +standalone runtime, plugin, theme and model-usage helper checks also passed. +Password checks used the `NERDOS_LIBCRYPT` exported by this source's pinned +lifecycle derivation; an initial run without that dependency was corrected +before the complete passing run. Python parsing, catalogue validation and +`git diff --check` also passed. + +## Booted staged updater + +The sandboxed `checks.x86_64-linux.vm` run booted and reached the actual +supervised software staging path. It failed because the offline guest could +not resolve/fetch missing build inputs, including Python, Binutils and Bash. +The updater reported “Staging failed; working system retained”. This sandbox +check did not pass. + +The online follow-up **passed in 114.39 seconds**, running the same driver and +complete original test script with temporary DHCP on the disposable guest's +NAT interface. QEMU used `-no-user-config` because the host's private +`/etc/qemu` directory blocked the first online attempt before boot. No +candidate, updater, assertion or test-script substitution was used. + +The real supervised software preview reached `ready` with `hello` present in +the candidate. The subsequent maintenance preview reached `ready` with its +new policy in the candidate while the running policy stayed unchanged. The +test checked machine-owned configuration hashes, identical locks, preserved +personal content, a review token and the pinned NerdOS input. Recovery, +desktop verification and the packaged Plymouth password callback also +passed. This exercised staging and review; it did not apply a deployment. + +- Driver: `/nix/store/iwzz56gagz5mvs9rl222pfwaigj7l7mx-nixos-test-driver-nerdos-lifecycle` +- Original test script SHA-256: + `672340514b190230bf4afdded1ce95d4f4254d96230b930d9a50b14d3e4684b4` +- The runner checked the loaded test bytes and unchanged original script. + The writable source still hashed to the frozen NAR after the tests. The + final checkout comparison differed only by this evidence record. + +## Boundaries and cleanup + +The physical installation failure, physical/multiple-display dragging and +real Claude/Codex browser/account authentication still require testing. +Source fixtures, offscreen rendering and a package build do not qualify a +fresh physical installation, hardware, Secure Boot or an applied update. + +Cleanup removed the Beast staging directory +`/tmp/nerdos-installer-assistant-0jeWZU` (341 MiB) and local staging directory +`/tmp/nerdos-installer-assistant-1x9jGa` (16 MiB), including source copies, +logs, screenshots and the VM disk. Checks found no remaining task processes +or mounts and confirmed both directories absent. No user Quickshell process +remained on the headless test host. Neither host had a +`~/.local/share/nerdos/images` directory. No disposable test artifacts are +retained. Shared Nix dependencies remain cached; no existing ISO or other +task's artifacts were removed. diff --git a/docs/measurements/installer-graphics-beta2-2026-10-23.md b/docs/measurements/installer-graphics-beta2-2026-10-23.md new file mode 100644 index 00000000..4d89ef37 --- /dev/null +++ b/docs/measurements/installer-graphics-beta2-2026-10-23.md @@ -0,0 +1,159 @@ +# Installer graphics / beta.2 — 2026-10-23 + +The Minisforum X1 Pro 370 still showed identical corruption with the previous +7.2.11 ISO. The user confirmed that its separate NerdOS GRUB menu displayed +correctly; corruption began after selecting the live/install entry, before any +desktop. That image did not resolve the physical issue. + +## Findings and changes + +The previous image has AMD/Intel/Nouveau drivers, Mesa 26.1.8 and the identified +Radeon 890M / Strix Point firmware in its live filesystem, but none of those +native GPU modules or firmware in its initrd. Its early display depends on +the firmware framebuffer. Shared hardware configuration now makes `amdgpu`, +`radeon`, `i915`, `xe`, `virtio_gpu` and, unless NVIDIA's vendor driver is +selected, `nouveau` available to initrd udev. Explicit NVIDIA configurations +load `nvidia`, `nvidia_modeset` and `nvidia_drm` early. This preserves native +graphics and the same installation contract on live and installed machines. + +An isolated VM reached Linux through the real iVentoy service using the old +image, then timed out after 90 seconds waiting for `NERDOS_INSTALL`. iVentoy +1.0.41's NixOS hook inserts its NIC/NBD setup before `mount the root` in a +scripted `/init`; it installs no systemd stage-one unit. The old image's +systemd initrd therefore never invokes that setup. The live image now uses +NixOS 26.05's supported upstream scripted initrd; installed systems retain +systemd stage one. Scripted initrd is deprecated upstream and scheduled for +removal in 26.11, so a deliberate future major upgrade must revisit this +transport integration. This does not establish the cause of the physical +framebuffer corruption. + +The default live entry retains Linux 7.2.11 and Mesa/Nouveau. A separate +NVIDIA-open entry includes vendor driver 595.71.05, its userspace and firmware, +using the pinned stable/LTS Linux 6.18.57. An actual build of that driver with +7.2.11 failed because it references the removed `strncpy` interface; NVIDIA +support was retained with the supported kernel rather than omitted. The live +NVIDIA profile keeps Intel/AMD drivers and avoids global NVIDIA renderer +overrides or guessed PRIME bus addresses. The installed closed-driver choice +defaults to `legacy_580`; NixOS's branch option remains machine-overridable. +Generation-specific hardware qualification is still required. + +Prominent verbose/native-text/firmware-text entries use the same live system +and guided installer. Only the explicitly selected firmware-text entry uses +`nomodeset`. Console ordering keeps serial logging while making the local +screen the primary console. None of the entries automatically partitions a +disk, and Secure Boot and firewall settings were not disabled. + +## Exact testing image + +- Filename: `nerdos-1.0.0-beta.2-20261023-pxe-gfx-7a36d3d7ccea.iso`. +- Beast path: `/data/pxe/iso/nerdos-1.0.0-beta.2-20261023-pxe-gfx-7a36d3d7ccea.iso`. +- Size: 9,920,796,672 bytes (9.24 GiB); companion checksum: 120 bytes. +- SHA-256: `d73f9bee58d7275f8d1bcc758c83e28b380ca4940cc9c09d7ca9b3479b06410f`. +- Source NAR: `sha256-ejbT18zqTGhcpIRM3QKQLoGiXlLXfJ37kayURMZJ4cw=`. +- Source revision: `uncommitted`; version: `1.0.0-beta.2`; NixOS: 26.05. +- Nixpkgs remains `da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36`. +- [ISO download](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261023-pxe-gfx-7a36d3d7ccea.iso) + and [checksum](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261023-pxe-gfx-7a36d3d7ccea.iso.sha256). + +The source snapshot was frozen before this result record. Unrelated workspace +work was preserved. This is development media, not an authenticated release. + +## Exercised checks + +The sandboxed ISO build and shared live/installed installation-contract check +passed. Image/source parity passed for the completed ISO and this exact frozen +source, including all kernel/initrd/system tuples in both boot menus and the +identical menu embedded in the EFI FAT image. Seven parser regression cases +passed, including rejection of mixed profiles, duplicate directives, split +entries and duplicate `init=` parameters. + +The completed image's default initrd is 1,464,583,410 bytes and contains the +six native GPU drivers above, including the identified Strix Point firmware. +The NVIDIA initrd is 189,395,231 bytes and contains its vendor modules alongside +AMD/Intel/virtio drivers, without Nouveau. The added early firmware and second +kernel/profile account for the larger image and PXE transfer. + +Both normal and NVIDIA menu entries booted the completed ISO as a virtual +optical disk under UEFI/KVM, with an e1000e NIC, virtio graphics and 8 GiB RAM. +They reported 7.2.11 and 6.18.57 respectively, and the exact beta.2 source NAR. +Managed desktop health passed at start/end, including the sole managed +Quickshell instance and current invocation journal; desktop configuration and +live installer checks passed. ZFS loaded and SSH was inactive. The NVIDIA +profile's VM boot exercises its packaging and Mesa fallback on virtio hardware, +not an actual NVIDIA GPU. No disk installation was performed. + +### Network-backed iVentoy boot + +The completed beta.2 image also reached a healthy desktop through the real +iVentoy service in an isolated UEFI/KVM guest. `/iso` was mounted from +`/dev/nbd0p1` as ISO9660 and `/nix/.ro-store` from `/dev/loop0` as squashfs. +It reported Linux 7.2.11 and the exact beta.2 NAR above. Managed desktop health +passed at start/end; desktop configuration, live installer and ZFS checks +passed, and SSH was inactive. A framebuffer capture visibly showed the desktop +and development-installer consent prompt. No installation was started. + +This used QEMU user networking with isolated DHCP/TFTP and a task-owned HTTP +relay, without changing LAN DHCP, iVentoy configuration or the ISO. The first +unmodified NAT attempt failed during network setup: iVentoy recorded the NAT +host address `10.10.0.7` as the client, rather than the guest's `10.0.2.15`. +For the successful follow-up, the guest GRUB command line selected serial as +primary console and added `boot.debug1devices plymouth.enable=0`. In its +stage-one shell the fixture configured the detected NIC as `10.0.2.15/24`, +added gateway `10.0.2.2`, and appended `vtoy_ip_addr=10.0.2.15` to the guest's +`/ventoy/ventoy.env`, then resumed boot. The iVentoy hook subsequently exposed +the NBD medium and boot completed. This establishes working network-medium +integration with that explicit NAT adjustment; it does not establish an +unmodified automatic LAN PXE boot or physical graphics recovery. + +### Actual staged updater + +The required sandboxed `checks.x86_64-linux.vm` was run on the frozen source +and failed during real software staging. Its offline guest could not resolve +the hosts for missing Python 3.14.4, Bash and Binutils build inputs. The updater +reported `Staging failed; working system retained`. This derivation remains a +failed check. + +An isolated online follow-up used the same immutable test driver and complete +original test script, with outbound QEMU networking and a temporary guest DHCP +connection to reach the upstream cache. No source edits, mocked updater, +candidate injection or assertion removals were used. All original assertions +passed in 118.79 seconds. Real supervised `hello` software staging and +maintenance-policy staging reached `ready`, including actual Nix builds, +candidate desktop validation, closure diffs and review tokens. Personal +configuration, machine locks, desired selections and active policy stayed +unchanged. The exact source NAR was verified unchanged before and afterward. + +The driver was +`/nix/store/favlf5c70qakvh521g3vhd2ickhs78l4-nixos-test-driver-nerdos-lifecycle/bin/nixos-test-driver`; +the original test-script SHA-256 was +`26acf1baafa2acc73eeedd698a38826421eb6811fc5dfb53d4f7c2ef01e5caa6`. +This exercises `checks.system` staging, with proprietary/Steam/Docker features +disabled, rather than a full product installation or an applied update. + +## Delivery and cleanup + +The served ISO and companion checksum were verified after copying, and a full +HTTP download matched the SHA-256 above. At the user's request both superseded +beta.1 ISO/checksum pairs were removed: + +- `nerdos-1.0.0-beta.1-20261023-ec5fa706073a.iso` +- `nerdos-1.0.0-beta.1-20261023-k7.2.11-68a754171686.iso` + +iVentoy refresh returned `result: success`; its final status was `running`, +the image tree contained only the beta.2 filename (image 4), and +`iventoy.service` was active. No service restart was needed. + +Only the requested testing ISO (9,920,796,672 bytes) and checksum (120 bytes) +are retained in `/data/pxe/iso`. Task-owned staging/source copies, VM state, +captures, extracted payloads, logs and the duplicate Nix-store ISO output are +removed. Both local and Beast task directories were verified absent, with no +remaining task processes or mounts. The Beast's served ISO tree uses 9.3 GiB +and its staging directory 4 KiB; neither machine has leftover user image output +under `~/.local/share/nerdos/images/`. The source embedded in the delivered +ISO retains the exact build identity. Shared Nix dependencies and unrelated +workspace work are preserved; no workstation deployment or release publication +was performed. + +Physical X1 graphics recovery, AMD/Intel/NVIDIA hardware operation, LAN PXE, +fresh disk installation and installed trial/apply/confirmation/rollback remain +separate qualification boundaries. VM success cannot establish these results. diff --git a/docs/measurements/installer-gui-beta2-2026-10-23.md b/docs/measurements/installer-gui-beta2-2026-10-23.md new file mode 100644 index 00000000..25ac505d --- /dev/null +++ b/docs/measurements/installer-gui-beta2-2026-10-23.md @@ -0,0 +1,160 @@ +# Graphical installer / beta.2 — 2026-10-23 + +This records the fresh graphical installer, shared account/encryption password, +desktop defaults and unattended live-session fix. The source was frozen before +this result record; this document is not part of the image's source identity. +Unrelated workspace changes were preserved. No workstation deployment, physical +disk operation, host reboot or release publication was performed. + +## Exact testing image + +- Filename: `nerdos-1.0.0-beta.2-20261023-gui-9bc2f93e7e83.iso`. +- Beast path: `/data/pxe/iso/nerdos-1.0.0-beta.2-20261023-gui-9bc2f93e7e83.iso`. +- Size: 10,241,574,912 bytes (9.54 GiB); companion checksum: 116 bytes. +- SHA-256: `c09fb3e222bbc1fea9218fe8ca0a89281f2de4caa796b3b9abd066660be299dc`. +- Source NAR: `sha256-m8L5Pn6DapNM6mYXdC5Bz4kI0jkJ/sJqT6aH/2vFlA4=`. +- Frozen source: `/nix/store/h02d8k5g9cnq64cihwj0m6jyrs7pqwb8-nerdos-gui-source`. +- Source revision: `uncommitted`; version: `1.0.0-beta.2`; NixOS: 26.05. +- Nixpkgs: `da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36`. +- [ISO download](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261023-gui-9bc2f93e7e83.iso) + and [checksum](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261023-gui-9bc2f93e7e83.iso.sha256). + +This is an unreleased development image. The native welcome requires explicit +consent to use its exact bundled test source; production authentication and the +separate signed-release prerequisites are unchanged. + +## Resulting behavior + +The live desktop opens the native PySide6/Qt Quick installer. One setup page +collects disk, username and one password; computer name and regional defaults +are editable, with optional settings collapsed. Only one available disk is +preselected; multiple disks require a choice. The review names the disk and its +contents. **Erase disk & install** is the final destructive confirmation, +without serial-number or ERASE typing. Its root worker binds confirmation to +the reviewed plan and rechecks disk identity before writing. Six actual stages +report progress; completion follows unmounting and encryption cleanup. Restart +is explicit. GUI, terminal and automated installation share the same engine. + +The shared password policy accepts at least eight characters without character +class rules or normalization. One private input sets both the account and +LUKS2 password. First desktop login remains password-based. Settings changes +both credentials while retaining the old disk key until the account commits; +its interruption record contains slot identities, never passwords. The ordinary +password/PAM path also changes the login keyring, using the authenticated user's +runtime socket. PAM reports keyring failures so a partially committed change +retains the matching disk key and explains the integration failure. + +Model Usage is enabled by default. Hermes Agent, Remote Server, T3 Code, GitHub +and Fusebox start hidden, with explicit personal widget choices preserved. +LAN discovery is enabled through Avahi and the existing firewall gates. Bibata +Modern Classic, size 24, is packaged and selected for the compositor, GTK, +XWayland, system session environment and SDDM. + +The ISO disables hypridle and the session-lock service, ignores automatic +logind idle/lid actions, guards the lock shortcut and removes live Lock controls. +It cannot automatically lock or suspend an unattended installation. Installed +systems retain their ordinary idle locking, suspend and password fallback. + +## Build and source checks + +The exact frozen source passed the ISO build and the Nix lifecycle, shared +installation parity, full product, XPS system and NVIDIA evaluation checks. +The lifecycle check passed 508 unit tests, its standalone helper checks, and +21 system-settings tests. Image/source parity passed for the completed ISO, +including the bundled source, input sources, native frontend/QML, desktop, +updater and kernel/initrd/system boot profiles. + +The Quickshell suite passed 1,360 tests with 17 skipped and no failures (1,377 +total). The native Qt/QML smoke check covered disk selection/reordering, +multiple-disk choice, input validation, review state and the close guard. Those +UI payloads did not change in the final credential-context correction. +Whitespace and shell syntax checks also passed. + +## Actual installation and lifecycle qualification + +Both runs passed using the exact ISO and source identity above, UEFI/KVM, +12 GiB RAM, four CPUs, NAT networking and an 80 GiB disposable disk. They drove +the packaged native GUI and real root installer, using the standard application +selection and an eight-character disposable QA password. + +| Check | Unencrypted install | Encrypted install | +| --- | --- | --- | +| Invalid username and short-password errors; review/back; disk untouched until confirmation | Passed | Passed | +| Actual GUI installation and source identity on the installed machine | Passed | Passed | +| Installed boot, console password login and SDDM password login | Passed | Passed | +| Runtime encryption state and desktop configuration/health | Passed, plain root | Passed, LUKS2 root | +| Two account password changes, reboots and automatic login-keyring unlock | Passed | Passed, new disk key accepted and old key rejected | +| Bibata/session defaults, LAN discovery and enabled firewall | Passed | Passed | +| Live Lock shortcut leaves no locker or idle/lock service running | Passed | Passed | +| Untouched live desktop beyond the previous five-minute lock timeout | Passed, 310 seconds | Not repeated in this case | + +Managed desktop health checked the sole service-owned Quickshell process and +the current invocation's journal. The real welcome and installation progress +were also visually inspected. No source-tree menubar was started beside it. + +The encrypted system additionally passed actual supervised upstream staging and +local deployment, with these results: + +- Independent upstream discovery and complete staging/build/validation passed + while the unpublished NerdOS endpoint accurately reported HTTP 404. Nixpkgs + was already current at the revision above, so the candidate did not change + packages and was not applied. This does not qualify a changed upstream update. +- Personal baseline `5f85621f64704e71968f424efdb3bb33` was staged, applied, trial + booted, authenticated and confirmed. +- Deliberate Nix evaluation and build failures retained the active system and + working lock. Killing a real staging worker during a build, then rebooting, + produced the expected interrupted state and a healthy usable desktop. +- Local candidate `23ac4dbfcb894045ba2964145e8a91c0` was staged, applied, trial + booted, authenticated and confirmed, then rolled back through the real updater. +- Personal packages, service/state, VPN declaration and private key, widget + plugin/settings/data, preferences and local modules passed preservation checks + through failures, recovery, application and rollback. The candidate-only file + disappeared after rollback and no pending transaction remained. + +The plain case stops after installation and password round trips; updater +application was exercised on the encrypted case. Neither case substitutes +fixture status files, candidate systems or a mocked installer/updater. + +## Findings from real installation tests + +The first encrypted installation reached bootloader installation and failed +copying the full initrd into the previous 1 GiB ESP. The default product initrd +measured 1,457,957,958 bytes, its kernel 13,578,752 bytes, and the product closure +26,350,228,896 bytes. Early GPU drivers and firmware were retained. The shared +module now defines a 16 GiB ESP and a 64 GiB minimum disk; the installer consumes +that exported layout contract. The ESP accommodates ten retained generations +and copying the next generation before cleanup. The ten-generation boot limit +was preserved. + +A subsequent image completed both GUI installs and password-based desktop +logins, but failed the post-password-change keyring check after reboot. +The password PAM stack needed to continue after `pam_unix`, and the elevated +worker needed the user's `XDG_RUNTIME_DIR`/keyring control socket. Both are now +explicit; keyring errors propagate through PAM. The qualification tool now +bounds secret-service waits and records PAM/keyring diagnostics and partial +password-change milestones on failure. + +## Delivery and cleanup + +The completed ISO and checksum were verified in task staging and again after +moving them into the Beast's served ISO tree, with mode 0644. A full HTTP +download matched the SHA-256 above, and the companion checksum was downloadable. +iVentoy refresh returned `result: success`; its status moved from `refreshing` +to `running`. The final image tree contained the new filename as image 5 and +the existing graphics-testing image as image 4. `iventoy.service` was active; +no service restart was needed. + +Only the requested new ISO (10,241,574,912 bytes) and checksum (116 bytes) are +retained from this task in `/data/pxe/iso`, for physical testing. The existing +graphics-testing ISO/checksum pair was preserved. The served directory uses +19 GiB. All task VM disks/control directories, extracted payloads, staging/source +copies, captures and temporary logs were removed, along with five task-created +Nix-store ISO outputs. Normal shared Nix build dependencies remain cached. +Both local and Beast task output directories were verified absent, with no +remaining task VMs/test processes or mounts, and neither host had leftover +user-image output under `~/.local/share/nerdos/images/`. + +Physical AMD/Intel/NVIDIA operation, X1 graphics recovery, an unmodified LAN PXE +boot, signed production-channel updates and Secure Boot remain outside these +VM checks. The documented XPS camera and hardware/security release blockers +remain in force; a complete hardware package build does not qualify hardware. diff --git a/docs/measurements/installer-input-2026-10-24.md b/docs/measurements/installer-input-2026-10-24.md new file mode 100644 index 00000000..561444bf --- /dev/null +++ b/docs/measurements/installer-input-2026-10-24.md @@ -0,0 +1,121 @@ +# Installer cursor, keyboard and responsiveness — 2026-10-24 + +This audit covers the ISO kiosk's KMS/software renderer and libinput backend, +Slint controls and sheets, socket client, state service and diagnostic requests. +The disk-writing worker and the shared ISO/installation modules were reviewed; +their installation sequence and safety checks were not changed. Most actual +installation time remains in copying, downloading and building the selected +NixOS closure. No before/after installation-duration claim is made. + +The source was frozen before this result record. No workstation deployment, +host reboot, physical disk operation, release publication or new ISO was +performed. The existing served testing image was preserved. + +## Findings and changes + +- **Invisible cursor:** Slint 1.18.1's KMS software renderer accepts a cursor + drawing callback but never invokes it. The local dependency patch draws it + inside the software renderer's partial-rendering pass and invalidates both + old and new bounds before collecting damage. This preserves double-buffer + repainting, scaling, rotation and the existing framebuffer-stride handling. +- **Incomplete keyboard access:** Close, links, top-bar chips, menu and picker + rows, disk details and password visibility were pointer-only. They now have + keyboard targets and focus indicators. Background targets are disabled while + a sheet is open; Tab/Shift+Tab remain inside and Escape closes the sheet. + The welcome shortcut now runs after focused controls instead of intercepting + their Enter key. Review has no default Enter action; the erase control must be chosen explicitly. +- **Held keys:** the upstream libinput path has no repeat. The pinned patch + uses XKB's repeatability and current modifier state, with a 350 ms delay and + 33 ms interval. Release and device removal cancel repeat. Activation controls + swallow repeated Enter/Space, and fields swallow repeated Enter. +- **Snapshot churn:** every snapshot replaced all list models. Models now keep + their identity and update only changed rows; identical state is skipped. + Unrelated updates preserve focus and Wi-Fi password fields. If a rescan + changes the network at the selected index, selection is cleared so credentials + cannot transfer to a different SSID. Hidden account maps are not regenerated + on other screens' timer ticks. +- **Blocking I/O:** socket writes moved off the UI thread, have a timeout, and + belong to one connection generation. Old queued requests are discarded after + reconnecting; serialized requests are cleared on drop. Slow diagnostics and + USB discovery run independently of the daemon's request reader, with duplicate + queries coalesced and disconnected queries cancelled. Diagnostic subprocesses + are killed and reaped on timeout/cancellation. +- **Reading logs:** arrows, Page Up/Down, Home and End scroll the log. Automatic + refresh preserves an older reading position; End resumes following output. + +The Slint patch is applied only to the Nix build's private vendor copy. It does +not modify a global Cargo cache, the shared Nixpkgs input, or the running shell. +Plain Cargo builds use unpatched upstream dependencies; use the Nix package for +KMS runtime verification. + +## Verification + +- Python installer worker/network/daemon suites: **68 tests passed**. +- Rust controller/protocol/name/time-zone/map/socket suites and actual rendered + Slint interaction tests: **26 tests passed**. These cover Close in all seven + sheets; forward and reverse Tab containment; focus surviving a network update; + Wi-Fi secrets surviving a signal change and selection clearing on reorder; + repeated Enter never invoking Install; log scrolling and reading-position + preservation; and an unread socket not blocking its sender. +- **100 identical snapshots scheduled zero frames**, measured through the real + controller and software window. This is a bounded redraw regression check, + not an estimate of physical typing latency or installation speed. + +## Real KMS runtime check + +A disposable QEMU/KVM guest booted the existing UEFI installer ISO with virtio +KMS at 1280×800, a USB absolute pointer and the PS/2 keyboard. The host store was +shared read-only into a guest-only writable overlay, and transient guest unit +overrides selected the exact final package below. The installer remained an +unprivileged logind-seat service; no compositor or alternative renderer was used. + +- The unmodified ISO reproduced the missing cursor: moving over an inert region + changed zero cursor pixels. +- The final package drew the cursor in five successive moves (188 non-background + pixels each); the previous positions were clean after every move, including + revisiting earlier positions across the double buffers. +- Holding `a` for 850 ms produced **16 characters**. Holding Backspace for 650 ms + deleted **11 characters**. After key release the name stayed unchanged during + the subsequent observation. +- A real menu click followed by Tab/Enter opened Installation details; another + Tab/Enter reached Close and returned to the welcome screen. The actual VM + display captures were inspected. +- Both installer services remained active, the UI journal showed no runtime + errors, and the service reported `phase: setup`, `diskTouched: false`. + +This was repeated on the final package after an earlier iteration. Initial +harness attempts exposed a socket-startup race and a read-only-store limitation; +waiting for the socket and using a guest-only writable overlay resolved those +harness issues. Neither was an installer regression. + +## Exact build + +- Frozen source: `/nix/store/xr5z0l13g31yd3h5vz3asm0xv9lyhbzl-source`. +- Source NAR: `sha256-ypuqxSE7wnp47bQCKwzeyEcmNR13LvHdPrWJocvScAY=`. +- Packaged installer: `/nix/store/i9kv3bqc22ajb8afdvx4r2yn58mw7bbq-nerdos-installer-1.0.0-beta.2`. +- `nix build .#installer-ui` passed, including all 26 Rust tests in the release + build. The lock file was not refreshed. +- Before the new patch and test files are tracked by Git, reproduce with + `nix build "path:$PWD#installer-ui"` so Nix includes them. Validation used a + frozen non-Git source directory. +- VM boot medium: existing + `nerdos-1.0.0-beta.2-20261024-installer-kiosk-6421fc1ac80f.iso`, SHA-256 + `219b6af61f7ecce186e13bed2dc11f6dc89c332acc53610738e32b33b85ad186`. + +## Qualification limits + +The VM runtime checks use an existing ISO with the newly built installer package +injected only into the disposable guest. They do not establish ISO/source parity +for a new image, a fresh installation, installed desktop behavior, physical +AMD/Intel/NVIDIA output, touch, HiDPI, Wi-Fi joining, USB log saving, Secure Boot, +or staged updater qualification. The worker/updater and hardware release gates +remain in force. Physical cursor visibility and input latency still need checking +on a rebuilt ISO using the same final source identity. + +## Cleanup + +Removed the task's 5.1 GiB remote staging directory and eight task-specific Nix +package outputs (50.84 MiB), together with local temporary captures, harnesses +and logs. No task VM, mount or ISO output was retained. The Nix store paths above +record historical validation identities; the tested package outputs have been +deleted. Normal shared Nix dependency and source caches remain. diff --git a/docs/measurements/installer-input-iso-2026-10-24.md b/docs/measurements/installer-input-iso-2026-10-24.md new file mode 100644 index 00000000..e713d02a --- /dev/null +++ b/docs/measurements/installer-input-iso-2026-10-24.md @@ -0,0 +1,101 @@ +# Testing ISO: installer input fixes — 2026-10-24 + +Historical handoff. The subsequent [KMS latency work](installer-kms-latency-2026-10-24.md) +records the newer image, measured rendering improvement and current PXE status. + +This requested development image includes the cursor, keyboard accessibility, +key-repeat and responsiveness changes from the +[installer input audit](installer-input-2026-10-24.md). It was built on the Beast +from a frozen snapshot of all 1,364 tracked and untracked source files, including +the new Slint dependency patch and interaction tests. No lock was refreshed. +This record was written after freezing the source and is outside that snapshot. + +## Testing deliverable + +On **thebeast (`10.10.0.7`)**: + +- ISO: `/data/pxe/iso/nerdos-1.0.0-beta.2-20261024-installer-input-9aff22041052.iso`. +- Size: **7,120,576,512 bytes (6.63 GiB)**. +- Checksum: the same path plus `.sha256` (128 bytes). +- [Download ISO](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261024-installer-input-9aff22041052.iso). +- [Download checksum](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261024-installer-input-9aff22041052.iso.sha256). +- SHA-256: `9b3f1d992250e7127e294f9d6b941517958dfea7725b999a8de9afc235d0e8a5`. +- Frozen source: `/nix/store/rfvjp5cb94h3hh9gz95zg2dj0fpxqlmi-source`. +- Source NAR: `sha256-mv8iBBBSBCPqSFKrH1aTE20odRS8nlyA3f2D7sL/+j4=`. +- Release identity: `1.0.0-beta.2`, `uncommitted`, NixOS `26.05`. + +## Verification actually exercised + +- The ISO and shared installation-contract builds passed. All **26 Rust tests** + passed in the release package build (20 unit and six rendered interaction tests). +- All **39 completed-image/source parity checks** passed against this exact + source, covering source NAR, locked input sources, installer screen and service, + desktop, updater, offline payload, and UEFI/BIOS boot-menu identities. +- The completed ISO booted directly in a disposable QEMU/KVM UEFI guest with + virtio KMS, a USB tablet and PS/2 keyboard. No package injection or guest unit + override selected a different installer. The guest release NAR matched above. +- Five pointer moves each drew **188 cursor pixels**, with old bounds clean on + every move, including revisits. Tab/Enter opened Installation details through + the menu; another Tab/Enter activated Close. Actual display captures were + inspected. Closing correctly restored focus to the menu button. +- Holding `a` for 850 ms produced **17 characters**; holding Backspace for + 650 ms deleted **11 characters**. Release stopped repeat. Both installer + services remained active, the UI warning journal was empty, and the service + ended in `phase: setup`, `diskTouched: false`. +- The same-source actual staged updater test passed in **116.07 seconds**. + Software and maintenance candidates reached `ready`, preserving declarations, + machine locks, personal sentinel and the running maintenance policy. Nothing + was applied. Driver: `/nix/store/zz808syw0a3hcjybxhx5rx4iw05sxwxr-nixos-test-driver-nerdos-lifecycle`. + The unchanged original test script was `/nix/store/hc8ls47i7gr37sh2qzliwnjis77jvwsm-test-script`, SHA-256 + `7cc4f941f5131537236be571bbed8e00ea7533d52eaeb56a5f27d91487726bc7`. The harness supplied temporary guest-only NAT + and DNS after the network-profile assertions. +- The finished ISO was copied and checksum-verified outside the served tree, + then linked into place atomically. The served file and a complete HTTP download + matched SHA-256; the downloaded checksum file matched as well. +- iVentoy 1.0.41 refresh explicitly returned `result: success`, changed from + `refreshing` to `running`, and listed the new image. `iventoy.service` remained + active; no service restart was performed. + +Initial harness attempts required two corrections: the Nix VM driver needed +`-no-user-config` to avoid reading the host's restricted QEMU configuration, +and the smoke test needed to select Get started after Close restored menu focus. +The final checks above passed after those harness corrections. The driver retry +used a private task-specific runtime directory. + +This is an unreleased testing ISO. No full disk installation, applied updater, +installed login/encryption, physical hardware, Secure Boot or end-to-end PXE boot +was qualified. The workstation was not deployed, partitioned or rebooted, and +no release was published. + +## Older PXE images + +The user explicitly requested removing all older ISOs. Removed all five images +and their checksums (**47,766,061,056 ISO bytes**): + +- `nerdos-1.0.0-beta.2-20261023-google-dns-c0684a50bfcc.iso` and its `.sha256` file. +- `nerdos-1.0.0-beta.2-20261023-gui-9bc2f93e7e83.iso` and its `.sha256` file. +- `nerdos-1.0.0-beta.2-20261023-installer-logs-51eec49de70d.iso` and its `.sha256` file. +- `nerdos-1.0.0-beta.2-20261023-pxe-gfx-7a36d3d7ccea.iso` and its `.sha256` file. +- `nerdos-1.0.0-beta.2-20261024-installer-kiosk-6421fc1ac80f.iso` and its `.sha256` file. + +The last image was initially kept available because client `10.10.1.64` had an +established NBD connection to it. The user confirmed that no installation was +running and interruption was safe, after which it and its checksum were removed. +The final refresh returned `result: success` and completed to `running` without +a restart. Both the served directory and image tree contain only the new ISO +(plus its checksum in the directory); all older filenames are absent. +`iventoy.service` is active, final checksum verification passed, and the served +directory uses 6.7 GiB according to `du -sh`. + +## Cleanup + +Removed task staging at `/data/tmp/nerdos-input-iso-20261024-ajiLzz` on the Beast +and `/tmp/nerdos-input-iso-20261024-4_snmxkz` locally, including source archives, +VM disks, extracted files, logs, test captures and harnesses. The first driver's +disposable state and the retry's temporary socket directory were removed too. +No task VM or host mount remains; neither host has a NerdOS image output directory. +The duplicate task-built ISO output +`/nix/store/cbrpk8xpmygnxqymdwiiglbzvi4kh57v-nerdos-1.0.0-beta.2-x86_64-linux.iso` +was deleted through Nix (**6,790.71 MiB freed**) after verifying the served copy. +Normal shared dependency/source caches remain. Retained task deliverables are +only the requested ISO and checksum listed above, plus this evidence record. diff --git a/docs/measurements/installer-kiosk-2026-10-24.md b/docs/measurements/installer-kiosk-2026-10-24.md new file mode 100644 index 00000000..10cd8ff3 --- /dev/null +++ b/docs/measurements/installer-kiosk-2026-10-24.md @@ -0,0 +1,191 @@ +# Installer-only ISO (tty1 kiosk) — 2026-10-24 + +This records the change from a live desktop ISO to a medium that boots straight +into the installer. The source was frozen before this result record; this +document is not part of the image's source identity. Unrelated workspace +changes were preserved. No workstation deployment, physical disk operation, +host reboot or release publication was performed. + +## Exact testing image + +- Filename: `nerdos-1.0.0-beta.2-20261024-installer-kiosk-6421fc1ac80f.iso`. +- Beast path: `/data/pxe/iso/nerdos-1.0.0-beta.2-20261024-installer-kiosk-6421fc1ac80f.iso`. +- Size: 7,120,576,512 bytes (6.63 GiB); companion checksum: 128 bytes. +- SHA-256: `219b6af61f7ecce186e13bed2dc11f6dc89c332acc53610738e32b33b85ad186`. +- Source NAR: `sha256-ZCH8GsgPGf+GiW+62mT+dIJqt5AwKEIKZZZI9/uRWmg=`. +- Frozen source: `/nix/store/y0cgkqsp7m73s3zfcgyfly03fbha6yyg-source`. +- Source revision: `uncommitted`; version: `1.0.0-beta.2`; NixOS: 26.05. +- Nixpkgs: `da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36`; medium kernel 7.2.11; + tzdata 2026c; Slint 1.18.1 built with rustc 1.95.0. +- [ISO download](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261024-installer-kiosk-6421fc1ac80f.iso) + and [checksum](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261024-installer-kiosk-6421fc1ac80f.iso.sha256). + +This is an unreleased development image. The installer requires explicit +consent to use its exact bundled test source; production authentication and the +separate signed-release prerequisites are unchanged. + +## Resulting behavior + +The boot menu offers **Install NerdOS 1.0.0-beta.2**, a safe-graphics entry +(`nomodeset`, firmware framebuffer), a text-mode entry and verbose boot +diagnostics. The live NVIDIA entry and its second kernel are gone: the screen +draws with the CPU through any KMS driver or simpledrm, and the installed system +still selects its own NVIDIA driver. + +No desktop session starts. tty1 shows `nerdos-installer`, a Slint software +renderer drawing directly to KMS/DRM in a logind seat session as the +unprivileged `nerdos-installer` user. The medium has no live account, +compositor, display manager, sudo or SSH. The root `nerdos-installerd` service +keeps the draft and drives the existing worker (`nerdos-install --json-ui`), +whose validation, disk-identity recheck and erase guard are shared with the +terminal and automated installers. The screen owns no state; it is restarted +after a keyboard-layout change and resumes from the service's next snapshot. +tty2 is a root recovery shell; the menu also opens the text installer. + +The visible flow is welcome/system check, **Disk**, **You**, **Review**, +installation progress and completion. Advanced choices are on demand: +applications, the NVIDIA driver choice, serial numbers, Wi-Fi, keyboard, +language, installation details and saving the log to USB. The username field +only accepts `[a-z][a-z0-9-]`, folding capitals and accents with a visible note; +the optional full name becomes the account description. Reserved names come +from the evaluated default system. Passwords are entered twice. The computer +name follows the username until edited. The UI is English-only. + +Time zones come from tzdata's `zone.tab`, countries and backward links, with +search by city, country, alias, word or offset, a map with day/night shading, +the live local time and the next clock change from the TZif data. The zone is +detected from the connection unless chosen manually; a manual choice is never +overridden. + +The medium carries the free part of the default desktop as an offline payload +(store paths of a representative default installation without its per-machine +initrd); unfree groups and NVIDIA drivers still download. The image is +6.63 GiB, against 9.54 GiB for the previous live-desktop GUI image. + +## Build and source checks + +The exact frozen source passed the ISO build and the Nix `parity`, `lifecycle`, +`system`, `product`, `nvidia-evaluation` and `xps-system` checks. The lifecycle +check passed 547 unit tests, including the installer worker and the new +installer-service tests (review, re-review and token-bound install without a +password in any snapshot or saved draft; confirmation and reserved names; +keyboard restart; prompt shutdown with a connected screen; idle-worker +cancellation; attached-screen reporting), and 21 system-settings tests. The +screen's 19 Rust unit tests (name filter, time zones, map, zoom) passed in its +Nix build. + +`checks.vm` failed in the Beast's sandboxed build. Its new local Software step +(staging `hello`, part of separate uncommitted Software/cleanup work in this +checkout) evaluates the VM's exact system derivation, which depends directly on +`stdenv-linux`. Neither the test node's registered closure (2,360 paths) nor the +prebuilt candidate or `hello` contains it, so Nix in the VM planned 2,219 +bootstrap derivations and failed without network access. Reproducing the +in-VM derivation on the host gave the identical path. The installer change did +not alter that test or its closure; the step depends on the VM reaching a +binary cache. That check's earlier pass was recorded in a different environment. + +Image/source parity passed for the completed ISO: 39 checks covering the +bundled source and input sources, the installer screen binary, service and +network helper, the offline payload's system path and `/etc`, release and +installer files, the desktop/updater packages and the boot profiles. + +## Actual installation qualification + +Both runs used the exact ISO and source identity above, UEFI/KVM, 12 GiB RAM, +four CPUs, NAT networking, an 80 GiB disposable disk and the standard +application selection. They ran the frozen source's +`scripts/qualify-installations --interactive`, which drives the real tty1 +screen with QMP keyboard and absolute-pointer input. The serial console only +observes the service's snapshots. + +| Check | Unencrypted install | Encrypted install | +| --- | --- | --- | +| Medium runs only the installer screen and service; no live account, compositor or display manager | Passed | Passed | +| Development consent by Enter; only disk preselected | Passed | Passed | +| Encryption switch by pointer click | Passed, turned off | Left on (default) | +| Typed `Nerd QA` and `NerdQA`; service received `nerdqa`, `nerdqa-nerdos` | Passed | Passed | +| Both passwords typed; reviewed plan matched the screen | Passed | Passed | +| Erase click to completion (capture times, 20 s polling) | Passed, 4 min 44 s | Passed, 5 min 4 s | +| Installed boot, serial password login, source identity | Passed | Passed | +| Runtime encryption state, desktop configuration and health, SDDM login | Passed, plain root | Passed, LUKS2 root | +| Two password changes, reboots and keyring unlock | Passed | Passed, new disk key accepted and old key rejected | +| Staged updater failures, interruption, apply, trial boot, confirmation, rollback | Not run | Passed | + +The time zone was detected from the connection as `Europe/Amsterdam`. The real +welcome, disk, account, review, progress, completion and installed-desktop +captures were inspected. + +The encrypted system additionally passed the actual staged updater: + +- Independent upstream discovery and complete staging passed while the + unpublished NerdOS endpoint accurately reported HTTP 404. Nixpkgs was already + current at the revision above, so that candidate did not change packages. +- A personal baseline was staged, applied, trial booted, authenticated and + confirmed. Deliberate evaluation and build failures retained the active + system and working lock. Killing a real staging worker during a build, then + rebooting, produced the expected interrupted state and a healthy desktop. +- A local candidate was staged, applied, trial booted, authenticated and + confirmed, then rolled back through the real updater. Personal files, + service state and preferences passed preservation checks throughout; the + candidate-only file disappeared after rollback. + +## Findings from real tests + +These were found by booting actual images and fixed before the image above: + +- Boot-time units that conflicted with `getty@tty1` never started. Wants-only + conflicts inside the boot transaction make systemd drop one side before any + kernel command-line condition is checked. Boot-time units no longer conflict; + only the on-demand text-installer switch does. +- Pointer clicks were ignored where a `FocusScope` was declared above a + `TouchArea`. Buttons, switches and picks now declare their focus scope first. +- In a 6 GiB VM the default out-of-memory policy stopped the whole service and + reset the screen. The service now continues so the worker's failure is shown. + A stop also closes connected screens before waiting for the server. +- The qualification harness matched its own shell with `pgrep -f quickshell`, + and its failure diagnostics needed Python, which the medium no longer has. +- The harness restarts the service (to add a serial console to the QA system) + and then pressed Enter before the screen had reconnected; the key reached no + page. The screen now identifies itself in its `hello`, snapshots report + attached `screens`, and the harness waits for one. +- One earlier encrypted lifecycle run failed upstream discovery on the GitHub + API's unauthenticated rate limit for the Beast's shared address. This is an + external limit and reports as unreachable, not as an update. + +Two earlier frozen sources (NARs `sha256-PhyJnCs…` and `sha256-4dOCnrk…`) were +built and partially qualified while fixing the last three items; neither image +was published. + +Development observations on earlier iteration builds, not repeated on the image +above: a German keyboard choice restarted the screen with +`XKB_DEFAULT_LAYOUT=de` (physical Z/Y typed `yz`), the menu's text installer and +recovery shell took over tty1/tty2, and a BIOS boot showed the UEFI-required +stop screen with a restart action. + +## Delivery and cleanup + +The completed ISO and checksum were verified in task staging on the Beast's +root filesystem and again after moving them into the served ISO tree, with +mode 0644. A full HTTP download matched the SHA-256 above, and the companion +checksum was downloadable. iVentoy refresh returned `result: success`; its +status moved from `refreshing` to `running`. The image tree lists the new +filename as image 8 beside the four existing NerdOS images, which were +preserved. `iventoy.service` was active; no restart was needed. + +Only the requested ISO (7,120,576,512 bytes) and checksum (128 bytes) are +retained from this task in `/data/pxe/iso`. The task's staging directory +(sources, crate build directory, VM disks, extracted image payloads, captures +and logs) was removed, along with four task-built Nix-store ISO outputs and +the iteration VM runner (27.2 GiB). Normal shared Nix build dependencies remain +cached. No task VMs, test processes or mounts remain on the Beast or the +development host, and neither has user-image output under +`~/.local/share/nerdos/images/`. + +## Not covered + +Physical AMD/Intel/NVIDIA display output, touchscreens, real HiDPI panels and +display zoom, an unmodified LAN PXE boot of this image, Wi-Fi joining, USB log +saving, non-US keyboard layouts and the text installer on this exact image, +signed production-channel updates and Secure Boot remain outside these checks. +The documented XPS camera and hardware/security release blockers remain in +force. diff --git a/docs/measurements/installer-kms-latency-2026-10-24.md b/docs/measurements/installer-kms-latency-2026-10-24.md new file mode 100644 index 00000000..8d6f39e3 --- /dev/null +++ b/docs/measurements/installer-kms-latency-2026-10-24.md @@ -0,0 +1,193 @@ +# Installer KMS rendering latency — 2026-10-24 + +The user still experienced delayed typing and a visibly trailing mouse pointer +on the preceding installer-input ISO, on a MinisForum X1 Pro with AMD 370. +They also observe pointer lag in its BIOS. Its monitor model, resolution and +refresh rate are unknown. This record separates a measured installer rendering +cost from unmeasured physical input/display latency. + +## Cause and change + +The pinned Slint 1.18.1 KMS software backend has three dumb scanout buffers. +Steady-state buffer age is three, but its software renderer only recognises +ages one and two for partial rendering. It consequently repainted the entire +screen on pointer movement, typing and other small updates. Pixel blending +also reads the destination directly in mapped display memory, which can be +write-combined or uncached on physical graphics. The backend exposes that +memory property; its software renderer did not use it. + +The Nix package now renders into one aligned cached RAM frame. It reuses that +frame for partial rendering and copies only current physical damage plus the +previous two frames' damage to scanout, without destination reads. New buffers, +unknown ages and layout changes receive a complete cached frame. Pitch, padding, +rotation coordinates and all existing 16/32-bit formats remain supported. +Native resolution, software KMS, hardware drivers and firmware graphics support +are preserved. The added RAM frame uses approximately 32 MiB at 3840×2160×32-bit. +No disk-writing or shared installation/lifecycle behaviour was changed. + +`installer/kiosk/kms-shadow.rs` is copied into the build's private Cargo vendor +copy by `nix/packages/installer.nix`; shared dependency sources are not edited. +`SLINT_KMS_NO_SHADOW_BUFFER=1` restores the preceding direct-rendering path for +controlled comparisons. It is unset in the normal image. + +## Repeated runtime comparison + +A disposable QEMU/KVM UEFI guest booted the preceding ISO on the Beast, an AMD +Ryzen Threadripper 9970X with 64 logical CPUs. The guest had four vCPUs, 12 GiB +RAM, virtio KMS, a USB absolute pointer and PS/2 keyboard. Both 3840×2160 and +1280×800 were verified as the actual display dimensions. The tested package +was exposed through a read-only host store and guest-only writable overlay. +Only a transient guest unit override selected it. No installation ran. + +Each resolution used the same binary in direct/cached/direct/cached order. +Each run injected 720 pointer movements over six seconds, followed by 16 +pointer input-to-visible observations and ten typed-character observations. +Old pointer regions cleared, every typed character appeared, the daemon draft +ended with the expected character, and every case ended in `phase: setup`, +`diskTouched: false`. Both cases passed. `SLINT_DEBUG_PERFORMANCE` used +`refresh_lazy,console`; full-speed mode would invalidate partial-rendering work. +ISO compression was not running during these comparisons. + +Renderer CPU time came from the actual UI PID's `/proc/PID/stat`, at 100 ticks +per second. The service's `CPUUsageNSec` was unsuitable because PAM moves the +renderer into its logind session scope. Repeated runs gave: + +- **3840×2160:** direct 5.17/5.22 CPU-seconds; cached 0.17/0.20 CPU-seconds. + Mean CPU utilisation fell from **86.58% to 3.08% of one core**, a **96.44%** + reduction in renderer CPU work for the same six-second motion workload. +- **1280×800:** direct 0.86/0.96 CPU-seconds; cached 0.12/0.11 CPU-seconds. + Mean CPU utilisation fell from **15.17% to 1.92% of one core**, an **87.36%** + reduction. + +The small-region VNC observer introduces emulator/display polling delay. +Pointer medians were approximately 31 ms at 4K and 30 ms at 1280×800 in both +paths. Typing samples showed strongly repeated polling patterns: per-run +medians were 184/162 ms direct and 184/114 ms cached at 4K; 183/113 ms direct +and 183/183 ms cached at 1280×800. These observations verify eventual pixels +and cursor clearing; **they do not establish lower physical input latency**. +The robust measured improvement is renderer CPU work. A VM also does not +reproduce a physical GPU's mapped-memory read penalty. No acceleration factor +for that additional cost is claimed. + +Harness corrections before the recorded comparisons included reusing QEMU's +single QMP connection, moving the visible pointer outside the text sample, +and measuring the real process instead of its original service cgroup. These +were harness problems. Product code did not change during the comparisons. + +## Exact source and package + +The source was frozen before this record was written. All package, updater and +image checks use this same source; no lock was refreshed. + +- Frozen source: `/nix/store/ds8ymmaq78n9a67dsvv1vqlzy2lqns8h-source`. +- Source NAR: `sha256-+mDh5Q7JTUrdalLjRlBz/B7LsGaKVtZSJtT6zEMJNI8=`. +- Installer wrapper: `/nix/store/y3s83rf945wfaalvgzbwabbcb9w6004v-nerdos-installer-1.0.0-beta.2`. +- Screen: `/nix/store/ms2ri2ing818rg7ihxd5hhwn8b9h0g57-nerdos-installer-screen-1.0.0-beta.2`. +- The release package build passed all **30 Rust tests**: 20 unit, six rendered + interaction and four presentation tests. The latter compile the exact copied + module and cover three-buffer cursor freshness, partial byte counts, missing + history, unknown ages, reset, clipping, row padding, 16/32-bit layouts and + layout changes. +- The shared installation-contract check passed. + +This remains an unreleased development image. Physical AMD hardware, BIOS/display +latency, Secure Boot, a full disk installation, installed login/encryption and +end-to-end PXE boot are not qualified by these VM and parity checks. The workstation +was not deployed, partitioned or rebooted, and no release was published. + +## Completed image + +- Filename: `nerdos-1.0.0-beta.2-20261024-installer-latency-fa60e1e50ec9.iso`. +- Size: **7,120,576,512 bytes (6.63 GiB)**. +- SHA-256: `592506a365fb0437f61f4563cd40e373dacfef6f8c4fe20d4bd02e0e64b747e1`. +- Nix output: `/nix/store/4qjigppb2q7rijhnvh7z69z5mal6sl0r-nerdos-1.0.0-beta.2-x86_64-linux.iso`. +- All **39 completed-image/source parity checks** passed against the exact NAR + above, including the installed screen binary, source identity, shared contract, + locked inputs, updater, offline payload and UEFI/BIOS boot identities. +- The completed image booted directly in a separate 3840×2160 QEMU/KVM UEFI + guest. No package was injected, no host store was mounted, and no guest UI unit + override was used. Its release metadata matched the exact NAR above; the UI + logged `Using Software renderer (cached RAM with damage copies)`. +- Direct-image runtime exercised 720 pointer events, 16 observed moves with + old positions clearing, and ten observed typed characters. Both services stayed + active, the UI PID's warning journal was empty, the final character was `a`, + and the final state was `phase: setup`, `diskTouched: false`. An actual 4K + display capture was visually inspected. No installation was started. + +## Actual staged updater + +The same-source lifecycle driver passed its full original test script in +**640.28 seconds** under TCG, including real software and maintenance-policy +candidate staging to `ready`. It preserved declarations, the machine lock, +personal sentinel and running maintenance policy. Nothing was applied. The +script also exercised desktop validation and the splash/password UI. This is +staged-updater evidence, not an applied update or fresh installation. + +- Driver: `/nix/store/q22g5is5jnqn2wqar3is9r0v5mz21cmj-nixos-test-driver-nerdos-lifecycle`. +- Original script: `/nix/store/ia89b6ga5ks1jg6wlb6v6fp472p5zp9n-test-script`. +- Script SHA-256: `501b4cb05ce16109b557b1d8e62a9a4a112a23f3cd78e0fb28a618779be4e5c2`. +- A task wrapper verified this digest and executed the unchanged script. It + supplied guest-only NAT/DNS immediately before the offline machine-lock + command, after the original network-profile assertions. All original + assertions remained intact. The software request completed in 423.86 seconds; + the remaining policy preview and preservation assertions passed afterward. +- The driver used `QEMU_OPTS=-no-user-config -machine accel=tcg`, + `QEMU_NET_OPTS=restrict=off` and a private `XDG_RUNTIME_DIR`. Its VM was stopped + and its runtime directory removed after completion. + +An initial invocation required creating the output directory before the driver +would accept it. A subsequent KVM attempt did not expose its backdoor test shell +and was stopped after 131.76 seconds; its cause was not determined. The full +script then passed under TCG. The separate KVM ISO boots above succeeded. A +read-only disposable disk copy was used to inspect the staging phase while TCG +was slow; it reported `building` with no error, and was removed immediately. + +## PXE handoff + +On **thebeast (`10.10.0.7`)**: + +- ISO: `/data/pxe/iso/nerdos-1.0.0-beta.2-20261024-installer-latency-fa60e1e50ec9.iso` + (**7,120,576,512 bytes**). +- Checksum: the same path plus `.sha256` (**130 bytes**). +- [Download ISO](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261024-installer-latency-fa60e1e50ec9.iso). +- [Download checksum](http://10.10.0.7:16000/iso/nerdos-1.0.0-beta.2-20261024-installer-latency-fa60e1e50ec9.iso.sha256). + +The completed ISO was copied outside the served directory, synced and compared +with the original build's SHA-256 before being linked atomically into place. +Both served files have mode 0644. iVentoy 1.0.41 refresh returned +`result: success`, completed to `running`, and listed the new filename. +`iventoy.service` was active. A full streamed HTTP download matched SHA-256 and +the downloaded checksum file matched too. No service restart was used. + +The preceding image was initially held because client `10.10.1.64` had NBD +connections and its SSH port was unavailable. After the user confirmed that +client was **powered off**, removed: + +- `/data/pxe/iso/nerdos-1.0.0-beta.2-20261024-installer-input-9aff22041052.iso` + (**7,120,576,512 bytes**) and its **128-byte** checksum. + +The remaining image's SHA-256 was verified before removal. The first refresh +request timed out after 30 seconds; a subsequent status request reported +`running`. Retrying refresh returned `result: success`, completed to `running` +and listed **only the new ISO**. `iventoy.service` remained active without a +restart. The served directory contains only the new ISO and its checksum; the +removed image and all five older images from the preceding handoff are absent. +Physical latency on the MinisForum remains the next useful test. + +## Cleanup + +Removed remote staging `/data/tmp/nerdos-kms-latency-20261024-Sy0Mpo` and local +staging `/tmp/nerdos-kms-latency-20261024-6lyjxcjl`, including private vendor +copies, source archives, test harnesses, sparse VM disks, captures, logs and +intermediate evidence. Both staged-test runtime directories were removed; +process and mount checks found no task VM or host mount. Neither host has a +`~/.local/share/nerdos/images` directory. Normal shared dependency/source caches +remain; the exact frozen source above remains a historical validation identity. + +After checking the independent served copy, deleted the duplicate Nix ISO output +through Nix: **one output removed, 6,790.71 MiB freed**. The output path recorded +above is historical. After the confirmed client shutdown and older-image +removal, the served directory uses **6.7 GiB** (`du -sh`), consisting only of the +new testing ISO (**7,120,576,512 bytes**) and its **130-byte** checksum. Their +paths are listed in the PXE handoff. No disposable image or unresolved diagnostic +capture is retained. diff --git a/docs/measurements/installer-logging-iso-2026-10-23.md b/docs/measurements/installer-logging-iso-2026-10-23.md new file mode 100644 index 00000000..b5f61e90 --- /dev/null +++ b/docs/measurements/installer-logging-iso-2026-10-23.md @@ -0,0 +1,81 @@ +# Installer failure logging test ISO + +The installer now opens its persistent installation-log dialog automatically +when its worker fails. The failure message appears above selectable plain-text +output, which refreshes every two seconds. The dialog remains available after +the worker exits and explains that the live-session log must be copied before +restarting or powering off. **Show installation details** also remains +available during installation. The full private log is +`/run/nerdos-installer.log`; the dialog reads its most recent 256 KiB. + +This follows the earlier +[installer and assistant checks](installer-assistant-2026-10-23.md). The +original powered-off machine's `nixos-install` failure is still undiagnosed; +this image makes the next attempt's diagnostics accessible. + +## Exact source and validation + +- Frozen source: `/nix/store/hkp9835w76h3bpaal4cryphv0p044hl5-source`. +- Source NAR: `sha256-Ue7EnecNzOwd8Bv5eIlshAZCc+RYtHKMfvsf2uU0b8A=`. +- Revision: `uncommitted`, version `1.0.0-beta.2`. This is a development + testing image and includes the checkout's pre-existing work. +- All 34 installer Python tests passed. The actual native Qt/controller test + passed automatic log opening on failure, visible failure context, failed + diagnostic reads and recovery, persistent selectable output, timezone + behavior, account validation and the close guard. No disk worker ran in + that UI test. +- The ISO built from this source. The shared installation-contract check and + all 36 completed-image/source parity checks passed, including source, + packages, installer frontend/QML, worker, release identity and boot profiles. +- The actual staged updater passed the complete original VM test script in + 143.05 seconds with temporary guest-only internet access. Software and + maintenance candidates reached `ready`; configuration hashes, locks, + personal content and the running policy remained intact. Nothing was + applied. Driver: + `/nix/store/ddy1hjzck9jvkl6hxnrp57cabvpk95qa-nixos-test-driver-nerdos-lifecycle`. + Test-script SHA-256: + `184891bfefc2dd3aa930f51bcfeb119caa4a060e5cb85c1d5a61e17acdf5c736`. +- The runner verified the unchanged original test script. The writable + snapshot still had the same NAR afterward. The checkout matched the frozen + source before writing this evidence record. `git diff --check` passed. + +The completed ISO was not booted or used for a fresh installation in this +task. Package/UI, parity and staged-preview checks do not qualify physical +hardware, a successful fresh installation, Secure Boot or an applied update. +No running workstation was deployed, partitioned or rebooted. + +## Retained testing deliverable + +On **thebeast (`10.10.0.7`)**: + +- ISO: + `/data/pxe/iso/nerdos-1.0.0-beta.2-20261023-installer-logs-51eec49de70d.iso` + (10,241,521,664 bytes; 9.54 GiB). +- Checksum: the same path plus `.sha256` (127 bytes). +- SHA-256: + `7434c4ac8ee8986cc3779a8956c6d0dfda4d8bccf8aaeb608d246f5e63ebfdb6`. + +The complete image was copied and checksum-verified outside the served ISO +directory, then linked into place atomically. Its served checksum was checked +again before refreshing iVentoy. The first status poll timed out during the +new-image scan; the subsequent refresh explicitly returned +`{"result":"success"}`, with status moving from `refreshing` to `running`. +`get_img_tree` contained the new filename and both existing images, and +`iventoy.service` was active. No service restart or existing-image replacement +was needed. The ISO and checksum are retained for the next diagnostic +installation attempt. + +## Cleanup + +Cleanup removed `/tmp/nerdos-installer-logging-EWEThf` on the Beast (339 MiB) +and `/scratch/tmp/john/nerdos-installer-logging-5ar2hvt0` locally (11 MiB), +including source copies, VM disks, screenshots and logs. Parity extraction +cleaned itself up. The duplicate task-built ISO output +`/nix/store/p7hbzn7amjkv5lssjhh6mab2s5wpn100-nerdos-1.0.0-beta.2-x86_64-linux.iso` +was deleted through Nix after verifying the served copy, freeing 9.5 GiB. +Shared Nix dependencies stay cached. + +Checks found no remaining task processes or mounts and confirmed both source +staging directories and the separate copy staging directory absent. Neither +host had a `~/.local/share/nerdos/images` directory. The only retained task +artifacts are the testing ISO and checksum listed above. diff --git a/docs/measurements/pxe-boot-2026-10-23.md b/docs/measurements/pxe-boot-2026-10-23.md new file mode 100644 index 00000000..06937718 --- /dev/null +++ b/docs/measurements/pxe-boot-2026-10-23.md @@ -0,0 +1,121 @@ +# PXE boot investigation — 2026-10-23 + +Historical beta.1 record: both images below were superseded and removed at the +user's request. See the [beta.2 results](installer-graphics-beta2-2026-10-23.md) +for the current testing image, follow-up fixes and qualification limits. + +The reported physical failure is a corrupted display after selecting NerdOS on +a Minisforum X1 Pro 370 (Ryzen AI 9 HX 370 / Radeon 890M). The photograph does +not provide a readable kernel error or establish which boot stage failed. + +## Confirmed defects and changes + +The original Beast image was +`nerdos-1.0.0-beta.1-20261023-ec5fa706073a.iso`. At inspection its SHA-256 matched +the previous handoff: `fe2b8739bd0b2ef030cd80d99716810f29fbec35e94a9969a78aee35bf305f1b`. +Its integrity and the running PXE service do not establish successful PXE boot. + +- The generated GRUB and ISOLINUX menus use `/boot//nix/store/...` paths. + iVentoy 1.0.41 logs `Failed to split path` and `not found in ISO file` for + the initrd, and records `Initrd:0`. A direct optical-disk VM boot does not + exercise this parser. The ISO module now canonicalizes these paths in the + BIOS menu, external EFI menu and menu embedded in the EFI boot image. +- The ISO's early module set lacked the X1 Pro's Realtek RTL8125 Ethernet + driver (`10ec:8125`, `r8169`). The initrd now carries common wired NICs and + the packet-socket/NBD modules used for network-backed media. This preserves + the ordinary ISO and the same fresh installer. +- At the user's request the ISO moves from Linux 6.18.57 to 7.2.11. The existing + stable Nixpkgs pin (`da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36`) provides 7.3 + as `linuxPackages_latest`, but its ZFS 2.4.4 packages support only through + 7.2. Evaluating the all-filesystems ISO with 7.3 fails on the broken ZFS + kernel package. The image uses the pinned 7.2 series to preserve filesystem + support; neither `allowBroken` nor a driver-disable workaround is used. +- `scripts/check-image-parity` now checks exact canonical kernel/initrd paths + in both boot menus and requires the embedded EFI menu to match. Its developer + shell includes `mtools` for reading the embedded menu. + +These are confirmed packaging and PXE preparation defects. Their correction +alone does not prove that the physical display problem is fixed. Physical AMD +graphics, firmware/Secure Boot and end-to-end LAN PXE boot still need exercise. + +The source snapshot was frozen before writing this evidence record. Existing +workspace changes are included in it; unrelated work was preserved. The original +testing image was preserved until the user requested cleanup during the beta.2 +follow-up. No workstation deployment or release publication is part +of this task. + +## Testing deliverable and exercised checks + +- Beast: `/data/pxe/iso/nerdos-1.0.0-beta.1-20261023-k7.2.11-68a754171686.iso`. +- Size: 6,191,474,688 bytes (5.77 GiB); accompanying `.iso.sha256`: 120 bytes. +- SHA-256: `b5ea6e9d3110ef50ae40007e5afd44e99045a38dd55467e4d35f3455075f8acc`. +- Exact source NAR: `sha256-aKdUFxaGI3VM1lf1l2aNWTDbJfT0CF7VKH54XKEiP0s=`. +- This historical ISO and its checksum have since been removed. + +The sandboxed Nix build and shared installation-contract parity check passed. +The extended `scripts/check-image-parity` passed against this exact frozen +source and completed ISO, including both menu paths and the embedded EFI menu. +`lsinitramfs` independently found `r8169`, RTL8125 firmware, `af_packet` and +`nbd` in the completed 7.2.11 initrd. + +The completed ISO booted from a virtual optical disk under UEFI/KVM in a +disposable QEMU guest. The guest reported Linux 7.2.11 and the exact NAR above; +the source bundled in the ISO hashed to that identity. The normal managed +desktop health check passed at the start and end, including the sole managed +Quickshell process and its current journal, compositor/workspace/plugin probes. +The desktop configuration check and active installer welcome passed, SSH was +inactive, and ZFS loaded successfully. A real framebuffer capture showed the +desktop and the development-installation consent prompt. No installation or +disk erasure was performed. This boot check completed in 35.04 seconds. + +The ISO and checksum were verified before entering the served tree, and the +final served files passed checksum verification. A full HTTP download and the +downloadable checksum also matched. iVentoy refresh returned `result: success`, +completed with PXE status `running`, listed the new filename alongside the +original image, and `iventoy.service` was active. Its scan now reports +`Initrd:1` / `INITRD:1` for the new image; the original still reports zero. +No service restart or firewall change was needed. + +## Failed updater check and remaining qualification + +The same-source `checks.x86_64-linux.vm` was actually exercised and **failed** +at the supervised package-staging request. Its isolated, offline guest tried to +fetch missing build dependencies, including Python 3.14.4, Bash and Binutils +sources, and failed with `Could not resolve hostname`. The updater reported +`Staging failed; working system retained`. The package and cleanup previews +did not complete, and no candidate was applied. This run does not establish a +successful staged update; the earlier image's successful updater evidence +cannot be attributed to this source snapshot. + +Physical Minisforum boot/display recovery, end-to-end LAN PXE, installation, +installed trial boot/apply/confirmation/rollback and Secure Boot remain +unqualified. The corrected iVentoy scan and successful optical-disk VM boot +are separate observations, not a physical PXE success claim. + +### Physical retest and follow-up diagnosis + +The user booted the 7.2.11 image and confirmed **identical corruption**, after +the separate NerdOS GRUB menu and before any desktop. The 16:03:41 iVentoy log +confirms image 3 (the corrected image), not the older image, was selected. + +A subsequent disposable UEFI/KVM PXE guest reached the real iVentoy service, +selected image 3 and booted Linux. Serial output then recorded a 90-second +timeout waiting for `/dev/disk/by-label/NERDOS_INSTALL`, followed by emergency +mode. This used isolated QEMU user networking with its own DHCP/TFTP and a +task-owned loopback HTTP relay; no LAN DHCP or PXE configuration was changed. +It reproduces a network-media failure, not the physical AMD screen corruption. + +The installed iVentoy 1.0.41 payload's NixOS hook only inserts +`ventoy_disk.sh` before the words `mount the root` in `/init`. That script sets +up the NIC and NBD installation disk. The image uses systemd stage one, whose +`/init` is not that shell script; the hook installs no systemd unit. Finding the +initrd and carrying its NIC modules did not make this setup run. The live module +now selects NixOS's upstream scripted initrd, while installed machines retain +systemd stage one. Early native graphics and explicit diagnostic/text entries +are also being added; these changes require their own image and boot checks. + +The beta.1 testing images/checksums were subsequently removed at the user's +request; beta.2 and its checksum are the retained testing deliverables. This +historical evidence record remains. Task-owned source snapshots, intermediate +ISO copies, VM disks, extracted stores, temporary logs and framebuffer captures +are removed after recording the results. Unrelated shared Nix build cache remains. diff --git a/docs/measurements/software-defaults-cleanup-2026-10-23.md b/docs/measurements/software-defaults-cleanup-2026-10-23.md new file mode 100644 index 00000000..5e32e2eb --- /dev/null +++ b/docs/measurements/software-defaults-cleanup-2026-10-23.md @@ -0,0 +1,90 @@ +# Software, default applications and cleanup: development checks + +These checks ran on 2026-10-23 in a disposable Nix 2.35.4 Podman environment +on the Debian development host. They are development evidence, not release or +physical hardware qualification. The running workstation was not deployed, +reconfigured or rebooted. No ISO was built or published. + +- `tests/run`: 450 Python discovery tests, 1,344 JavaScript tests passed, 19 + JavaScript tests skipped; the remaining helper, ownership, theme, plugin and + model-usage checks also passed. After the final status/recovery refinements, + the focused lifecycle, Software, default-app and maintenance run passed 89 + tests, including 12 cleanup cases. +- `tests/qml-lint`: 363 product files and four test harnesses passed, with only + the existing Qt/Quickshell type warnings. +- `nix flake check --no-build`: all declared derivations, product/hardware + configurations and templates evaluated. The desktop and updater packages + built with their install checks. The final `checks.lifecycle` rebuild passed + all 454 discovery tests and its additional integration helpers. + `checks.parity` passed the shared installation contract without building an ISO. +- Real Nix evaluation indexed the machine's effective locked package set, + including nested package sets while skipping throwing Nixpkgs attributes. + Selected-package inventory correctly distinguished Software ownership from + the same package also declared elsewhere. +- Default-app tests used real GIO, desktop-entry registration and + `xdg-terminal-exec` in disposable XDG directories, with capture applications. + Browser/private/web-app routing, terminal arguments, terminal editors with + file URIs, external MIME choices, missing applications and unrelated MIME + preservation passed. They do not qualify real browser or terminal rendering. +- The actual Quickshell engine rendered Software, Applications and Storage + offscreen using fixture backend snapshots. This was not a live desktop test. + Background-request errors, concurrent selection edits and cleanup-policy + draft baselines also have executable regression coverage. The final focused + Settings/UI run passed 40 tests and the Dutch translation catalogue check + passed. Abrupt worker exits release the cleanup UI and expose the durable + recovery journal; automatic and boot-recovery workers are distinguished. +- `tests/nixos-native` passed real Nix root registration, system-profile + generation deletion, stale-review rejection, shared-root/pin preservation + and garbage collection in a private store. Saved personal edits and records + survived. Bootloader and health commands in that test are fixtures. +- `checks.vm` passed in a booted NixOS VM under TCG (536 seconds). The real + authenticated request/supervised worker path staged `hello`, built its system, + ran the desktop checker and closure diff, and produced a reviewable preview. + The candidate contained `hello`; the working declarations, personal sentinel + and locks stayed unchanged. A second real preview staged an opt-in cleanup + policy and preserved those same files. An unprivileged account could observe + both requests through completion. This exercised staging and review, without + applying either candidate. The final declaration-status, stopped-worker and + draft-discard refinements followed that VM source snapshot and passed the + relevant checks above; they were not rerun in the VM. + +Full package apply/trial-boot/confirmation/removal/rollback qualification and +cleanup across an interrupted real bootloader operation remain required on the +release source identity. Secure Boot, hardware and actual weekly timer/recovery +behavior are not qualified by these checks. These original checks used an +off-by-default cleanup policy; the subsequent default change is recorded below. + +Disposable containers, stores, VM disks, logs and screenshots are removed after +recording the results. Source tests and this record remain in the repository. + +## Follow-up: weekly cleanup enabled by default + +At the user's request, fresh installations now enable weekly cleanup in the +shared NixOS module, personal template and Settings defaults. Retention remains +five confirmed deployments and 30 days; explicit personal opt-outs are retained. + +- The 12 focused maintenance tests and three Settings state tests passed. + Automatic cleanup now has coverage with the enabled default, an explicit + opt-out, an occupied operation lock, unresolved recovery, an unhealthy system + and an unconfirmed first boot. +- `tests/nixos-native` passed automatic cleanup using the default policy in a + private real Nix store. Pins, shared roots, retained profile generations and + personal edits survived. Bootloader and health commands remain fixtures. +- QML lint passed 363 product files and four harnesses, with the same existing + warnings. The expanded ISO/installed-system parity check passed the enabled + policy, weekly schedule and disabled generic Nix automatic GC. +- The booted NixOS VM read the enabled five-deployment/30-day policy from its + installed system, found the weekly timer active and manually invoked its + worker. The worker skipped its unconfirmed initial boot as intended. + Its source NAR was `sha256-yzd3bnD8giRjw/69DIdPAOHKpop09p26UiZLUJek+lI=`; + all five product default files matched the final working tree. Later changes + expanded parity assertions and documentation, without changing those defaults. +- The complete VM staged-updater check passed in 532 seconds. A real supervised + request built a cleanup opt-out with increased retention. The candidate policy + disabled automatic cleanup while the active policy stayed enabled. The working + policy, personal sentinel and locks retained their original checksums. The + package preview also passed. Neither preview was applied. + +These follow-up checks do not exercise a scheduled weekly firing or an +interrupted real bootloader cleanup. Those and physical qualification remain +separate launch requirements. diff --git a/docs/measurements/testing-iso-2026-10-23.md b/docs/measurements/testing-iso-2026-10-23.md new file mode 100644 index 00000000..31280358 --- /dev/null +++ b/docs/measurements/testing-iso-2026-10-23.md @@ -0,0 +1,72 @@ +# Testing ISO — 2026-10-23 + +The requested internal test image contains Software, Settings → Applications, +Settings → Storage & cleanup, and automatic weekly cleanup enabled by default. +It was built in an isolated Nix 2.35.4 Podman environment. The workstation was +not deployed, reconfigured or rebooted, and no release was published. + +## Artifact and source + +- Beast: `10.10.0.7:/data/pxe/iso/nerdos-1.0.0-beta.1-20261023-ec5fa706073a.iso` +- Size: 6,179,346,432 bytes (5.75 GiB). +- Download: +- Checksum: +- SHA-256: `fe2b8739bd0b2ef030cd80d99716810f29fbec35e94a9969a78aee35bf305f1b`. +- Exact source NAR: `sha256-7F+nBgc6ux6YKtSI6jwCB7KI5PO6KDiNqbzNoQ31fNM=`. +- Release metadata: NerdOS 1.0.0-beta.1, revision `uncommitted`, NixOS 26.05. +- Pinned Nixpkgs revision: `da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36`. + +The frozen snapshot contains all 1,272 tracked and untracked source files, +including the existing workspace changes. This handoff record is written after +freezing the source and is not inside that snapshot. The complete source is +bundled in the ISO and checked against its NAR identity. + +## Checks actually exercised + +- The ISO build and shared ISO/installed-system parity check passed. +- `scripts/check-image-parity` passed against this exact frozen source and the + completed ISO: source NAR, locked input sources, runtime/QML, installer, + templates, updater and boot menu system identities matched. +- The completed ISO booted under UEFI in a disposable QEMU/TCG VM. Its source + identity, normal managed desktop health, sole shell process, current shell + journal, desktop configuration, enabled five-deployment/30-day cleanup policy + and active automatic-cleanup timer passed (136.1 seconds). +- The same-source staged updater VM passed in 644.57 seconds. It read the + enabled cleanup default, found the timer active and observed its worker skip + an unconfirmed initial boot. Real supervised requests staged a package and a + cleanup opt-out, built and reviewed both candidates, and preserved the working + declarations, personal sentinel and machine-owned locks. Neither was applied. +- All three focused JavaScript state tests passed. The real Qt/Quickshell engine + constructed the production Storage page and passed its default-policy, + draft-isolation and discard checks in an isolated offscreen harness. +- QML lint passed 363 product files and four harnesses, with existing warnings. +- The broader offscreen lifecycle harness did not pass: its Revealer timing and + recovery-search-anchor checks failed, and its hard-coded `/usr/bin/printf` + commands are absent in the Nix container. Those failures remain unresolved; + this is not a claim that the complete QML lifecycle harness passed. +- Both the transferred file on the Beast and a complete HTTP download matched + the recorded SHA-256. The downloadable checksum file matched too. +- iVentoy 1.0.41 refresh returned `result: success`, completed from `refreshing` + to `running`, and listed this filename with the failed intermediate absent. + `iventoy.service` was active; its existing LAN firewall was preserved. + +The first ISO exposed a real startup failure: `StoragePage.baseline` overrode +Qt's final Item anchor property. Renaming it to `policyBaseline` fixed startup; +the stored lifecycle harness now constructs the real page to catch this class +of error. The failed image is removed and is not a testing deliverable. +The initial iVentoy restart loop was caused by its empty ISO directory. Its +missing log directories were restored for diagnosis; placing the verified ISO +allowed the saved PXE configuration to run normally. + +## Scope and retained artifacts + +This is an unreleased testing image. The live installer offers the bundled +development build when started interactively. No disk installation, installed +trial boot/apply/confirmation/rollback, physical hardware, Secure Boot or actual +weekly timer firing was qualified by this task. The source VM stages and reviews +candidates without applying them. + +Retain only the requested ISO and its 112-byte checksum on the Beast, plus this +small repository evidence record. Task-owned build containers, images, source +snapshots, local ISO copies, VM disks, extracts, logs and screenshots are removed +before handoff. No pre-existing images or unrelated workspace files are removed. diff --git a/docs/native-system-settings-research.md b/docs/native-system-settings-research.md deleted file mode 100644 index 894cf5ec..00000000 --- a/docs/native-system-settings-research.md +++ /dev/null @@ -1,194 +0,0 @@ -# Integrating system settings into CybexOS - -Research date: 2026-09-24. Repository baseline: `834c1f0`. -Status: research baseline. The recommendations below describe the repository -before implementation. See [the implemented settings integration](native-system-settings.md) -for the delivered behavior, validation and remaining coverage. - -**Recommendation.** Add Network, Sound and Online Accounts pages to the existing -CybexOS Settings window. Reuse the current shell services and let NetworkManager, -PipeWire/WirePlumber and GNOME Online Accounts (GOA) remain responsible for system -state. Keep GNOME Settings installed during the transition. Its removal is a -separate milestone after both shell and application entry points are covered. - -The first implementation should deliver Sound settings and shared navigation. -In parallel with that work in the roadmap, validate the Fedora-packaged standalone -GOA frontend as the account setup fallback. Full native account authentication -should not block improvements to everyday system controls. - -**What exists today.** Paths below are relative to -`roles/desktop/files/quickshell/` unless stated otherwise. - -| Area | Existing implementation | Remaining external dependency | -| --- | --- | --- | -| Network | `Common/WifiState.qml`, `Common/NetworkDetails.qml`, `scripts/network-tool.py`: radio/scanning, connect/disconnect/forget, hidden Wi-Fi, DNS presets/custom DNS, band selection, QR sharing and diagnostics | `Popovers/WifiPopover.qml` and `NetworkOverlayWindow.qml` launch `nm-connection-editor`, falling back to `gnome-control-center network` | -| Sound | `Common/Audio.qml`, `Popovers/Drawer/DrawerSound.qml`: output/input selection, mute/volume, microphone meter and playback application volumes; routing helpers move existing streams | The drawer launches `pavucontrol`, falling back to `gnome-control-center sound`; no general card profile/port editor | -| Online accounts | `Common/Calendar.qml` and `scripts/calendar-events.py` read calendars through GOA and Evolution Data Server (EDS) | `Calendar.manageAccounts()` opens GNOME Settings with a process-local `XDG_CURRENT_DESKTOP=GNOME` override | -| Settings window | `Settings/SettingsView.qml`, `Common/Settings.qml`, `Common/SettingsSearchData.js` already provide navigation, search, page routing and focus behavior | No dedicated Network, Sound or Online Accounts pages | - -On the inspected Fedora 44 workstation, `nm-connection-editor` is installed and -`pavucontrol` is absent. Neither is explicitly selected in the shared desktop -package task or desktop RPM. `pulseaudio-utils`, which supplies the already-used -`pactl`, is explicitly selected only by the XPS hardware role. Portable settings -must declare their dependencies rather than inherit the builder/workstation's -package history. - -ISO package changes need attention in two places: `image/cybexos-desktop.spec` -and `roles/desktop/tasks/main.yml`. `image/applications:package_names()` also -imports that desktop task's package list into the ISO application payload. -Changing the RPM dependency alone would therefore leave GNOME Settings in the -image. GNOME Calendar is already selected through that shared task. - -**Shared integration design.** Use three pages, tentatively `NetworkPage.qml`, -`SoundPage.qml` and `AccountsPage.qml`, within the existing Settings window. Add -their identifiers to the page allowlist, navigation, loaders and search index. -Route menubar actions to `Settings.showSetting()` so search and popovers reach -the same controls. Preserve the compact popovers for quick changes. - -Keep device/profile/account data in shared service models, outside `shell.json`. -That file should retain presentation preferences only. Network edits need a -draft, validation and explicit Apply/Cancel; sound sliders can apply live. -The Settings window's current global “Saved · applies live” status must not -claim that a pending system operation succeeded. Show operation-specific pending, -failed, applied and rollback states. Shell reset/undo must not erase system -connections or accounts. - -Use the existing Python/JSON helper pattern for capabilities unavailable in QML. -Pass structured input through stdin, use bounded requests and sanitized errors, -and correlate replies to the request/device that initiated them. Run as the -session user and let system services enforce authorization through their normal -polkit path. Start listeners on demand, share them across views and release them -when no consumer remains. Reuse current services before introducing a general -settings daemon or a second settings application. - -**Network: extend the working implementation.** The first page should expose -physical interfaces and saved profiles, connection status, autoconnect, metered -status, per-profile DNS, and IPv4/IPv6 automatic/manual configuration. Preserve -existing QR, band and diagnostics behavior. Current DNS presets modify multiple -saved physical profiles; present that as an explicitly broad operation and keep -it distinct from editing one connection. - -For profile editing, add a focused Python PyGObject/libnm adapter. The installed -`NM 1.0` namespace supports connection validation, `RemoteConnection.update2`, -profile version IDs and checkpoint operations. Preserve the full original -profile, change only supported fields and reject concurrent edits. NetworkManager -updates replace the supplied settings map, so constructing a partial profile -risks losing unrelated configuration. Ordinary settings reads exclude secrets; -preserving secret flags and stored credentials needs dedicated tests. -[Connection update contract](https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.Settings.Connection.html), -[libnm connection API](https://networkmanager.dev/docs/libnm/latest/NMRemoteConnection.html). - -For changes to an active connection, use a NetworkManager checkpoint with a -rollback timeout and a “Keep changes” action. This lets NetworkManager restore -connectivity if the UI closes or crashes. Verify saved-profile and runtime -restoration on Fedora 44; do not assume a successful helper exit proves both. -Keep the checkpoint limited to affected devices. -[Checkpoint API](https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.html). - -Keep `nm-connection-editor` as a clearly named advanced editor initially. VPN -plugins, bridges, VLANs, complex routes and enterprise certificate configuration -need more than a basic form. The current helper creates enterprise Wi-Fi with -PEAP/MSCHAPv2; this is not complete enterprise support. A new enterprise editor -must handle CA certificates and server identity validation before claiming that -coverage. Do not expand this first task into a full NetworkManager replacement. - -**Sound: the best first native page.** Reuse `Audio.qml` and factor the drawer's -device/application controls into shared components. Add hardware profile selection -(for example Bluetooth headset versus playback mode), available ports, channel -balance, per-application mute and explicit application routing. Keep existing XPS -speaker tuning behavior: its virtual output and physical volume-control sink are -intentionally different. - -The installed Quickshell build exposes writable channel volumes, stream nodes, -default devices and a peak monitor. Its installed QML type declarations do not -expose a `PwDevice` card-profile API. Upstream documentation from another version -must not be assumed to match this build. -[Quickshell node API](https://quickshell.org/docs/v0.2.1/types/Quickshell.Services.Pipewire/PwNode/). - -Use native QML bindings for existing controls. For card profiles, ports and -stream routing, a small helper using `pactl --format=json list ...` and explicit -argument arrays fits the existing PipeWire-Pulse stack. Refresh capabilities -after hotplug/profile changes and resolve current IDs before mutations; do not -persist transient numeric IDs. Subscribe to changes while the page is open. -Declare `pulseaudio-utils` in the shared package contract. `wpctl` offers profile -and route setters too, but mixing both backends for the same control would add -unnecessary state coordination. -[pactl reference](https://github.com/pulseaudio/pulseaudio/blob/v17.0/man/pactl.1.xml.in), -[WirePlumber control API](https://pipewire.pages.freedesktop.org/wireplumber/man/wpctl.html). - -Retain an explicitly packaged `pavucontrol` advanced fallback until the page -covers the functions users need. Replacing the GNOME fallback is useful even -before every advanced control is native. Do not add meters that keep recording -after the page closes, or rebuild the microphone meter for every monitor. - -**Accounts: native management, delegated authentication.** Keep GOA, EDS and -GNOME Keyring. A CybexOS account page can list provider/identity, show whether -attention is needed, toggle calendar integration and remove a local account -through GOA. Removal should explain that other applications sharing the account -are affected; it does not delete the remote Google account. - -Build its model using Python `gi.repository.Goa`/Gio and GOA object/property -notifications. Return only display metadata and action results to QML. Do not -read access tokens just to display account status or copy them into shell -configuration. The installed GI namespace exposes account properties through -GObject properties; generated C getter names are not all Python methods. -Use an asynchronous D-Bus Properties.Set with completion/error handling for -changes, and verify it against the installed version. -[GOA interface contract](https://github.com/GNOME/gnome-online-accounts/blob/3.58.1/data/dbus-interfaces.xml). - -Two API distinctions change the implementation plan: `Manager.AddAccount` -accepts credentials and account details; it is not a browser-sign-in command. -`EnsureCredentials` checks/refreshes existing credentials and can report expired -authorization; it is not an interactive reconnect dialog. -[AddAccount API](https://gnome.pages.gitlab.gnome.org/gnome-online-accounts/method.Manager.call_add_account.html), -[EnsureCredentials contract](https://github.com/GNOME/gnome-online-accounts/blob/3.58.1/data/dbus-interfaces.xml). - -| Authentication option | Assessment | -| --- | --- | -| Launch `gnome-online-accounts-gtk` | Best initial fallback. Fedora 44 packages the standalone GTK frontend with GOA, GTK4 and libadwaita dependencies; it does not list GNOME Settings as a dependency. Validate real Google sign-in/reconnect on Hyprland before switching. | -| Small CybexOS C/GTK4 helper around libgoa-backend | Best later integration if direct Add/Reconnect actions are needed. Host GOA's provider dialogs in a separate process; keep account overview and controls in QML. Requires building and maintaining a small version-sensitive native helper. | -| Implement a separate OAuth/calendar stack | Defer. It adds ownership of OAuth client setup, callbacks, refresh/revocation, credential storage and calendar integration for little immediate benefit. | - -Sources: [Fedora 44 standalone frontend](https://packages.fedoraproject.org/pkgs/gnome-online-accounts-gtk/gnome-online-accounts-gtk/fedora-44.html), -[XApp project](https://github.com/xapp-project/gnome-online-accounts-gtk), -[GOA provider API](https://github.com/GNOME/gnome-online-accounts/blob/3.58.1/src/goabackend/goaprovider.h), -[Google desktop OAuth requirements](https://developers.google.com/identity/protocols/oauth2/native-app). - -The GOA 3.58.1 provider API takes a `GtkWidget` parent for asynchronous Add, -Refresh and Show operations. A QML window cannot simply be passed as that GTK -parent. `GoaBackend` introspection is unavailable on the inspected host, while -`Goa` introspection is available. Therefore do not plan a pure Python/QML call to -the provider UI. The small compiled helper should own its GTK window, cancellation -and completion; browser/provider UI stays outside Quickshell. This is a researched -architecture, not a validated sign-in prototype. - -There is an application-level dependency beyond the shell: GNOME Calendar's -new-calendar and edit-calendar pages call its GNOME Settings launcher for online -accounts. Before removing `gnome-control-center`, adapt those entry points through -a maintained application change, replace the relevant application workflow, or -retain GNOME Settings. Do not install a fake `gnome-control-center` executable -or impersonate GNOME's settings D-Bus service. -[New-calendar entry point](https://github.com/GNOME/gnome-calendar/blob/gnome-50/src/gui/calendar-management/gcal-new-calendar-page.c), -[Existing-account entry point](https://github.com/GNOME/gnome-calendar/blob/gnome-50/src/gui/calendar-management/gcal-edit-calendar-page.c). - -**Delivery sequence and acceptance.** - -| Milestone | Deliverable | Evidence required | -| --- | --- | --- | -| 1. Shared routing and Sound | Native Sound page, reusable controls, explicit helper dependencies and standalone advanced fallback | Hotplug, Bluetooth profile changes, default and existing-stream routing, XPS filter preservation, microphone lifetime and backend failure tests | -| 2. Network profiles | Native common connection editor, per-profile DNS, checkpoints and advanced editor | DHCP/manual IPv4/IPv6, preservation of unknown settings/secrets, concurrent edits, polkit denial, rollback after lost UI, multiple adapters and unsupported profile handling | -| 3. Online Accounts | Native account overview/actions; standalone GOA frontend for Add/Reconnect | Existing-account compatibility, browser return/cancel, expired authorization, locked keyring/autologin, offline state, GOA restart and calendar enable/remove behavior | -| 4. Direct sign-in integration, if warranted | Small GOA provider-dialog helper | Build/runtime compatibility with target GOA, duplicate-window prevention, cancellation, focus/parenting, service failure and clean process exit | -| 5. Package removal | Remove explicit GNOME Settings dependencies only once remaining launchers are covered | Shell and application launcher audit, clean Fedora dependency resolution, installed package manifest and ISO first-boot verification | - -Extend existing audio, network, calendar and Settings tests around behavior and -failure cases. Run the repository checks before deployment. Future live shell -tests must use `tests/lib/quickshell-live` at start/end and leave the service-owned -Quickshell as the sole instance. Source checks cannot qualify OAuth sign-in, -hardware routing or a clean ISO installation; report those separately. - -Installed versions inspected: Quickshell -`0.2.1^git20260209.dacfa9d-5.fc44`, NetworkManager `1.56.1`, GOA `3.58.1`, -GNOME Settings `50.4`, WirePlumber `0.5.17`, PulseAudio utilities `17.0`. -These are workstation observations, not a future ISO manifest. Space savings -and the dependency closure after removal remain unmeasured. diff --git a/docs/native-system-settings.md b/docs/native-system-settings.md deleted file mode 100644 index c99a377c..00000000 --- a/docs/native-system-settings.md +++ /dev/null @@ -1,376 +0,0 @@ -# Native system settings - -CybexOS Settings includes Network, Sound, Displays, Keyboard, Touchpad, Region, -Online Accounts and Security. The -Wi-Fi popover, network details, sound drawer and calendar account action open -these pages. Settings search includes them too. The compact controls remain -available. - -## Delivered behavior - -| Page | Native controls | External support | -| --- | --- | --- | -| Network | A list of saved Ethernet/Wi-Fi profiles with their status, and the physical adapters; VPN and WireGuard profiles with a connect switch each, import of OpenVPN/WireGuard files and confirmed removal; a Wi-Fi hotspot with its name, password and join QR code; for the selected profile, autoconnect, metering, automatic/manual/disabled IPv4 and IPv6, address, gateway and DNS; edits wait in the page's Apply bar | `nm-connection-editor` for other VPN types and detailed VPN settings, enterprise certificates, bridges and advanced routing | -| Sound | Output and input device lists (network outputs folded), volume and mute, microphone level meter; ports of the default devices, output balance, hardware profiles, and per-application level, mute and playback/recording routing | `pavucontrol` for advanced controls | -| Online Accounts | Provider, identity and attention status; calendar enable/disable; confirmed local removal; administrator locks | `gnome-online-accounts-gtk` for provider setup, browser authentication and reconnection | -| Displays | Arrangement preview that also selects the display to edit, with drag and keyboard placement; per display: on/off, resolution, refresh rate, scale, rotation, flip, mirroring and adaptive sync; a 15-second trial before anything is saved | `~/.config/cybexos/hypr/user.lua` for monitor rules the page does not cover (bit depth, HDR, reserved areas) | -| Keyboard | Search installed XKB layouts and variants; add, remove and reorder up to four layouts; configure switching shortcuts and switch immediately | Personal `user.lua` rules retain final precedence | -| Touchpad | Tap to click, natural scroll direction, three-finger swipe between workspaces and pointer sensitivity, drafted behind Apply; existing scroll speed control applies immediately | Pointer sensitivity is Hyprland's shared mouse/touchpad setting; per-device rules remain available in `user.lua`. The swipe is a gesture, saved as `touchpad.workspaceSwipe` in `input.json`; a `user.lua` gesture on the same fingers replaces it | -| Region & formats | Search installed timezones and locales, then explicitly apply system timezone or language; existing clock and temperature format controls | Normal system Polkit authorization; additional locales require Fedora language packs | -| Security | Fingerprint reader and enrolled fingers, guided enrolment with live progress, confirmed removal, the system fingerprint switch; registered security keys, adding by touch, confirmed removal | fprintd's own Polkit authorization for enrolment; pkexec for the authselect switch; a terminal for keys that need a PIN to register ([details](security.md)) | - -GOA continues to own account authentication and credentials. The shell reads -metadata and never requests access tokens or passwords. Calendar data still -comes from Evolution Data Server. Disabling calendars or removing an account -affects other applications using that account on this computer; removal does not -delete the remote account. - -GNOME Settings remains installed for GNOME Calendar's own account-management -entry point. CybexOS's settings entry points no longer launch it or impersonate -a GNOME session. Removing GNOME Settings, replacing GNOME Calendar, replacing -the login manager and building a new ISO are separate work. - -## State and failure handling - -NetworkManager, PipeWire/WirePlumber and GOA own their settings. None of the new -device, connection or account data is stored in `shell.json`. Shell reset/undo -does not change these services. System pages show a status line only while -their first read is in flight and after a failure, which offers Refresh. -Network and Displays edits wait in an Apply bar pinned to the page foot. It -names the pending changes, or the problem that blocks them, and during a -trial counts down to the automatic restore with **Revert now** and **Keep -changes**. Sound changes apply at once. - -Network edits clone the full libnm connection and validate the result. Version -checks reject concurrent changes. Unedited settings, routes, secret flags, -stored Wi-Fi credentials and secondary addresses in DHCP profiles are preserved. -Unsupported IP methods remain accessible through the advanced editor. - -Applying an active profile creates a 60-second NetworkManager checkpoint and -changes only the in-memory profile. **Keep changes** saves the profile and ends -the checkpoint. **Revert now**, leaving the page or closing Settings restores the -previous settings. NetworkManager also owns the timeout if the UI or helper -exits. Inactive profiles save without activation. A trial requires user -confirmation of connectivity; activation alone does not prove internet access. - -The sound helper re-resolves capabilities before changing them. Application -streams require their PipeWire serial as well as their transient index, so an -index reused by another stream is rejected. Internal filter streams are excluded -from routing controls, and the XPS speaker filter retains its existing gain -policy. Hardware and stream rows keep stable identities during refreshes. - -`Common/SystemSettingsBackend.qml` owns one request channel per domain. Its -consumers acquire and release service monitors; monitors and snapshot readers -stop when no page needs them. Requests use bounded JSON through stdin with -explicit argument arrays, timeouts and sanitized errors. Network mutations use -the session user's normal NetworkManager authorization path. - -The shared desktop Ansible package task and `image/cybexos-desktop.spec` declare -the standalone editors, libnm, GLib tools and PulseAudio client utilities. The -image application payload consumes the shared task's package list too. - -## VPN and hotspot - -The Network page has a **VPN** group and a **Hotspot** group between the -connection list and the selected connection's settings. The drawer's Network -tab adds one compact VPN switch under Tailscale, but only when at least one -VPN profile exists: the connected or connecting profile, else the one used -most recently. Tailscale keeps its own row and is not a VPN profile here. - -Both surfaces use `scripts/network-tool.py` (`vpn` and `hotspot` requests, -JSON on stdin). The Settings page reaches it through the network settings -service (`system_settings_network.py`), so VPN and hotspot requests share the -page's one request channel and busy state. Their failures appear on the row -they concern rather than as the page's status line, and they never touch a -pending connection edit. - -### VPN - -- **List.** NetworkManager profiles of type `vpn` (OpenVPN, OpenConnect, - vpnc, SSH and other installed plugins) and `wireguard`, with their type and - state, from the same `nmcli connection show` the network snapshot already - runs. Naming each plugin costs one extra `nmcli` read on the Settings page. -- **Connect.** CybexOS runs no network applet, so nothing would answer - NetworkManager when a VPN needs a password. For one user-requested - activation, `scripts/network_vpn_agent.py` registers a secret agent on the - system bus, activates the profile from the same process (NetworkManager - asks that process's agent first), and answers only that profile's `vpn` - secret request by running the plugin's own sign-in dialog, the - `auth-dialog` from its `NetworkManager-*-gnome` package that nm-applet also - uses. Every other request gets `NoSecrets`, so another agent can still - answer it. Secrets go from the dialog straight into the D-Bus reply; they - are never logged, written to disk or passed to the shell, and `SaveSecrets` - is declined, so a profile keeps its own secret storage. The agent - unregisters when the activation succeeds, fails, is canceled or times out - (50 seconds). Failures are reported from NetworkManager's reason: a - canceled sign-in, a rejected login, a missing plugin, no network to run - over, and so on. -- **Import.** *Import VPN…* opens a file picker (the Settings folder dialog in - file mode, starting in Downloads) for an OpenVPN `.ovpn` or a WireGuard or - OpenVPN `.conf` file of at most 1 MiB. The helper only sniffs the format, - then runs `nmcli connection import type openvpn|wireguard file …`. - NetworkManager names a WireGuard profile and interface after the file, so a - file name that is not a valid interface name is imported through a - symlink with a valid name in a private temporary directory (the key is - never copied) and the profile is renamed after the file. Imported profiles - get `connection.autoconnect no`; NetworkManager starts an imported - WireGuard profile at once, so the import also takes it down. `nmcli`'s - diagnostics are classified, never shown, because a parser error can quote - a line of the file. -- **Remove.** *Remove…* asks on a second row; only the confirming press - sends `confirmed: true`, and the helper refuses a removal without it, or - for a profile that is not a VPN. -- **Advanced editing** stays in `nm-connection-editor`. - -### Hotspot - -The group appears only on a computer with a Wi-Fi adapter. It is one switch, -the network name and, while the hotspot is on, *Show password and QR code*. - -- The hotspot is one NetworkManager profile, `CybexOS hotspot` with a fixed - UUID, created on first use: access-point mode on 2.4 GHz, WPA2-PSK (CCMP, - PMF off for the widest client and driver support), `ipv4.method shared`, - `connection.zone nm-shared` and `connection.autoconnect no`. It never - starts at boot; the switch starts it again. -- The password is generated (16 characters from an alphabet without - look-alike characters, about 79 bits) and reaches `nmcli` only through - `passwd-file` on stdin, like a Wi-Fi password. NetworkManager stores it in - the profile on the first start and reuses it afterwards. The Settings page - never holds it: the join card, the network overlay's QR page, asks the - helper for the code and the password together and drops both when it - closes. Change the password in the advanced editor. -- The adapter is chosen to avoid dropping Wi-Fi: an idle second adapter that - can host an access point is preferred. NetworkManager runs one connection - per adapter, so on a single adapter the Wi-Fi connection drops while the - hotspot runs; the group says so before it is switched on. An adapter - without access-point support, or Wi-Fi switched off, disables the switch - with the reason. -- Renaming a running hotspot restarts it under the new name. - -### Firewall - -Shared mode runs NetworkManager's DHCP and DNS server (dnsmasq, which no -Fedora package pulls in, so the desktop package task and the RPM's -`Requires` both install it) for the clients and NAT for their traffic. NetworkManager puts a shared connection in firewalld's -`nm-shared` zone, which accepts DHCP, DNS and ICMP to this computer and -forwards client traffic; the strict `cybexos` zone (target `DROP`) would -refuse the clients' DHCP and DNS. The hotspot profile therefore names -`nm-shared` explicitly. The checkout installer's adoption of physical -profiles into the `cybexos` zone skips profiles whose `ipv4.method` is -`shared`, and the ISO never rebinds profiles, so both installation paths -leave the hotspot (and any other shared connection) in `nm-shared`. -`tests/verify-system` and the VM convergence check expect exactly that. The -`cybexos` zone template itself is unchanged. - -## Displays - -The Displays page lists every output Hyprland reports, including disabled -ones. The arrangement is drawn in Hyprland's layout coordinates. A dropped -display snaps to the nearest shared edge (at least 64 logical pixels long) and -never overlaps another. Changing a display's size keeps its neighbours on the -side they were on. The arrangement also picks the display the rows below -edit. Each display is a Tab stop: Enter or Space selects it, and arrow keys -place it beside the others. A display that is off or mirroring takes no room -in the layout and appears as a chip under it, so it can still be selected. -Scale presets are limited to values Hyprland keeps -for the chosen mode, that is multiples of 1/120 that divide the mode into -whole logical pixels. - -**Apply** in the page's Apply bar starts a trial: - -1. `scripts/display-settings.py` writes the candidate to - `$XDG_RUNTIME_DIR/cybexos/displays-trial.json`. -2. It arms a transient `systemd-run --user` timer - (`cybexos-display-trial-`), which restores the previous settings - after 20 seconds. -3. It runs `hyprctl eval 'require("displays").apply_trial(...)'`, so the - trial goes through the same Lua that loads the saved file at startup. - -If the timer cannot be armed, nothing is applied. If Hyprland rejects a rule, -the helper undoes the change immediately. - -The Apply bar counts down 15 seconds: - -- **Keep changes** saves the candidate atomically to - `~/.config/cybexos/displays.json` and disarms the timer. A confirm is - refused, and the change undone, when the saved file changed since the page - read it or when it arrives more than three seconds after the countdown. -- **Revert now**, Escape, the countdown ending or closing Settings reload - Hyprland, which rebuilds every rule from the unchanged files on disk. The - timer does the same if the shell is not running. - -The saved file is JSON owned by the user, keyed per physical monitor: - -- `desc:` plus Hyprland's make/model/serial description, so choices follow a - monitor across ports. -- The connector name when that description is empty or could match another - connected monitor, for example twin panels without serials. - -Entries for monitors that are not connected are kept. - -When a connected monitor moves from a connector key to its physical identity, -Apply merges the old entry into the physical entry and removes the connector -alias. This includes early files whose connector entries have no description. -Unknown fields survive, with the physical entry taking precedence. Keeping -both entries could silently undo an edit: `displays.lua` emits sorted keys, -and Hyprland's [monitor rule manager](https://github.com/hyprwm/Hyprland/blob/v0.56.2/src/config/shared/monitor/MonitorRuleManager.cpp) -chooses the last matching rule. A stale `eDP-1` entry therefore overrode the -new `desc:LG Display ...` position, scale and mode. Connector entries explicitly -describing other hardware are left intact. - -Live verification on Hyprland 0.56.2 reproduced an Apply requesting the -built-in display at `(0, 540)` while the stale connector rule kept it at -`(0, 0)`. With the alias migrated, Apply and Keep placed it at `(0, 540)` and -the layout survived a reload. A 160% / 60 Hz trial applied and reverted to -200% / 120 Hz; an unattended arrangement trial also restored through the -systemd watchdog. The repaired preferences work with the installed runtime. - -`roles/desktop/files/displays.lua` reads the file with a strict, size- and -depth-bounded JSON parser and never executes it: - -- It is loaded from `hyprland.lua` in a `pcall`, after the vendor `monitors` - module and before `~/.config/cybexos/hypr/user.lua`. -- A missing file changes nothing. An invalid one is ignored as a whole and - reported in `$XDG_RUNTIME_DIR/cybexos/displays-status.json`, and the page - shows that error. The vendor rules then stay in effect. -- Each saved rule starts from the vendor rules that match the same monitor, - which the vendor module records in `_G.__cybexos_vendor_monitor_rules`. This - keeps fields such as bitdepth, colour management or a vendor VRR policy - unless the user chose another value. -- Every field the page owns is written explicitly, so a trial never inherits - a previous trial's value. A reload retires the module's event subscriptions. -- Hyprland applies monitor rules last-first, so saved choices override vendor - rules and `user.lua` overrides both. - -A display turned off on this page records the displays that were on beside it -(`disabledWith`). It stays off only while one of those companions is -connected and on. `displays.lua` re-evaluates this on `monitor.added` and -`monitor.removed`, so undocking a laptop whose panel was turned off beside an -external monitor turns the panel back on. A display is only turned off while -a different one remains on. - -Not covered: per-dock profiles for different sets of monitors, HDR and -bit-depth controls, and custom modelines. Use `user.lua` for those. - -## Validation and maintenance - -### Keyboard, touchpad and system region - -Input preferences are user-owned data in -`$XDG_CONFIG_HOME/cybexos/input.json` (normally `~/.config/cybexos/input.json`). -Only edited fields are saved. Unknown top-level, section and unchanged-layout -metadata survive; version hashes reject concurrent writes. A malformed file or -symlink is left untouched and explained in Settings. The helper takes an -exclusive lock, atomically writes the new file, and reloads Hyprland. A rejected -reload restores the original bytes and reloads again; failure of that recovery -is explicitly reported. It never rewrites `user.lua`. - -The shared `input_preferences.lua` loader uses the existing bounded JSON -parser, validates all fields before applying any, and contains corrupt-file -errors so the compositor can start. It loads after the release's input defaults -and before `user.lua`. Therefore a personal override can intentionally supersede -a saved setting. Settings reads effective compositor values when refreshed. -Layout switching retains the release's compose/third-level options; selecting -Caps Lock as a switch replaces its Compose role. Switching to the next layout -changes the current session only, while the first saved layout is the sign-in -default. Shell reset/undo does not reset these input preferences. - -Region changes go through `org.freedesktop.timedate1.SetTimezone` and -`org.freedesktop.locale1.SetLocale` on the system bus, with normal interactive -Polkit authorization. No privileged helper, password capture or custom Polkit -exception is installed. Choices come from the installed timezone and locale -catalogs. Requests reject stale values, preserve existing `LC_*` overrides, -verify the resulting properties, and report authorization denial, cancellation -or service failure. Changing the system language sets `LANG`; applications use -it after signing out and back in. Separate timezone/language Apply actions -avoid a misleading partially successful combined change. Shell clock and -temperature formats remain independent personal settings. - -`tests/native-input-region.py` exercises the production helpers with isolated -preferences and mocked compositor/system buses, including failed reload -recovery, unknown-field preservation, stale writes, invalid inputs, symlinks, -real stdin/subprocess failure handling, authorization requests and preserved -locale categories. `tests/hyprland-input` executes the shipped Lua loader under -LuaJIT. `tests/qml/tst_input_draft.qml` exercises the pages' draft, reorder and -filter functions with Qt's JavaScript engine. These tests do not claim a live -authorization prompt, physical keyboard/touchpad or fresh-install validation. - -Both installation paths ship the same loader, pages and helpers, and use the -same XKB/timezone package resources. No installer applies these personal or -system region choices implicitly during reconfiguration. - -Run the normal repository gate before deployment: - -```sh -./tests/run -PYTHONDONTWRITEBYTECODE=1 python3 image/check-source -``` - -Additional integration checks: - -```sh -# Private mount/network/PID namespaces with a separate D-Bus and NetworkManager. -# Requires sudo and never edits the workstation's network profiles. -./tests/system-settings-network-namespace - -# After deploying through Ansible: uses only the managed quickshell.service. -./tests/system-settings-live -``` - -Display settings are covered without touching a live compositor: - -- `tests/display-settings.py` runs the helper against fake `hyprctl`, - `systemd-run` and `systemctl`. It covers trial, confirm, rollback, timer - expiry, rejected rules, a timer that cannot be armed, concurrent edits, and - symlinked or invalid saved files. -- `tests/hyprland-displays` runs `displays.lua` under LuaJIT with a recording - `hl` mock. -- `tests/displays/documents.json` holds valid and invalid documents that the - Lua and Python validators must agree on. -- `tests/quickshell/display-helpers.test.cjs` covers modes, scales, identities - and the arrangement. -- `tests/hyprland-features` loads the whole configuration tree with a - truncated and a hostile `displays.json`. - -The isolated NetworkManager test covers active in-memory edits, confirmation and -persistence, explicit and automatic rollback, expired confirmation rejection, -inactive-profile edits and saved Wi-Fi password preservation. Backend unit -tests also cover address validation, concurrent changes, typed D-Bus values, -capability changes, stale audio streams and account action guards. - -VPN and hotspot behaviour is covered without a real NetworkManager: - -- `tests/network-connections.py` runs `network-tool.py` and the settings - service with `nmcli` stubbed on `PATH`: parsing of terse `nmcli` output, - import argument construction (including the WireGuard symlink), file - validation, confirmed removal, hotspot profile creation with the password - only on stdin, adapter choice, and error mapping that never echoes - diagnostics. The secret agent runs against a fake NetworkManager on a - private D-Bus daemon with a fake plugin dialog: only the activating - profile's secrets are answered, a canceled dialog is reported as such, the - agent always unregisters and no secret reaches the helper's output. -- `tests/quickshell/network-vpn-hotspot.test.cjs` covers the helpers and the - structure of the drawer switch, the VPN and Hotspot groups, the join card - and the folder dialog's file mode; the real-engine lifecycle harness - constructs both groups against a hand-set snapshot. -- `image/test_installation_parity.py` runs the checkout's zone-adoption - script against a stubbed `nmcli` and checks that the ISO path never - rebinds profile zones. - -A real VPN sign-in, a hotspot client joining, and the hotspot staying off -after a reboot need hardware and are on the manual checklist -(`docs/shell-settings-manual-verification.md`). - -The managed live check opens and reopens all three pages, verifies their data -and monitor readiness, closes them and checks that readers/monitors stop. It -restores the previous Settings window state, verifies that the service owns the -sole Quickshell process, and checks the current journal for QML errors. -The `settings status` IPC method reports lifecycle flags without account or -device metadata. - -Validated on Fedora 44 on 2026-09-24: repository gate, image source checks, -isolated NetworkManager transactions, managed page lifecycle and visual layout. -The standalone GOA frontend opened its provider picker under Hyprland. Real -provider sign-in, account deletion, Bluetooth hardware profile switching and a -fresh ISO boot were not exercised. Those remain release acceptance checks; -the existing tests do not imply their completion. diff --git a/docs/native-titlebars.md b/docs/native-titlebars.md deleted file mode 100644 index b1b4972f..00000000 --- a/docs/native-titlebars.md +++ /dev/null @@ -1,190 +0,0 @@ -# Native Fedora titlebars - -The exact tested product revision is -`d2037af92cb3a47a08a9755e5996d2c4b0cae28d`; source/image gates, the exact-source -ISO/RPM build and testing PXE publication pass. All four ISO scenarios, both -English/Dutch checkout parity cases, pristine offline/native/recovery and scoped -native workspace/notification checks pass. The core aggregate and R27 evidence -audit pass within their recorded scopes; English was verified separately by -the parent. The product is **not fully qualified**: startup measurement is blocked; -first-frame acceptance remains open, and representative hardware and generic -Wayland CSD detection remain blocked. This documentation update is separate -from the tested installed-product artifacts. -Main integration is complete at `bee9d722021301875823e6285a6eef2896b283eb`; -the intervening CI-only fix 9974aaee changes no installed-product payload. -The titlebar artifacts and installed qualification remain bound to d2037af. - -The optional Floating layout titlebars are built from -`omacom/omarchy-hyprland-titlebars` tag `v0.2.2`, full commit -`379ff910fcce9f0cc9b41f9a87c527997076a417`. The authorized codeload archive -SHA-256 is `a453ae6b0a39f75e9a559a0258746e7df24a9432d5571ce505501cd0a333aa72`. -Inventory records all three values. No Arch binary is installed on Fedora. -The fixed library path is `/usr/lib64/cybexos/titlebars.so`; the native plugin -name and Lua namespace remain `hyprbars`. - -`roles/desktop/tasks/titlebars.yml` and `image/titlebars` use the same source -pin, patch, native installer and rebuild units. The image builder stages a -complete build before assembling the desktop RPM. `image/titlebars_payload.py` -rejects missing builds, changed source/recipe/patch, damaged binary, source -archive or license files. The image's desktop RPM must require the exact -Hyprland EVR recorded in `titlebars.json`, so repository changes between native -build and image composition cannot silently select another compositor build. -Every released Hyprland update therefore needs a rebuilt desktop RPM from the -same source revision as its image and qualification report. - -The native build requires CMake >=3.27, C++23, pkg-config `hyprland`, `libdrm`, -`libinput`, `libudev`, `pixman-1`, `wayland-server`, and `xkbcommon`. The Fedora -dependency task is also the image builder's dependency source. The upstream -`-fno-gnu-unique` option is retained for clean unload/reload; whole-library -hidden visibility is deliberately avoided because Hyprland shares inline state. -The original BSD-3-Clause `LICENSE` and Omarchy MIT `LICENSE.MIT` are installed -under `/usr/share/licenses/cybexos-titlebars/` on both paths. - -## ABI and update lifecycle - -`/usr/libexec/cybexos-titlebars-install` verifies identical installed Fedora -`hyprland` and `hyprland-devel` EVR/architecture, verifies their RPM-owned files, -and compares the headers' ABI string with the installed compositor's -`Hyprland --version` output. This version query does not start a compositor. -It records the binary digest, header version digest and GNU ELF build ID in -`/usr/lib64/cybexos/titlebars.json`, along with the verified source pin, recipe, -patch and output/license digests. Identical healthy builds converge offline; -damaged output or changed inputs require a rebuild. - -The patch embeds that exact compositor ELF build ID. During plugin -initialization it reads the **running process's mapped ELF notes**, so replacing -`/usr/bin/Hyprland` during an update cannot fool an old compositor into loading -a plugin built for the new executable. Missing or different build IDs fail -before assigning the plugin handle or creating plugin state/event hooks. -Upstream's source/library ABI hash -check remains in place too. The ELF guard also detects Fedora rebuilds that -retain the same source commit and upstream API hash. - -`cybexos-titlebars-build.path` watches the compositor, installed SDK identity and -managed recipe. `cybexos-titlebars-build.service` also checks at boot, rebuilding -from the checksum-verified cached source archive without network access. Builds -are locked, bounded to four compiler jobs and a 15-minute service timeout. -The service retries up to three times to cover an update transaction replacing -the binary and SDK separately. It never restarts the compositor or shell. -The native guard covers the interval before the asynchronous rebuild completes. - -A stale binary/manifest is removed before rebuilding. Missing headers, a failed -compiler, invalid source, or a compositor update during compilation leaves the -optional component unavailable instead of retaining a stale loadable plugin. -The next successful build restores it. Existing keyboard and mouse controls -remain available. A plugin already mapped by a running compositor stays mapped -until its normal unload/session end; no updater forces live desktop changes. - -Hyprland 0.56 Lua's `hl.plugin.load(path)` is a **configuration declaration**. -Keep it present on every config parse when the optional file exists, including -when the plugin is already in the native loaded registry. Omitting it on the -next parse removes a config-owned plugin, triggering another reload. Alternating -the declaration based on native loaded state causes recursive load/unload/reload -and starves IPC. Check `hl.get_loaded_plugins()` before invoking native options -and functions; use `enabled=false` to hide decorations outside Floating layouts. -An optional plugin can remain loaded while drawing no bars. - -To inspect a failed rebuild, use -`journalctl -u cybexos-titlebars-build.service`. After repairing the matching SDK -or restoring cached source through checkout convergence, use -`sudo systemctl reset-failed cybexos-titlebars-build.service` and -`sudo systemctl start cybexos-titlebars-build.service`. The cached archive and -patch live in `/usr/share/cybexos/titlebars/`. Uninstall removes only these -managed files, the helper/units, build cache and fixed plugin/manifest/licenses; -user titlebar settings and independently installed plugins are user-owned. - -## Theme API and client decorations - -The CybexOS patch adds `hl.plugin.hyprbars.clear_buttons()`. It clears the native -button vector, marks every existing bar's buttons dirty and damages the bars. -The compositor theme hook can clear then add exactly three themed buttons -without accumulating duplicate hit targets or retaining old icon textures. -Upstream `add_button`, `workspace_tag` and explicit `hyprbars:no_bar` rules keep -their existing behavior. - -**Generic automatic Wayland CSD detection is blocked on the inspected Hyprland -0.56.2 API.** `CWindow` exposes X11 border hints, but no negotiated per-window -Wayland decoration mode. `CXDGDecorationProtocol` keeps its toplevel-decoration -map private. The pinned `XDGDecoration.cpp` always configures server-side mode, -including when a client requests client-side mode. Clients such as GTK and -libadwaita applications can draw their own titlebars without using that protocol -at all. Absence of an xdg-decoration object consequently proves neither that a -client has a titlebar nor that it needs a server titlebar. - -Use explicit `hyprbars:no_bar` class rules for applications known to draw CSD, -for example exact `org.gnome.Nautilus` and `org.gnome.TextEditor` classes. -Confirm the actual class with `hyprctl clients` before adding an exclusion; -Firefox and Electron classes/settings vary. Keep these exclusions in the shared -compositor configuration on both installation paths. This is an explicit class -policy, not automatic CSD detection. Native `decorate=false`, explicit no-bar -rules, upstream X11 border opt-outs and missing-component fallback remain -supported. A future automatic solution needs a supported compositor API or -protocol-aware compositor patch and real mixed-client qualification. - -## Validation and scope - -The native source pin and qualified Fedora compositor identity are unchanged: -Hyprland `0.56.2-2.fc44.x86_64` plus matching `hyprland-devel`, source -`efb50993780079460b0cbed1363e2166a2de1d9f`, executable ELF build ID -`04b20094d13879e70824418ba363f8dcc3253b34`. The exact d2037af ISO/RPM set is -built and published for testing; artifact paths, sizes, hashes and source -provenance are in [desktop validation](desktop-validation.md). - -The exact pristine-upgrade fixture passed forced native compilation, one-line -unchanged convergence, candidate boot/runtime snapshot and recovery boot/restore. `native-offline-rebuild.json` in the recorded offline-upgrade evidence -binds the converged plugin SHA-256 -`1142347ed31dcfb67733602b8ec6d88733131e0f8140ad8c3f50c870ef24e878`. -`native-candidate-runtime.json` confirms exact clean d2037af provenance, loaded -hyprbars, Scrolling/titlebar capabilities, empty error and a Floating workspace. -The separate exact-source native workspace report now passes renamed/numbered -opening, named/manual group provenance, original-owner closure, reload and -restoration. Those stable observations do not establish first-frame behavior -or requalify titlebar pointer controls on this artifact. - -Historical isolated native tests passed compilation/offline convergence, -load/capability discovery, stable declarations/reloads, themed clear/re-add, -pointer drag/maximize/minimize/close and an actually recompiled wrong-build-ID -rejection. Those results retain their earlier product scope. Detailed native -failure/correction evidence, helper reviews, gate skips and resource history -are centralized in validation and the ledger. - -`python3 tests/titlebars.py` covers CMake/C++ fixture builds, offline convergence -and repair, same-source Fedora rebuilds, checksum/traversal rejection, -compiler/SDK failures and concurrent updates. A release-optimized native probe -checks the running-executable ELF guard and unchanged handle state after -rejection. These and payload-parity/admission checks establish local contracts; -they do not complete installed-session or mixed-client/hardware qualification. - -| Exact d2037af acceptance | Current status | -| --- | --- | -| ISO/RPM build and testing PXE publication | Passed; exact identities and publisher checks in validation | -| Installed forced native build/offline unchanged convergence, boot and recovery | Passed within the exact pristine-upgrade fixture; runtime evidence is the limited plugin/capability/Floating snapshot described above | -| Renamed opening and named/manual group lifecycle | Passed within the exact plain-US native fixture; stable observations, not first-frame/configure proof or a new titlebar pointer test | -| Settings pixels and remaining interaction coverage | Recorded encrypted-US settings/empty-overview frame review passed within scope; unsampled interactions, assistive technology and first-frame behavior remain unqualified | -| Generic negotiated Wayland CSD detection | Blocked on a supported qualified compositor API and mixed-client tests | -| Physical lid/dock/mirror/two-external/scaling placement | Blocked on representative hardware | - -The exact-class no-bar policy and keyboard/overview fallback controls remain -supported. First-frame, new-artifact pointer and unavailable hardware/CSD -acceptance remain open; general duplicate-decoration avoidance must not be -advertised as complete. [PR #21](https://github.com/DigitalPals/CybexOS/pull/21) -merged at bee9d722 on `2026-10-21T14:01:19Z` after both required hosted checks -succeeded; origin/main was verified at that merge. The missing-`patch` CI -failure, exact sole-path correction and clean R28 review are recorded in -validation/ledger, separately from d2037af artifact qualification. Startup/pixel/performance measurement is **BLOCKED** after four failed attempts, -zero accepted samples/comparison. R11 is scoped inert tooling review only. -Warm04 passed root-policy/reconciliation validation but its Welcome frame -showed black background/pointer only; null review response sent no input. -Failure preceded SSH fixture/warm-up; cause unknown, no product-regression proof. -Warm02's cause and warm03's exact offending bytes remain unknown. All leases -closed/empty. No further VM series is planned; any future benchmark needs -visibly verified Welcome/portals, a reviewed retarget and eight fresh equivalent -boots with current prerequisites. Detailed identities and guards are in validation. -Validation records R30 scoped retention acceptance, both independently verified -R29 repairs and completed teardown: all 53 recorded leases closed, MAIN absent, -no temporary VM/container retained, shared services untouched. Baseline/intermediate -payloads were removed; final testing deliverables and bounded evidence remain. -Original evidence paths are historical identities resolved through durable maps; -R30 is not runtime/release acceptance. Its historical-source limits and cleanup-time -inventory snapshot are documented in validation. -Active user desktop rollout/restart/reboot and public release remain unauthorized. diff --git a/docs/nvidia.md b/docs/nvidia.md deleted file mode 100644 index 694bc52d..00000000 --- a/docs/nvidia.md +++ /dev/null @@ -1,119 +0,0 @@ -# NVIDIA graphics - -CybexOS installs NVIDIA's proprietary driver from RPM Fusion automatically -when it finds an NVIDIA GPU. Machines without one are left alone. The driver -is never bundled in the ISO: it is built for the installed kernel after -installation, so the open-source `nouveau` driver runs the desktop until the -first restart with the NVIDIA driver. - -## What happens - -Both installation paths run the same `nvidia` role: - -- a source checkout runs it from `site.yml` (after the applications role, - `--tags nvidia` or `--tags hardware`); -- an ISO installation runs it from `cybexos-hardware-setup.timer` once the - network is up after boot, and again after every kernel change, through - `/usr/libexec/cybexos-configure-installed --hardware`. - -The role: - -1. detects display controllers (PCI class `03xx`) with vendor `10de` from - sysfs. A laptop that also has an Intel or AMD GPU is treated as hybrid; -2. installs `akmods` and `mokutil`, and creates the akmods signing key before - anything is built, so the module is signed for Secure Boot; -3. enables RPM Fusion free and nonfree with the applications role's - fingerprint-checked keys (`roles/apps/tasks/rpmfusion.yml`); -4. installs `akmod-nvidia`, `xorg-x11-drv-nvidia-cuda-libs` (NVDEC/NVENC for - FFmpeg and OBS) and `libva-nvidia-driver` (Fedora's name for - `nvidia-vaapi-driver`, VA-API decoding for browsers and mpv), plus the - running kernel's `kernel-devel` when it is still available; -5. builds the module for the running kernel with `akmods` and verifies it with - `modinfo`. `akmods` waits for the package's own background build; -6. with Secure Boot enabled, queues the akmods key for enrollment (below); -7. records the outcome in `/var/lib/cybexos/nvidia-status.json`. - -A failure in any step is recorded, shown, and never fails the rest of the -installation. RPM Fusion's `nvidia-fallback.service` loads `nouveau` whenever -the NVIDIA module is missing at boot, so a failed build, an unenrolled key or -a new kernel without a module still starts the desktop. - -Kernel mode setting: driver 560 and later enable `nvidia-drm.modeset` and -`nvidia-drm.fbdev` by default (the current RPM Fusion driver for Fedora 44 is -615). When the loaded driver reports either as off, the role adds -`nvidia-drm.modeset=1 nvidia-drm.fbdev=1` with `grubby` and asks for a -restart. An explicit `nvidia-drm.modeset=0` on the kernel command line is left -as it is. - -## Secure Boot - -CybexOS never disables Secure Boot. When it is enabled and the akmods key -(`/etc/pki/akmods/certs/public_key.der`) is not enrolled yet, the role runs -`mokutil --import` with a one-time password of eight digits (the MOK manager -uses a US keymap; digits are typed the same on every common layout). The -password is kept in `/var/lib/cybexos/nvidia-mok.json`, readable by root and -the `wheel` group only, and removed once the key is enrolled. - -To finish, restart. At the blue **MOK management** screen choose -**Enroll MOK**, **Continue**, **Yes**, type the password, then **Reboot**. -The password is shown - -- in a desktop notification, once per boot, until the driver runs; -- in the welcome window's setup list on ISO installations; -- by `sudo cat /var/lib/cybexos/nvidia-mok.json`. - -If the MOK screen times out, the request lapses and the open-source driver -stays in use. The boot-time setup does not ask again on every restart; request -it again with `cybex repair --hardware` (ISO) or -`cybex install -- --tags nvidia` (source checkout). The same key signs the -XPS camera modules, so one enrollment covers both. - -## Hybrid laptops - -The integrated GPU (the firmware's boot display device) stays the renderer -for Hyprland and applications. `switcheroo-control` is enabled, so launchers -can start an application on the NVIDIA GPU. Only `NVD_BACKEND=direct` is set -for the session; the NVIDIA-only variables below would move rendering to the -dGPU and break VA-API on the integrated GPU. - -## Session variables - -The role writes `/etc/cybexos/nvidia-session.env`. Both session wrappers -(`hyprland-quickshell`) read it as data, accept only the names below, and -apply it only while `nvidia_drm` is loaded, so a boot that fell back to -`nouveau` never inherits NVIDIA settings. The variables are exported to -Hyprland and imported into the user manager for the shell and its launches. - -| Variable | NVIDIA-only desktop | Hybrid laptop | -| --- | --- | --- | -| `LIBVA_DRIVER_NAME` | `nvidia` | not set | -| `__GLX_VENDOR_LIBRARY_NAME` | `nvidia` | not set | -| `NVD_BACKEND` | `direct` | `direct` | - -`GBM_BACKEND` is not set: Mesa selects the NVIDIA GBM backend from the DRM -driver name, and forcing it is a known cause of browser crashes. - -## Status and troubleshooting - -| State | Meaning | -| --- | --- | -| `ready` | The NVIDIA driver is loaded for the running kernel. | -| `reboot` | The driver is built; restart to switch from `nouveau`. | -| `enroll` | Secure Boot key enrollment is pending (or was skipped). | -| `failed` | Packages, the build or the key request failed; `reason` says why. | - -- `cybex doctor` reports an `nvidia` check on ISO installations; - `cybex verify` does on source checkouts. Neither prints the password. -- `/usr/local/libexec/cybexos-nvidia status` prints the recorded outcome, - corrected for a restart since it was written. -- Build logs: `/var/cache/akmods/nvidia/*-for-$(uname -r).failed.log`. -- Boot logs: `journalctl -b -u akmods.service -u nvidia-fallback.service` - and, on ISO installations, `journalctl -b -u cybexos-hardware-setup.service`. -- `cybex report` collects these into a redacted bundle for a bug report. - -Uninstalling a source checkout removes the helper and the session variables -but keeps the driver and the akmods key: the machine's graphics depend on -them. Remove `akmod-nvidia` with `dnf` if you no longer want the driver. - -Physical NVIDIA hardware, hybrid switching and the MOK enrollment screen are -not covered by the VM qualification; verify them on real machines. diff --git a/docs/omarchy-plugin-compatibility.md b/docs/omarchy-plugin-compatibility.md index dcf4a97d..98788d95 100644 --- a/docs/omarchy-plugin-compatibility.md +++ b/docs/omarchy-plugin-compatibility.md @@ -1,6 +1,6 @@ # Omarchy plugin compatibility -Cybex supports all six Omarchy plugin kinds: **bar-widget, service, panel, +NerdOS supports all six Omarchy plugin kinds: **bar-widget, service, panel, overlay, menu, and replacement bar**. Packages using the implemented host contract can run unchanged. This includes the tested upstream Pomodoro widget, Media service/widget, and Bar replacement. It is not a guarantee that every @@ -11,24 +11,22 @@ The implementation targets Omarchy commit inspected on 2026-09-19. Its `schemaVersion: 1` describes manifest structure; it does not freeze the host API across future upstream releases. -## Parity assessment and implementation (2026-09-19) +## Compatibility assessment -**The main management and runtime gaps are now implemented; compatibility is -still qualified by the limits below.** The preceding audit used a fresh -`git ls-remote` and upstream checkout to confirm that the pinned commit above is -still Omarchy's default-branch HEAD. This comparison does not cover its separate -development branch. +The implemented contract targets the exact revision above. Future Omarchy +changes require an explicit API comparison and runtime qualification; a moving +default branch is not the compatibility boundary. | Capability | Assessment against that upstream revision | | --- | --- | | Six plugin kinds, defaults, service/UI lifecycle, repeated widgets, replacement bars | Implemented, with representative fixtures and the limits below; not universal package compatibility | | Git package management | `add`, `update [--preview]`, and `remove`; candidate manifests/entrypoints are validated before installation or fast-forward. Dirty/diverged checkouts are rejected; settings and data survive removal | -| Built-in customization | `clone [--edit]`, with `--from ` for built-in manifests and `clonePaths`. Installed originals are disabled and restored when the clone is removed. Native Cybex built-ins are not replaced by Omarchy clone IDs | +| Built-in customization | `clone [--edit]`, with `--from ` for built-in manifests and `clonePaths`. Installed originals are disabled and restored when the clone is removed. Native NerdOS built-ins are not replaced by Omarchy clone IDs | | Management menus | Settings → Omarchy plugins supports trusted Git installs, enable/disable, update preview/update, clone and confirmed removal; built-in imports from an Omarchy checkout use the CLI | | Code reload | Two-second discovery refresh detects package edits and loads versioned snapshots, including relative QML/JS imports. `keepLoaded` services survive code reload; disable/re-enable or `plugin restart` recreates them | -| Shell IPC | Added configuration/theme refresh, placement and settings mutations, panel toggling, transparency and geometry methods. Mutations are queued, and Cybex schemas/validation differ; this is not exact signature/result parity | -| First-party integrations | Full bars receive narrow idle, nightlight, notification and media adapters backed by Cybex. Authentication, Omarchy commands and external backends are not supplied | -| Manifest acceptance | Stricter here, not identical. For example, Cybex requires an ID beginning with a letter, while upstream's package CLI also accepts a leading digit; Cybex also requires entrypoint keys to exactly match declared kinds | +| Shell IPC | Added configuration/theme refresh, placement and settings mutations, panel toggling, transparency and geometry methods. Mutations are queued, and NerdOS schemas/validation differ; this is not exact signature/result parity | +| First-party integrations | Full bars receive narrow idle, nightlight, notification and media adapters backed by NerdOS. Authentication, Omarchy commands and external backends are not supplied | +| Manifest acceptance | Stricter here, not identical. For example, NerdOS requires an ID beginning with a letter, while upstream's package CLI also accepts a leading digit; NerdOS also requires entrypoint keys to exactly match declared kinds | The lack of a marketplace is a local limitation, but is **not established as a parity gap** by this upstream revision. Its plugin catalogue enumerates local @@ -47,20 +45,15 @@ Comparison sources at the pinned revision: [management menu](https://github.com/omacom/omarchy/blob/60663faf8764253646f1d6166e864b608d4a0fa1/bin/omarchy-menu-plugin), and [service proxies](https://github.com/omacom/omarchy/blob/60663faf8764253646f1d6166e864b608d4a0fa1/shell/services/PluginFirstPartyServiceApi.qml). -Audit verification: `node --test tests/quickshell/*.test.cjs` completed with -736 passes, two skips (installed Qt enum checks unavailable on the Debian host), -and no failures. `tests/user-plugins.py` passed in a disposable Fedora 44 -container with Quickshell and two headless Sway outputs, including runtime -replacement/rollback and the Omarchy entrypoint suite. Eight Omarchy fixture -files matched current upstream HEAD byte-for-byte; both Pomodoro fixture files -matched their recorded commit. This audit did not repeat the full `tests/run` -pipeline or physical desktop qualification. That audit changed only this document; the implementation below adds code and regression tests. +Current NixOS validation and its limits are recorded in +[the validation record](validation.md). Historical source counts do not qualify +the current installed desktop. ## Install and configure -Install a trusted package with `cybex plugin add `, +Install a trusted package with `nerd plugin add `, then enable it, or put a complete plugin checkout in -`~/.local/share/cybexos/plugins//`. The directory must match +`~/.local/share/nerdos/plugins//`. The directory must match its manifest ID. Preserve the manifest, QML, relative imports, scripts, and assets. No manifest conversion or Omarchy installation is required. Packages are trusted executable QML running as the desktop user; they are not sandboxed. @@ -69,15 +62,15 @@ Enabling a package does not run its installer or install its dependencies. For the tested `markbus-ai/omarchy-pomodoro` package: ```bash -cybex plugin enable markbusking.pomodoro --section right --width 90 -cybex plugin set markbusking.pomodoro workMinutes 25 -cybex plugin set markbusking.pomodoro sound false -cybex plugin list +nerd plugin enable markbusking.pomodoro --section right --width 90 +nerd plugin set markbusking.pomodoro workMinutes 25 +nerd plugin set markbusking.pomodoro sound false +nerd plugin list ``` ### Declared settings -Upstream schema 1 defines no settings format, so Cybex reads an optional +Upstream schema 1 defines no settings format, so NerdOS reads an optional `barWidget.schema` array, the convention used by packages such as `digitalpals.model-usage`. The widget's settings dialog (Settings → Bar → the widget) draws each entry as an ordinary settings row, with its `label`, @@ -98,16 +91,15 @@ or whose saved value its control cannot show exactly (wrong type, an undeclared option, a number out of range), is not drawn as a row. That key, and any saved key the schema does not mention, stays in the dialog's raw JSON editor, so no saved value is hidden. The schema is presentation only: the CLI and IPC still -accept any JSON value. Cybex's own `apiVersion` manifests can declare the same +accept any JSON value. NerdOS's own `apiVersion` manifests can declare the same `barWidget.schema`; their defaults come only from each entry's `defaultValue`. Preferences apply at once. Package code refreshes within two seconds while -Settings is open and within five minutes otherwise; `cybex plugin update` -reloads at once, and `cybex plugin reload` requests an immediate scan through -the running shell. `cybex plugin restart` explicitly restarts the managed +Settings is open and within five minutes otherwise; `nerd plugin update` +reloads at once, and `nerd plugin reload` requests an immediate scan through +the running shell. `nerd plugin restart` explicitly restarts the managed `quickshell.service`. -Disable with `cybex plugin disable `; files and preferences remain. -Deploy the updated Quickshell role before using this on an older desktop. +Disable with `nerd plugin disable `; files and preferences remain. Manage packages in **Settings → Omarchy plugins**, or from the CLI. The page links the community directory at ; its Source @@ -116,12 +108,12 @@ command the directory hands out (the command words and flags such as `--enable` are dropped, so the package still starts disabled). ```bash -cybex plugin update markbusking.pomodoro --preview -cybex plugin update markbusking.pomodoro -cybex plugin clone markbusking.pomodoro personal.pomodoro --edit -cybex plugin remove personal.pomodoro +nerd plugin update markbusking.pomodoro --preview +nerd plugin update markbusking.pomodoro +nerd plugin clone markbusking.pomodoro personal.pomodoro --edit +nerd plugin remove personal.pomodoro # Import a built-in from a trusted checkout of the pinned upstream revision: -cybex plugin clone omarchy.spacer personal.spacer --from ~/Code/omarchy +nerd plugin clone omarchy.spacer personal.spacer --from ~/Code/omarchy ``` Cloning copies settings, enables the new ID and disables an installed original. @@ -130,7 +122,7 @@ state. Removing any package preserves its preferences and persistent data. Clones are editable copies without Git history; update their source explicitly. Enabling a built-in import does not install its operating-system dependencies. -Hot reload uses snapshots under `$XDG_RUNTIME_DIR/cybex-plugin-code/`, keyed by +Hot reload uses snapshots under `$XDG_RUNTIME_DIR/nerd-plugin-code/`, keyed by shell PID/start time and package fingerprint. Old snapshots remain for the session so retained services can still read their original assets; later scans remove dead sessions, and logout clears the runtime directory. Original package @@ -147,31 +139,31 @@ section's plugin space and reports overflow instead of covering built-ins. For a widget whose manifest declares `barWidget.allowMultiple: true`: ```bash -cybex plugin instance example.widget work --section left --width 100 -cybex plugin instance example.widget personal --section right --width 100 -cybex plugin set example.widget label '"Work"' --instance work -cybex plugin merge example.widget '{"label":"Personal"}' --instance personal +nerd plugin instance example.widget work --section left --width 100 +nerd plugin instance example.widget personal --section right --width 100 +nerd plugin set example.widget label '"Work"' --instance work +nerd plugin merge example.widget '{"label":"Personal"}' --instance personal ``` Named instances replace the implicit single instance and appear on each output. They inherit plugin defaults/base preferences, with independent overrides. -Remove one with `cybex plugin instance example.widget personal --remove`. +Remove one with `nerd plugin instance example.widget personal --remove`. Removing the last returns to the implicit single instance. These commands do not enable a disabled plugin; enable it separately. A plugin declaring a `bar` entrypoint can replace the native bar: ```bash -cybex plugin bar omarchy.bar --position top -cybex plugin bar omarchy.bar --position left -cybex plugin bar native +nerd plugin bar omarchy.bar --position top +nerd plugin bar omarchy.bar --position left +nerd plugin bar native ``` The selected replacement receives a catalogue and layout of enabled Omarchy widgets. Native bars are retired while it is active. A construction failure restores the native bar and exposes an error. Top, bottom, left and right are supported by the tested upstream replacement; position is its configuration, -not a change to Cybex's native bar preference. +not a change to NerdOS's native bar preference. ## Built-in Model Usage @@ -180,22 +172,17 @@ The [Model Usage](https://github.com/DigitalPals/omarchy-modelusage) plugin widget: an unchanged copy in `ModelUsage/`, hosted by a native bar module through this adapter's bar facade (see `ModelUsage/README.md`). It is placed, enabled and configured in Settings → Bar like any built-in widget, and is -updated with CybexOS; `scripts/sync-model-usage` re-vendors a new upstream +updated with NerdOS; `scripts/sync-model-usage` re-vendors a new upstream commit. An installed copy of the package is listed under Settings → Omarchy plugins with that -reason and is never loaded, so it cannot draw a second widget; `cybex plugin -enable` and `add` refuse it. Remove it with `cybex plugin remove +reason and is never loaded, so it cannot draw a second widget; `nerd plugin +enable` and `add` refuse it. Remove it with `nerd plugin remove digitalpals.model-usage`. Its settings in `plugins.json` are not carried over, but the scripts share their state and saved keys (`~/.local/state/omarchy/`, `~/.config/omarchy/model-usage/`), so re-enter only the source and server settings. -The first built-in usage widget, retired in settings schema 25, left a quota -cache (`~/.cache/quickshell/model-usage.json`) that deployment deletes. Proxy -management keys it saved under `~/.local/state/quickshell/model-usage-*.key` -are left in place; delete them yourself if you no longer need them. - ## Host contract | Area | Implemented behavior | @@ -211,14 +198,14 @@ are left in place; delete them yourself if you no longer need them. | Replacement bar | Component catalogue, metadata, layout, orientation, scoped UI controls and bar-config mutation; repeated widgets supported; omitted layout widgets hidden without stopping their services | | Menus | Application listing, basic name/subtext search, icons and launch through Quickshell desktop entries; `appsChanged` notification | | UI | All 35 QML components from the pinned upstream `Ui` directory, including forms, popups, sliders, media backgrounds and speed-test overlay | -| Theme | `qs.Commons` Style, Color, Util and Border APIs, with Cybex palette/font/rounding and upstream role names/helpers | +| Theme | `qs.Commons` Style, Color, Util and Border APIs, with NerdOS palette/font/rounding and upstream role names/helpers | | IPC | `shell` target: `ping`, `summon`, `hide`, `toggle`, `call`, `listPlugins`, `rescanPlugins`, `setPluginEnabled`, `enablePlugin`, `putBarWidget`, `moveBarWidget`, `setBarWidget`, `togglePanelAt`, `reloadConfig`, `applyTheme`, `toggleBarTransparency`, `listShellConfig`, `debugBarGeometry`; plugin-defined targets remain available | | Helpers | Bundled `omarchy-shell` forwards IPC to the already-running matching runtime; `omarchy-notification-send` uses desktop notification D-Bus | `OMARCHY_PATH` points to the bundled compatibility directory and its `bin` directory is on the shell's PATH. It is not a complete Omarchy checkout. The IPC helper supports `-q`, timeouts, and default `{}` payloads for summon -and toggle; it never starts another shell. `listPlugins` reports Cybex's +and toggle; it never starts another shell. `listPlugins` reports NerdOS's plugin descriptors and runtime errors, not an identical upstream JSON schema. Ordinary plugins receive their own registry/service/settings access. A full @@ -231,7 +218,7 @@ These API boundaries do not sandbox trusted QML. A preference change preserves running widget/service state. Changing layout, enablement, or code can recreate objects; plugins must persist state needed across those events. Native-bar Omarchy popups coordinate ownership -with Cybex popouts. Lifecycle failures are reported without intentionally +with NerdOS popouts. Lifecycle failures are reported without intentionally restarting the entire shell. Preferences and package data stay in the existing user-owned roots described @@ -246,7 +233,7 @@ menubar's color and opacity, including glass mode. Inner cards, controls and menus retain their own palette roles for contrast. Plugins drawing custom windows still own their styling. -On the native Cybex bar, shared Omarchy `KeyboardPanel` and `PopupCard` +On the native NerdOS bar, shared Omarchy `KeyboardPanel` and `PopupCard` components share the native panels’ six-logical-pixel gap from the visible menubar edge and screen sides. They remain centred on their widget (or the bar when `centerOnBar` is set), including with a bottom bar. This applies to Model Usage without @@ -256,7 +243,7 @@ plugins that create their own windows control their own placement. Native surfaces and Omarchy plugins share the font, logical-pixel sizing, accessibility scale, spacing density, border and corner settings described in -[Shell appearance](shell-appearance.md). Appearance settings apply individually; +[Shell typography](shell-typography.md). Appearance settings apply individually; there are no appearance presets. The defaults use JetBrainsMono Nerd Font at 14px, standard spacing, no panel @@ -271,7 +258,7 @@ and opacity overrides. Corner value -1 follows the shared panel corners; 0 is square. Existing explicit plugin overrides are preserved until changed or reset. Scaling affects shared Omarchy UI, including bar widgets. Advanced users can set `pluginThemeOverrides` in -`~/.config/cybexos/shell.json`, using flat Omarchy shell tokens: +`~/.config/nerdos/shell.json`, using flat Omarchy shell tokens: ```json { @@ -297,12 +284,12 @@ retain their status indications. - No automatic emulation of Arch packages, Omarchy's general command suite, Hyprland scripts, system paths, or external backends. Plugins depending on - those still need their dependencies installed or a Fedora-specific adapter. + those still need their dependencies installed or a NixOS-specific adapter. - Authentication, lock-screen/polkit services, and privileged first-party host capabilities are not supplied. Cross-plugin private service lookup is unavailable; full bars receive only the four narrow service adapters. The compatibility theme's lock/polkit colors do not implement those systems. -- A replacement bar cannot render native Cybex API 1 widgets. Hosted widget +- A replacement bar cannot render native NerdOS API 1 widgets. Hosted widget facades do not expose service objects: a widget relying on direct service access can work in the native bar yet need adaptation in a third-party replacement. This includes the tested Media widget, whose native-bar @@ -314,15 +301,15 @@ retain their status indications. - No remote marketplace catalogue. Generated settings rows cover only the `barWidget.schema` types listed under Declared settings. `applyTheme` updates the Omarchy compatibility palette/style for the session, - not Cybex's persisted theme. `reloadConfig` refreshes plugin preferences; - Cybex shell settings retain their existing file watcher. + not NerdOS's persisted theme. `reloadConfig` refreshes plugin preferences; + NerdOS shell settings retain their existing file watcher. - IPC mutations return acceptance before the serialized helper writes finish; check plugin diagnostics for persistence errors. Layout validation is stricter than upstream (for example, out-of-range indices are rejected, not clamped). Native-bar transparency/geometry is not exposed through the Omarchy adapter. - Built-in clones that have no installed source cannot restore an absent Omarchy package on removal. This host does not import Omarchy's whole built-in - catalogue or replace native Cybex widgets by matching their names. + catalogue or replace native NerdOS widgets by matching their names. - The media adapter supports player selection and basic transport; it does not reproduce Omarchy's feedback overlays or player-launch policy. - Application removal explicitly returns false. The menu application bridge @@ -334,7 +321,7 @@ retain their status indications. - Mihoro's network backend and arbitrary marketplace packages have not been qualified. The fixture results must not be generalized to every plugin. -The [vendored source notes](../roles/desktop/files/quickshell/compat/omarchy/README.md) +The [vendored source notes](../desktop/quickshell/compat/omarchy/README.md) describe upstream provenance and modifications. MIT notices accompany the runtime components and immutable test fixtures. @@ -361,29 +348,13 @@ engine checks if another `qs` is active. Task-owned engines, compositors, sockets and user roots are cleaned up. Static QML lint covers the whole shared UI kit; not every form control, media backend or gesture has runtime coverage. -Validation runs in Fedora 44 containers on a Debian build server, which has no managed -Quickshell desktop service. No persistent user plugins are installed by the -tests. A physical Hyprland session, real audio/notifications, and pointer/grab -behavior still need live qualification under the repository's sole-PID and -journal checks. Headless validation does not establish universal or future -version compatibility. - -The earlier runtime implementation passed all 16 `tests/run` source stages, including 738 JavaScript tests and -242 QML files (no lint errors; six nonfatal upstream warnings). The final -active-bar geometry change was rechecked with QML lint and the complete -real-engine plugin suite. The container and downloaded upstream sources used -for this validation were disposable; no persistent desktop deployment was made. - -Implementation verification adds `tests/plugin-packages.py` for Git installation, -preview, valid/invalid updates, dirty-checkout protection, clone restoration, -built-in imports, removal retention and snapshot identity. The Quickshell suite -also loads the Plugins settings page, exercises added IPC calls and first-party -adapter scope, and edits a relative JS import while both outputs are running to -verify UI reload and retained service identity/state. QML lint covers the new UI. -Physical audio/idle/nightlight behavior is not qualified by the headless fixture. - -For this management/reload change, validation passed with 736 JavaScript tests -(two host Qt-enum skips), all 244 QML files linted, the extended two-output engine -suite, package transaction tests, both deployment fixtures, Ruff and ShellCheck. -The full 16-stage source gate was not repeated. No persistent desktop deployment -was possible on the Debian build server, which has no installed Quickshell. +The current port's real-engine plugin suite passed in an isolated Nix build +container on a Debian host. No persistent user plugins or workstation desktop +were changed. Physical Hyprland, audio/notifications, and pointer/grab behavior +still need qualification under the repository's sole-PID and journal checks. +Headless validation does not establish universal or future version compatibility. + +`tests/plugin-packages.py` separately checks Git installation, valid/invalid +updates, dirty-checkout protection, clone restoration, built-in imports, +removal retention and snapshot identity. Current source and installation evidence +is tracked in [validation](validation.md). diff --git a/docs/operations.md b/docs/operations.md deleted file mode 100644 index e8ab05ca..00000000 --- a/docs/operations.md +++ /dev/null @@ -1,1045 +0,0 @@ -# Operating and diagnosing this configuration - -This is the authoritative operator contract for repository commands, Ansible -tags, source checks, update logs, and deliberate reboots. The short command -table in the README links here instead of duplicating these details. - -## Command behavior - -| Command | Behavior | -| --- | --- | -| `./install` | Detects machine defaults, asks first-run questions, saves `/etc/cybexos/config.yml`, installs `ansible-core` when needed, and applies `site.yml`. Later runs reuse the saved answers. | -| `./bootstrap` | Compatibility alias for `./install`. | -| `./tests/run` | Runs all required source-tree checks without inspecting or changing the live machine. | -| `./verify` | Runs complete source and non-destructive installed-system checks. Use `--source`, `--system`, or `--quick` for a narrower scope and `--json` for automation. | -| `./update` | Resolves and verifies the selected GitHub release channel without a GitHub login, applies a newer compatible release, then starts the durable Fedora/Flatpak worker. With no published release it only updates packages and succeeds; if a release cannot be checked, verified, or staged, it still updates packages and exits 69. | -| `./update --system-only` | Skips the project release check and updates Fedora packages and system Flatpaks only. | -| `cybex agent` | Launches the selected AI coding agent in the current directory; an unset interactive session opens the picker. | -| `cybex agent --pick` | Selects, persists, and launches an installed OpenCode, Claude Code, or Codex CLI. | -| `cybex dev enable PATH` | Selects a validated checkout for live desktop source without changing it. | -| `cybex dev status` | Reports the active development or vendor runtime. | -| `cybex dev disable` | Returns desktop components to the verified vendor runtime. | -| `cybex backup setup local PATH` / `setup sftp HOST [PATH]` | Sets up encrypted personal-file backups and prints the recovery key once; see [Personal-file backups](#personal-file-backups). | -| `cybex backup now` / `status` / `restore` | Backs up now, reports the last and next backup, or restores into a new folder under `~/Restored`. | -| `./uninstall` | Removes CybexOS-owned services and configuration, restores first-adoption backups, and retains installed applications. Pass `--keep-user-data` to retain backup/updater state after restoration. | -| `cybex factory-reset` | ISO installations only: after a typed confirmation and sudo, restores the factory recovery point; the next start removes every account, home folder and other recovery point and opens the new-owner setup. A checkout installation prints that it is unavailable. See [Factory reset](#factory-reset). | - -`./install` and `./uninstall` pass `--ask-become-pass` to Ansible unless sudo -works without a terminal. Ansible's workers detach from the terminal, so the -ticket that `sudo -v` caches there cannot authorize their privileged tasks. -Both take the durable updater's lock before changing the machine and exit -with status 75 while an update is running, so a playbook never interleaves -with an update's DNF transaction or configuration run. - -For repository development, run Ansible directly after the source gate. The -saved installer configuration is deliberately supplied explicitly: - -```bash -./tests/run -ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags desktop,dotfiles -``` - -`ansible.cfg` pipelines modules to the Python interpreter instead of writing -a temporary file for every task, and sets `force_handlers`. A handler notified -before a later task failed (an initramfs rebuild, a daemon-reload, a service -restart) therefore still runs; a retry would see its triggering task as -unchanged and never notify it again. - -The public release updater applies a candidate through the lower-level durable -worker with `--full --skip-tests --repo `. That internal path -always supplies `--skip-tags boot`; public updates therefore do not rebuild -Plymouth or initramfs unexpectedly. Apply reviewed boot-role changes directly -with Ansible, then allow its handler to finish before rebooting. - -New installations select the Cybex Plymouth theme, including the LUKS unlock -screen. To apply the boot theme and Fish defaults to an existing installation -after running the source gate: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags boot,shell-defaults -``` - -The `shell-defaults` tag requires the existing installation's Fish configuration -directory. New Fish shells launch `codex` with -`--dangerously-bypass-approvals-and-sandbox` (YOLO: no approval prompts or sandbox). -Use `command codex` to bypass that alias. This terminal default does not change -the `cybex agent` dispatcher or the user's Codex configuration. - -`./verify --help` is the authoritative verification interface. Scope flags are -mutually exclusive, unknown arguments fail before any check runs, and -`--require-hyprland` is accepted only when system checks are in scope. -The installed `cybex verify` and `doctor` commands default to -`--system`; pass `--source` explicitly when the developer lint toolchain is -installed. - -### Reporting a problem - -`cybex report` collects `cybex doctor --json`, versions, a hardware summary, -shell health, failed units and recent journal excerpts (Quickshell, Hyprland, -SDDM, the update worker, hardware setup and akmods) into -`cybexos-report-.tar.gz` in the download folder, readable only by -you. Before writing, it replaces user and real names, hostnames, home paths, -MAC and IP addresses, serial numbers and UUIDs, Wi-Fi network names, tokens, -keys, passwords and email addresses with placeholders such as `[user]` or -`[token]`, and prints what it collected and how much it removed. It then -offers to open a GitHub bug report with a short, prefilled environment -section; it never uploads anything, so attach the archive yourself after -looking through it. `--no-browser` skips the question, `--json` prints a -machine-readable summary without asking, and `--output PATH` chooses a -directory or a `.tar.gz`, `.tgz` or `.tar.zst` file. The default is gzip -because GitHub does not accept `.zst` attachments. Redaction is pattern-based: -review the files before sharing them. - -The default-agent dispatcher stores only an allowlisted command name under -`$XDG_CONFIG_HOME/cybexos/defaults/agent`, or -`~/.config/cybexos/defaults/agent` when `XDG_CONFIG_HOME` is unset. It -changes that file atomically only after confirming the selected command -exists. Agent login tokens, API keys, models, permissions, and configuration -are deliberately not copied into CybexOS. A desktop launch begins in -`~/Code`; an invocation from an existing terminal keeps its working directory. -Use `cybex agent unset` to clear the preference. - -The development-source switch stores one canonical path at -`~/.config/cybexos/dev-source`. The resolver accepts only a user-owned, -non-world-writable Git worktree with the required desktop sources. Enabling or -disabling it restarts the managed Quickshell service and reloads Hyprland when -one is active. Rendered machine-specific Hyprland modules still come from the -installed vendor runtime. See [the ownership architecture](architecture/ownership.md). - -## Supported Ansible tags - -The definitive list for the current checkout is generated by Ansible: - -```bash -ansible-playbook site.yml --list-tags -``` - -The stable role boundaries are `base`, `desktop`, `apps`, `xps-2026` and -`nvidia` (both also `hardware`), `dotfiles`, `private-hooks`, `boot`, and -`finalize`. Narrow tags -currently exist for `browser`, `onepassword`, `fonts`, `font-defaults`, `packages`, `quickshell`, -`quickshell-lint`, `shell-defaults`, `user-tools`, `camera`, `fingerprint`, -`speaker`, `touchpad`, and `display`. The `display` tag applies the measured -XPS panel self-refresh quirk; see [hardware notes](xps-2026-hardware.md#internal-panel-black-flashes). The -narrow tags are development tools, not independent installation profiles; -their prerequisites can live in an earlier role. - -The `quickshell` tag compares the deployed shell tree with the managed sources -in one read-only pass and writes only the files that differ. An unchanged -tree skips qmllint, and with an unchanged Quickshell unit also the live -snapshot and the verified restart; `--tags quickshell-lint` still runs qmllint -on its own. Bytecode caches written by the running shell's Python helpers do -not count as a change; the next real deployment clears them. Because it also -installs the Hyprland and Quickshell packages, it converges their -qualified-series versionlock first. - -The `font-defaults` tag installs Liberation Sans/Serif/Mono, the full Noto -collection, Noto CJK Sans/Serif/Mono, Noto Color Emoji, and Font Awesome -(desktop and web fonts), -then applies [Omarchy's font mappings](https://github.com/omacom/omarchy/blob/8f324c90b82790d31ab33565441e07cbdb8d2308/default/fontconfig/conf.avail/50-omarchy.conf) -in `/etc/fonts/conf.d/49-cybexos-defaults.conf`. Sans-serif and system UI -aliases use Liberation Sans, serif uses Liberation Serif, and monospace uses -JetBrainsMono Nerd Font. Strong UI aliases preserve these choices against -Fedora's generic rules, and the system UI rule replaces Fedora's pre-expanded -Cantarell preference. `ui-sans-serif` and `-apple-system-body` also map -to Liberation Sans, and `ui-monospace` maps directly to JetBrainsMono Nerd Font. -The latter must already be installed by the upstream -application tasks. Personal Fontconfig files remain separate and load afterward. -Apply just these defaults on an installed machine with: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags font-defaults -``` - -Font coverage mirrors [Omarchy's base manifest](https://github.com/omacom/omarchy/blob/8f324c90b82790d31ab33565441e07cbdb8d2308/install/omarchy-base.packages) -checked on 2026-09-21. Fedora splits Noto into many packages; the required -`google-noto-fonts-all` metapackage supplies its language, symbol and math -families, with CJK and emoji installed separately. Our full JetBrainsMono Nerd -Font includes the coverage of Omarchy's basic variant. The `fonts` tag also -installs all 16 iA Writer static faces (Mono, Duo, Quattro and legacy Duospace), -using Omarchy's upstream commits with SHA-256 pins and bundled OFL notices. -Omarchy's private branding icon font is shell artwork; CybexOS uses its own -bundled Tabler interface icons and product icons. Existing additional font choices remain. -Arabic and Urdu fallback ordering follows Omarchy, including Chromium requests -without a language hint. - -Restart existing browsers to clear their cached font selection. These system -defaults are separate from the Quickshell Appearance settings. - -The `onepassword` tag installs the Wayland launcher under the upstream -`com.onepassword.OnePassword.desktop` ID and removes the legacy user launcher -so the application appears only once. New image seeds use the same desktop ID. - -The proprietary application group installs Brave Origin (`brave-origin`) from -Brave's signed release repository. The `browser` tag updates the package, -keyboard shortcut, launcher, default URL handlers, and managed policies, and -removes standard Brave's package and old managed launcher. Browser profiles are -preserved: Origin uses `~/.config/BraveSoftware/Brave-Origin`, while standard -Brave uses `~/.config/BraveSoftware/Brave-Browser`. Existing profiles are not -moved automatically; close both browsers before migrating profile data. - -`user-tools` deploys and runs the CLI updater on an existing developer-tools -installation. Codex resolves npm's `latest` release each time this updater runs; -it validates the downloaded version before activating it and retains the working -installation if resolution or download fails. Claude Code's inventory pin is a -minimum: an older or missing Claude Code is installed at the pin, while a newer -one (Claude Code updates itself) is left alone rather than downgraded. OpenCode -retains its exact inventory pin. System verification checks Codex locally -without requiring the npm registry; freshness is checked by the updater. - -Every invocation still executes tasks tagged `always`. That includes fresh -fact gathering, the feature contract, Fedora/architecture/user validation, -and precise XPS-role detection. A partial tag run is therefore not a way to -bypass the Fedora 44 and architecture support contract. Do not add -`--skip-tags always`; it removes the checks that make a targeted run safe. - -## Agent skill lifecycle - -CybexOS ships one canonical skill in the active release at -`~/.local/share/cybexos/current/agent-skills/cybexos`. Provisioning -always creates these discovery links, independently of whether each agent is -installed: - -- `~/.agents/skills/cybexos` -- `~/.claude/skills/cybexos` -- `~/.codex/skills/cybexos` - -The internal `scripts/manage-agent-skills` helper adopts only those three -named slots. Before first replacement it records each existing file, -directory, symlink, or absence separately under -`~/.local/state/cybexos/backups/agent-skills/`. If any backup or -replacement fails, the invocation restores every affected slot. Later runs -treat an adopted slot as project-owned and converge it without rewriting the -first-adoption record; unrelated skills and parent directories are untouched. - -Release activation swaps `current` and then reconciles all three links through -the new canonical skill. A reconciliation failure rolls back the links, the -active-release symlink, and the migrated installer configuration. Uninstall -uses the same helper before removing project state and unconditionally -restores each recorded original. `--keep-user-data` retains those records only -after restoration; it does not leave the CybexOS links installed. - -## Migrating from fedora-config - -The project was called `fedora-config` before it became CybexOS. An -installation made under that name moves to the CybexOS names when `./install` -runs from a CybexOS checkout; releases published under the old name cannot -update it. `scripts/migrate-legacy-names` runs first in `install`, `uninstall`, -and every playbook, and is idempotent. It: - -- moves `/etc/fedora-config`, `/var/lib/fedora-config`, the - `fedora-config-upstream` cache and state, `/opt/fedora-config-apps`, and - `/opt/fedora-config-builds` to their `cybexos` paths, keeping install - markers and retargeting `/usr/local/bin` links so nothing is rebuilt; -- renames the `fedora-config` firewalld zone to `cybexos`, including the - default zone and NetworkManager profile bindings, and replaces the Btrfs - scrub timer; -- moves the `fedora-config` directories in `~/.config`, `~/.local/share`, - `~/.local/state`, and `~/.cache` to `cybexos`, plus the user-tool - directory, and retargets `~/.local/bin` links; -- restores the former `fedora-config` agent skill slots so that provisioning - adopts the `cybexos` slots afresh. - -The `legacy-names` role then removes the remaining `fedora-config` helpers, -units, fragments, and managed include blocks after their replacements are in -place, and restarts a running Quickshell and hypridle. Links at the three -former home paths keep the current desktop session working. The next Hyprland -login removes them. If both an old and a new path already exist, the -migration leaves the old one for manual review and says so. - -## Release updater lifecycle - -`cybex update` follows the channel saved in -`~/.local/share/cybexos/channel` (`stable` by default, or `beta` after -`--channel beta`). It reads `DigitalPals/CybexOS` releases through GitHub's -public API without credentials; no GitHub account, `gh auth login`, or token -is needed. A project release is accepted only when GitHub marks it immutable, -the downloaded SHA-256 matches GitHub metadata, its provenance attestation -verifies, and its manifest supports the current Fedora release, architecture, -configuration schema, and updater version. - -The release workflow publishes the Sigstore bundle of that attestation as -`cybexos-VERSION.tar.zst.sigstore.jsonl` beside the archive. The updater -downloads it and runs `gh attestation verify --bundle` offline, pinned to this -repository's `.github/workflows/release.yml`, the release tag's -`refs/tags/vVERSION`, and a GitHub-hosted runner. `gh` is always installed; -one without offline bundle verification is reported as too old before -anything is downloaded. - -While a channel has no published release (stable: GitHub reports no latest -release for an existing repository; beta: no published prerelease), that is -not an error. `--check` prints `No CybexOS release has been published on the -stable channel yet (installed: VERSION).` and exits 0; `--check --json` reports -`"status": "no-release"`, `"available": false`, and an empty `projectError`. -An update then runs only the package phase and exits with its status, and -the Updates panel shows the neutral note "No CybexOS release published yet". -A missing repository is an error, not an unpublished channel. - -Fedora and Flatpak updates never depend on GitHub: when a release cannot be -checked (offline, an API error, or the anonymous API rate limit), verified, or -staged, the updater says why, removes any partial stage, and runs the -package-only update. The terminal command then exits 69 after a successful -package run; `--start --json` returns the started run with a `projectError` -field, and `--check --json` reports an available release together with a -`projectError` when this machine cannot verify it yet. A failed check exits -nonzero with the reason as its last stderr line. A request with -`--no-packages` has nothing to fall back to and fails. - -The verified archive is extracted into a new versioned directory. A dedicated -durable system worker owns configuration migration, candidate application, -agent-skill reconciliation, rollback, and the atomic `current` symlink change. -Detaching the terminal cannot split those steps, and an unrelated active -update is never accepted as the candidate transaction. On the managed Btrfs -layout, the worker checkpoints root, `/boot`, and the exact vendor-owned home -paths before applying changes. It downloads RPMs first, applies the update, -waits for RPM desktop reconciliation where applicable, and validates system -and desktop health before committing. Failure or cancellation after application -selects the previous root and restores the vendor desktop; restart when status -reports `rollbackState: restart-required`. Personal preferences, themes, plugins -and application data under home are excluded from this restore. The active -release plus two recent release directories are retained, with pruning deferred -until the health check succeeds. - -On other filesystems `transactionProtection` is `unavailable`: release activation -still restores the prior `current` and saved configuration on failure, but -already-deployed files and package changes require manual recovery. Such a failed -apply reports `mixedState: true`; converge the active release again with -`~/.local/share/cybexos/current/install` after fixing the cause. See the recovery -limits below before relying on rollback. - -Useful release commands are: - -```bash -cybex update --check -cybex update --check --json -cybex update --channel beta -cybex update --channel stable -cybex update --no-packages -cybex update --system-only -``` - -## Durable updater lifecycle - -The package/configuration worker is a transient service. It has no terminal, -and sudo's default ticket (`timestamp_type=tty`) is tied to the terminal that -authenticated, so a terminal invocation uses a user service only when sudo -works without any terminal (for example passwordless sudo). Otherwise it -authenticates once and starts a system service. Quickshell explicitly requests -a system service; when authorization is needed, systemd's own Polkit action is -handled by the graphical session agent, so no terminal is opened and progress -remains in the Updates view. -Release transactions also use a system service because their configuration -migration and activation must outlive the client. Closing the view or pressing -Ctrl+C while attached only detaches the observer. At most one worker can own -the update lock. The worker runs at nice 10 with a CPU and I/O weight of 20, -so the desktop stays responsive while it works. - -Use the installed backend to inspect or control it: - -```bash -cybexos-update-run status -cybexos-update-run status --json -cybexos-update-run attach -cybexos-update-run log-dir -cybexos-update-run cancel -cybexos-update-run dismiss -cybexos-update-run history -cybexos-update-run history --json --limit 5 -``` - -Each command accepts a run ID where documented by `cybexos-update-run --help`. -`cancel` is explicit; Ctrl+C during `attach` never cancels. The stop request -reaches only the worker process (`KillMode=mixed`), because interrupting -DNF/RPM midway can leave duplicate or half-upgraded packages. A running -package transaction, firmware update, repository check, or Ansible play -therefore finishes first, and the worker records `cancelled` at the next step -boundary; `status --json` reports `cancelRequested: true` meanwhile. Once -Ansible has started, the run completes or fails instead, so installed files -and the active release stay coherent. A worker still running 30 minutes after -the request is killed. A package phase started by an updater without this -contract is not cancellable. `dismiss` only changes the completed status shown -by the UI and does not delete its logs; the record keeps its result as -`outcome`. - -`history` is the transaction history: the finished runs among the retained -records, newest first, each with its `outcome` (`done`, `failed` or -`cancelled`), timestamps, failing `phase`, `snapshotId`, -`transactionProtection`, `rollbackState` and message. A run that is still -active is not listed. The worker writes `snapshotId` into the run record the -moment the recovery point exists, before any package changes, so even a run -whose worker disappeared names the point to return to. - -Hyprland and Quickshell are held to the series the release qualified, with a -dnf versionlock (see [the dependency policy](dependency-policy.md)). Both -package passes run `dnf upgrade --no-best`, so a library update that only a -held-back compositor or shell release could use is skipped and the rest of the -update still completes; `dnf versionlock list` shows the active entries. - -After a successful package phase, the root worker performs one bounded -`daemon-reload` through systemd's private socket, then requires a valid manager -version reply through the public system bus before continuing. This synchronizes -the manager after RPM triggers; it does not retry packages or replay failed -provisioning service actions. Public manager readiness is checked again before -commit, without another reload. A failed or timed-out synchronization uses the -existing transaction recovery path and preserves the package logs. -`--no-packages` skips the post-package reload and retains the commit readiness -check. - -Installed account reconciliation and feature changes reload an existing user -manager as that account through its local runtime bus, with a 30-second deadline -and a five-second forced-termination grace for the whole client process group. -They do not restart the running desktop. A dormant account with no runtime bus -needs no reload; a failed reload on an existing bus fails configuration. This -avoids the transient machine bridge and its descriptor transfer through PID 1. - -`--firmware` (also accepted by `cybex update`) installs available fwupd device -firmware in the same worker after the reversible update has committed, through -`cybexos-firmware-update`. A firmware failure never fails the run; the final -message notes the helper's status, and a capsule staged for the next boot -recommends a restart for the rest of that boot. With `--no-packages`, -`cybexos-update-run --firmware` is a firmware-only run. - -Run state lives under `~/.local/state/cybexos/update/`. Each run has a private -directory under `logs//` containing: - -- `status.json`: atomic machine-readable phase, result, component exit codes, - timestamps, transient unit name, the pre-update `snapshotId` when one was - created, `transactionProtection` (`btrfs` or `unavailable`), `rollbackState`, - and `mixedState` when the running root still needs recovery or a restart; -- `run.log`: the complete combined stream with `dnf`, `flatpak`, `firmware`, - `tests`, and `ansible` prefixes; -- component logs such as `dnf.log`, `flatpak.log`, `firmware.log`, - `tests.log`, and `ansible.log`, plus their exit-code files when that phase - ran; -- `firmware-events.log`: one JSON object per line (`plan`, `device`, - `progress`, `request`, `installed`, `skipped`, `failed`, and a final - `summary`) that the Updates view renders. - -The twenty newest valid run directories are retained, and with them the -history. For a worker that looks -stuck, start with `cybexos-update-run status --json`, inspect `run.log`, then use -the `unit` field with `systemctl --user status ` and -`journalctl --user -u ` (without `--user` when `systemUnit` is true). If -the unit disappeared without final status, the next status read marks the run -failed with phase `abandoned` instead of blocking all future updates. - -## What an update will change - -The Updates view counts what is pending by source: **System** (Fedora -packages), **Apps** (Flatpaks), **Firmware** and **CybexOS**. On an ISO -installation the `cybexos-desktop` RPM from the enrolled channel counts as -CybexOS rather than as a system package. Before a run, the panel's **What will -change** disclosure lists: - -- Fedora's advisories for the pending packages, from - `dnf advisory info --updates --json`: how many are security fixes, bug fixes - and enhancements, then their titles, security first and most severe first; -- each package and app as installed → incoming version. The incoming side - comes from the check (`dnf check-update`, - `flatpak remote-ls --updates --app --columns=name,application,version`); the - installed side is read only while the disclosure is open, once per check - (`rpm -qa`, `flatpak list --app`); -- CybexOS's own changes: the release notes GitHub publishes with an available - release (`cybex update --check --json` carries them as `releaseNotes`), or, - on an ISO installation, the desktop RPM's changelog entries newer than the - installed one (`dnf --repo=cybexos-desktop changelog --upgrades - cybexos-desktop`). Each desktop RPM build adds an entry naming its release and - the address of its release notes. - -None of these reads needs root; each is a bounded subprocess, and all of the -text is shown as plain text. The advisory read replaces the security-only read -the check already made, so a check costs no more processes than before. - -## Background downloads - -**Download updates automatically** (Settings → Bar → Updates, beside -**Check every**) is off by default; nothing downloads in the background unless -it is turned on. The setting is the enablement of the system timer -`cybexos-update-download.timer`, so systemd keeps the choice and installs or -updates never change it. Turning it on or off runs -`systemctl enable --now` or `disable --now` for that timer in one transient -system unit, so systemd asks the desktop's Polkit agent once; there is no -passwordless path. - -Every four hours (spread by up to an hour, and once after a missed slot) the -timer starts `cybexos-update-download.service`. It runs only on AC power -(`ConditionACPower=true`) and outside the live session, and its helper skips -quietly unless NetworkManager reports a fully connected, unmetered network, -no update worker or other `dnf`/`rpm` process is running, and the system is -not a temporary recovery boot. It then runs `dnf upgrade --downloadonly` and -`flatpak update --system --no-deploy` at idle priority. It never installs or -deploys anything. The durable updater stops a running download before it -takes its recovery point and reuses what was downloaded. - -A finished download writes `/var/lib/cybexos/update-download/status.json` -(`state` `done` or `failed`, timestamps and both exit codes). A failed download -is recorded rather than failing the unit. While that record is newer than the -last change to the installed packages and updates are pending, the Updates -view and its bar tooltip say **Updates downloaded — ready to install**. - -```bash -systemctl status cybexos-update-download.timer cybexos-update-download.service -journalctl -u cybexos-update-download.service -cat /var/lib/cybexos/update-download/status.json -``` - -The desktop also accepts `cybexos-runtime ipc updates check` (check every -source now) and `cybexos-runtime ipc updates open` (open the Updates view). - -## Personal-file backups - -`cybexos-backup` keeps encrypted copies of the home folder with restic, on a -local folder or external drive, or on an SSH/SFTP server. `cybex backup` runs -it on both installation paths, and **Settings → Backup & recovery** drives the -same helper: Set up… walks through the destination, the recovery key and the -first backup on the page itself. Recovery points (next section) restore the -installed system; backups are the personal files they never touch. - -```bash -cybex backup setup local /run/media/$USER/T7 # a folder or external drive -cybex backup setup sftp nas backups/laptop # SSH alias or user@host, then a folder -cybex backup confirm-key # after saving the recovery key -cybex backup now # back up now, in the foreground -cybex backup status [--json] # last and next backup, destination -cybex backup snapshots [--json] # the backups available to restore -cybex backup restore [SNAPSHOT] [--path Documents/Taxes] [--open] -cybex backup automatic on|off -cybex backup recovery-key # replace the recovery key -cybex backup excludes # what backups leave out -``` - -**Destinations.** A local destination gets a `cybexos-backup` folder inside the -chosen folder, unless the folder already is a restic repository. Folders inside -the home folder on the same filesystem, and system folders, are refused. The -destination's mount point is recorded: while a removable drive is absent its -empty mount directory is never written to, which would fill the system disk. -An SSH destination uses restic's SFTP backend through `ssh` with the user's -keys and `~/.ssh/config`, like the Remote Server widget: `BatchMode=yes` (no -password prompts) and `StrictHostKeyChecking=yes` (no unknown host keys). -Connect once with `ssh HOST` so the server is trusted; put ports, IPv6 -addresses and jump hosts in `~/.ssh/config` under an alias. The relative -folder (default `cybexos-backup`) is in the remote account's home. - -**Encryption and the recovery key.** Repositories are always encrypted. Setup -generates a random 256-bit password and stores it in the GNOME Keyring with -`secret-tool` (libsecret; attributes `application cybexos-backup repository -ID`); it reaches restic through a pipe, never argv or the environment. A -second restic key, the recovery key (30 characters in six groups, without -I, L, O or U), is shown once and never stored; Settings copies it to the -clipboard marked sensitive, so clipboard history skips it. With it the -backups open on any computer. **If this computer's keyring and the recovery -key are both lost, the backups cannot be opened.** `cybex backup recovery-key` -adds a new recovery key and removes the previous one; Settings offers the same -as New key… while a key is not confirmed as saved. After reinstalling, setting -up the same destination reports the existing backups; `--existing` (or the -Recovery key field in Settings) reads their recovery key and stores a new -keyring password beside it. On a computer without CybexOS, restore with plain -restic: `restic -r REPOSITORY restore latest --target ~/restored` and the -recovery key as the password. - -**What is backed up.** The whole home folder, on its own filesystem -(`--one-file-system`, so mounted drives and network shares under it are -skipped), excluding directories tagged with `CACHEDIR.TAG` and: - -- caches and trash: `~/.cache`, `~/.local/share/Trash`, Flatpak application - caches (`~/.var/app/*/cache`), and the caches Chromium, Brave, Chrome and - Electron applications keep inside `~/.config` (`Cache`, `Code Cache`, - `GPUCache`, `DawnCache`, `GrShaderCache`, `ShaderCache`, - `Service Worker/CacheStorage`, `component_crx_cache`); -- downloaded Steam games (`steamapps/common`), downloads, shader caches and - client caches in the native and Flatpak Steam stores; `userdata` and - `steamapps/compatdata` (Proton prefixes and saves) are included; -- rootless Podman/Docker image/build stores (`overlay`, `overlay2`, `vfs`, - image metadata, BuildKit and containerd), including disposable writable - container layers; readable named volumes and home bind-mounted personal data are included; -- applications that are downloaded again: `~/.local/share/flatpak`, the Neovim plugin and - Mason stores, Voxtype models and `~/.ollama/models`; -- the CybexOS vendor runtime and release store - (`~/.local/share/cybexos/runtime`, `releases` and `current`); -- toolchains and package caches: `~/.cargo/registry`, `~/.cargo/git`, - `~/.rustup`, `~/.npm`, `~/.npm-global`, `~/.bun/install/cache`, - `~/go/pkg/mod`, `~/.m2/repository`, `~/.gradle/caches`, - `~/.gradle/wrapper/dists`, `~/Android/Sdk` and `~/.android/avd`; -- dependency trees and build output anywhere: `node_modules`, `__pycache__`, - `.tox`, `.nox`, `.next`, `.nuxt`, `.svelte-kit`, `.turbo`, `.parcel-cache`, - `.zig-cache` and `zig-cache` (Cargo's `target/` carries `CACHEDIR.TAG`). - -Add restic exclude patterns, one per line, to `~/.config/cybexos/backup-exclude`. -`cybex backup excludes` prints the effective list. - -**Application data.** Steam games that store saves inside their installation -directory (`steamapps/common`) need a separate export into a backed-up home -folder. Steam Cloud is not a substitute for that export. Keep persistent -container data in named volumes or home bind mounts, rather than writable -container layers. Root-owned stores under `/var/lib`, custom stores outside -home, symlink targets outside home and separately mounted filesystems are not -included. The regular cache/dependency exclusions also apply inside volumes. - -The backup runs as your desktop account. Private `_data` directories and files -owned by subordinate UIDs in rootless containers may be unreadable to that -account, even though the volume lives under home. Export that data with the -application/container's supported tools into a home folder readable by your -desktop account, and keep the export until a complete backup succeeds. -Stopping a database alone does not change file ownership or make private -volume data readable. The helper does not elevate privileges or change volume -permissions. - -These are file backups, not application-consistent database snapshots. Files -copied while PostgreSQL, MariaDB/MySQL, SQLite or another database is writing -may not restore consistently, even when the volume is included. Before backup, -write an application-supported export (for example `pg_dump`, `mysqldump`, or -SQLite's `.backup`) into a backed-up home directory and retain it until backup -finishes, or stop the database for the entire backup when its files are readable. -Restore and verify those exports with the application's tools. Setup and status describe this coverage -and consistency limit on both installation paths. - -**Incomplete backups.** Restic may save a usable snapshot of readable files -while omitting an unreadable directory and everything below it. Such a run is -reported as `lastResult: "partial"`, with the reason in `lastError`; a manual -`now` returns a nonzero exit status. Its snapshot remains available to restore, -but `lastSuccess` continues to identify the last complete backup. Incomplete -runs do not apply retention and remain due for the next scheduled retry. -`lastSummary.unreadable` is the number of read-error records restic reported, -not a count of all omitted files: a directory error can hide an unknown number -of children. It is `null` when restic reports an incomplete backup without -structured error records, and `0` on a complete run. `lastSummary.errors` -retains the latest 40 error messages, including affected paths when available; -the error count includes records beyond that diagnostic limit. - -**Schedule.** `cybexos-backup.timer` is a user timer started with the Hyprland -session (on the ISO by the packaged session target, on checkout installations -by the desktop role's `hyprland-session.target.wants` link); it needs the -session's keyring and notifications. It checks 10 minutes after login and then -hourly; a backup runs when automatic backups are on and none has succeeded in -the last 23 hours. A due backup waits quietly while the destination is -unreachable (drive not mounted, folder missing, SSH port not answering), on -battery below 30 % (peripheral batteries do not count), on a metered network -(SSH destinations only), or while the keyring is locked: a scheduled run never -raises an unlock prompt. It runs at `Nice=10` with idle I/O priority. Once a -week, after a backup, `restic forget --keep-daily 7 --keep-weekly 4 ---keep-monthly 6 --prune` applies to this computer's snapshots, followed by -`restic check`. - -**Notifications.** A failed or incomplete backup sends one notification with -the reason, at most once a day for the same reason, however often the hourly check retries. -After seven days without a successful backup, a reminder follows once a day, -naming the drive to connect when that is the cause; it also comes while -automatic backups are off. Clicking either opens Settings → Backup & recovery. - -**Restoring.** A restore never writes into the home folder: it goes into a new -folder `~/Restored/YYYY-MM-DD-HHMM` named by the backup's time (`-2`, `-3` when -taken), with `--overwrite never`. `--path` restores one file or folder, -relative to the home folder; without it everything is restored after a free -space check. Settings restores the chosen backup and path, then opens the -folder in Files. Back up now and Restore run as transient user units -(`systemd-run --user`), so closing Settings or restarting the shell does not -interrupt them; one backup or restore runs at a time. - -**Files.** `~/.config/cybexos/backup.json` holds the destination and keyring -item name (no secrets); `~/.local/state/cybexos/backup.json` the last results -and notification bookkeeping; `$XDG_RUNTIME_DIR/cybexos/backup-progress.json` -the progress of a running backup or restore. - -The earlier opt-in `app-backup` (and `browser-backup`) command still exports -browser profiles as an age-encrypted tarball for `AGE_RECIPIENT`; it is now a -mode of `cybexos-backup` and is installed on ISO installations too. Ordinary -backups already include the browser profiles. - -`tests/backup.py` covers the helper against stubbed restic, secret-tool, SSH, -NetworkManager and power-supply fixtures, and the image parity tests compare -the units and the `cybex backup` dispatchers of both installation paths. When -restic is available, it also snapshots and restores synthetic home data in a -temporary local repository, checking saves, readable volumes and cache exclusions. -It also runs a real unreadable-directory backup as an unprivileged account, -checking partial status, preserved complete snapshots, read errors and a -readable volume restore. These fixtures do not qualify private subordinate-UID -volumes as readable. A backup to real external media or an SSH server from an installed system, and -the Settings flow in a live session, still need qualification on hardware. - -## Update recovery points - -Before package work starts, the updater requires -`/usr/local/libexec/cybexos-system-snapshot` on the managed Btrfs layout. It saves a -read-only snapshot of the `root` subvolume and a matching archive of `/boot` -(including the mounted EFI tree), records the default kernel, then retains the -five newest points. `/home` is a separate subvolume and is intentionally -outside system rollback; on the standard layout `/var` (logs, containers, -system Flatpaks) is inside `root` and rolls back with it. A snapshot failure -stops the update before DNF changes anything. On a non-Btrfs root the step -records that no filesystem recovery point was required. - -The transaction journal and vendor checkpoint live in the Btrfs top-level -recovery store, outside the root that is restored. An active checkpoint pins -its recovery point against ordinary retention. `cybexos-update-recover.service` -resolves interrupted updates before login, retrying an interrupted restoration -without exchanging the root twice. A prepared transaction with no applied -changes is abandoned safely. A failed restoration enters emergency mode instead -of starting a desktop with incomplete recovery. - -The first update from an older installation creates an untouched recovery point, -installs a root-owned recovery bundle and login dependency, then creates a second -point containing that hook. Package changes begin only after the second point -and vendor checkpoint succeed. This keeps recovery available if power fails -between restoring root and restoring vendor files in home. Normal convergence -replaces the bootstrap hook; a package-only update can retain it until that -convergence. Starting the boot service during the current update never rolls -back that same-boot worker. - -Installed baseline and account provisioning runs in the fixed transient -`cybexos-provision-work.service` cgroup. The supervising helper holds -`provision.lock` until `cgroup.events` confirms every descendant has exited, -including workers that detach into another session. Runtime limits stop the -whole cgroup; cancellation cannot release serialization during teardown. A -killed supervisor is fenced by draining that named workload before another -configuration run or updater recovery. A kernel task that cannot be killed -keeps the fence held rather than permitting concurrent writes. Live provisioning -requires a working systemd cgroup v2 manager and fails if it is unavailable. -Only Anaconda's explicit `--offline --installer-target` invocation in a verified -chroot provisions without a running target manager; `--offline` alone retains -live containment. The updater probes the helper's advertised `--drain` interface -so the first update from an older baseline remains supported. - -Commit checks the RPM database and newly failed system services. When the -desktop was active, it also requires the managed Quickshell service to own the -sole shell process, complete read-only IPC initialization, remain stable, and -report no QML errors for that invocation. Safe mode is not successful update -validation. Headless updates validate system health; they cannot validate a -desktop session that is not running. - -These are recoverable filesystem transactions, not whole-machine snapshots. -Root selection takes effect after restart, and a new kernel that cannot boot -after an otherwise successful commit still needs the recovery boot menu. -User Flatpaks, independently updated application/tool stores in home, external -filesystems, remote effects and firmware are outside the checkpoint. Firmware -runs only after commit. Keep external backups; root rollback also reverts data -in `/var` on the standard layout. Guided Fedora release upgrades use the same -journal with separate offline-boot and desktop validation stages; see -[the major-upgrade workflow](fedora-major-upgrade.md). - -```bash -sudo cybexos-system-snapshot list # ID and description -sudo cybexos-system-snapshot list --json # also kernel and boot-menu status -cybexos-update-run status --json | jq -r .snapshotId -cybexos-update-run history --json | jq -r '.[] | "\(.id) \(.outcome) \(.snapshotId)"' -``` - -The Updates view shows the last update's recovery point as **Recovery point · -Today at 14:02** (local time) with **Manage**, which opens the Settings section -where recovery points are listed and restored. The line stays after the result -is dismissed and is replaced when the next update takes its own point. - -### Trying a recovery point from the boot menu - -GRUB shows a **CybexOS recovery points** submenu with one entry per retained -point whose kernel and initramfs are still in `/boot`. Fedora hides the menu -after a successful boot: hold **Shift** (BIOS) or press **Esc** (UEFI) while -the machine starts, or simply let a failed boot bring it up. An entry boots the -read-only snapshot with its own kernel; the `cybexos-recovery` dracut module -lays an in-memory overlay over it, so the system runs normally but nothing is -written to the snapshot and every change disappears at the next restart. -`/home` is the live one. The real `/boot` and EFI partitions are mounted -read-only, so nothing installed during a recovery boot can leave a kernel -behind without its modules. The desktop announces the recovery boot and offers -to restore it; new recovery points, and therefore updates, are refused while it -runs. - -Nothing here regenerates `grub.cfg` per snapshot. `/etc/grub.d/42_cybexos_recovery` -makes `grub.cfg` source `/boot/grub2/cybexos-recovery.cfg`, which the helper -rewrites atomically after every create, prune and restore, from a kernel-install -hook when kernels change, and at boot (`cybexos-recovery-refresh.service`, which -also publishes `/run/cybexos-snapshots/recovery-points.json` for the desktop). -Entry titles and kernel arguments are reduced to a safe character set, so a -description cannot inject GRUB commands. A kernel whose initramfs lacks the -overlay module gets no entry; Ansible rebuilds initramfs images when the module -or helper changes. - -### Restoring - -Restore from **Settings → Backup & recovery → Recovery points** (two presses; systemd asks -the desktop's Polkit agent, as for updates) or from a terminal, in a normal or -a recovery boot: - -```bash -sudo cybexos-system-snapshot restore ID -# From a recovery point older than this feature, use the helper the -# initramfs provides instead: -sudo /run/cybexos-recovery/cybexos-system-snapshot restore ID -``` - -Restore never reboots. It makes a writable copy of the point, puts back any of -the point's kernels that `/boot` no longer has (from its archive), exchanges the -copy with `root` in one atomic rename, and keeps the previous system as -`root.replaced-TIMESTAMP`. It then retires kernels that the restored root has no -modules for into the store and makes the point's kernel the GRUB default. The -bootloader itself (GRUB, shim and the EFI tree) is left at its current version. -Nested subvolumes such as `/var/lib/machines` are never part of a point and move -to the restored root. An interrupted restore is finished or undone by the next -`cybexos-system-snapshot refresh`, which runs at every boot. - -Restart to use the restored system, then review and reclaim space: - -```bash -sudo cybexos-system-snapshot replaced -sudo cybexos-system-snapshot discard root.replaced-TIMESTAMP -``` - -Discarding is refused while that root is still the running system. - -### Factory reset - -ISO installations made with the guided installer (interactive, unattended or -for a new owner) take one **factory recovery point** at their first start, -before anyone logs in: `cybexos-first-boot.service` runs -`cybexos-system-snapshot create --factory`. The point is marked -`metadata/ID.factory` in the store, is listed with `"factory": true`, does not -count towards the five retained points and is never pruned; only one can exist. -Checkout installations never create one. - -```bash -cybex factory-reset -``` - -The command runs `/usr/libexec/cybexos-factory-reset reset` through sudo. It -refuses during a recovery boot, while a restore waits for a restart, while an -update transaction is unfinished, and without a factory point. It explains -what is erased, suggests `cybex backup now`, and requires typing the computer -name or `erase`. On an encrypted disk it asks for the current passphrase and a -temporary passphrase for the next owner, adds that passphrase and removes every -other key slot and LUKS2 token. It then restores the factory point with the -normal restore and marks the restored root. **Settings → Recovery points → -Factory reset…** explains the same and opens the command in a terminal. - -At the next start `cybexos-first-boot` finishes the reset before any login: -every other recovery point, set-aside root and update checkpoint is deleted, -every desktop account and everything in `/home` (nested Btrfs subvolumes -included; any other mount below `/home` stops the reset instead) is removed, -along with SSH host keys and the passwordless-sudo rule. The installation's -feature choices move from `config.yml` to `/etc/cybexos/install-choices.json`, -`/etc/cybexos/oem.json` is written and `cybexos-new-owner.service` asks the new -owner for language, keyboard, account, password and time zone before the login -screen: in a graphical setup, or on the console when graphics are unavailable -(see [image/INSTALLER.md](../image/INSTALLER.md#new-owner-oem-installations)). If finishing fails, the reset marker remains, the -failure is in `journalctl -b -u cybexos-first-boot` and the next start retries. -The system returns to the factory point's package versions; the hostname and -machine ID are kept. See [image/INSTALLER.md](../image/INSTALLER.md#factory-reset). - -### Last resort: live media - -If neither the default entry nor a recovery entry boots, boot Fedora -rescue/live media, unlock the LUKS device, identify the root Btrfs filesystem -and the separate `/boot` and EFI partitions with `lsblk -f`, then use the -reviewed snapshot ID below. Device names are placeholders and must be replaced -with the values from `lsblk`: - -```bash -mount -o subvolid=5 /dev/mapper/ROOT_CRYPT /mnt -ID=20260903T120000Z-1234 -test -d "/mnt/cybexos-snapshots/root/$ID" -test -f "/mnt/cybexos-snapshots/boot/$ID.tar" -btrfs subvolume snapshot "/mnt/cybexos-snapshots/root/$ID" /mnt/root.recovered -mv /mnt/root "/mnt/root.failed-$ID" -mv /mnt/root.recovered /mnt/root -mount /dev/BOOT_PARTITION /mnt/root/boot -mount /dev/EFI_PARTITION /mnt/root/boot/efi -tar --acls --xattrs --selinux --numeric-owner \ - --extract --file "/mnt/cybexos-snapshots/boot/$ID.tar" --directory /mnt/root -sync -``` - -Reboot into the default entry and run `sudo restorecon -RF /boot` followed by -`./verify --system --require-hyprland`. Keep `root.failed-$ID` until the system -and user session are confirmed healthy; deleting it is a separate, explicit -space-reclamation decision. These recovery points do not replace backups: -they share the same physical Btrfs filesystem and cannot survive device loss. -Use [personal-file backups](#personal-file-backups) for the home folder. - -`tests/system-snapshot` covers the helper against fixtures (retention, the -factory point, menu generation and escaping, kernel selection, restore -ordering and interruption, the overlay hook); `image/test_new_owner.py` covers -the factory reset guard rails, the next-start cleanup and the new-owner setup -with the snapshot helper, cryptsetup and account tools stubbed. Booting a recovery entry and restoring from it on real -hardware have not yet been qualified end to end; test them in a disposable VM -before relying on them. - -Ansible's `cybexos` callback is intentionally compact: unchanged and skipped tasks -are quiet, changes are one line, and failures include a bounded diagnostic. -Pass `-v` to use the stock verbose callback behavior. A full updater run keeps -the callback's complete emitted stream in `ansible.log`; it does not recreate -details that the compact callback intentionally never emitted. Pass `-v` on a -diagnostic run when those details are needed. `tests/callback-smoke.py` checks -compatibility with callback event objects. - -## Shutdown and reboot expectations - -Do not shut down or reboot while `cybexos-update-run status` reports `queued` or -`running`. A durable transient service survives a terminal or shell restart, -not a machine power cycle. Wait for a terminal state (`done`, `failed`, or -`cancelled`), or cancel deliberately and confirm the terminal state first. - -While it runs, the worker holds a logind block inhibitor for shutdown and -sleep (`systemd-inhibit --list` shows `CybexOS`), so ordinary power-off, -reboot, and suspend requests, including idle suspend, are refused until it -finishes. `systemctl poweroff -i` overrides it deliberately. Closing a laptop -lid still suspends, because logind's default `LidSwitchIgnoreInhibited=yes` -ignores inhibitors; keep the lid open during an update. The protection is -complete for a system-service worker (Quickshell and release updates). logind -does not apply a user's own inhibitor to that user's requests, and Polkit may -refuse it to a user-service worker altogether; `run.log` then records an -`[inhibit]` line and the update continues unprotected. - -No repository command automatically reboots or powers off the machine. -Ordinary package updates can install a new kernel for the next boot. A direct -bootstrap can rebuild initramfs through the `boot` role. The IPU7 camera role -can also leave `/var/lib/xps-hardware/ipu7/reboot-required` when new signed -DKMS modules must be loaded in a clean boot. Let the play finish, inspect its -result, then perform one normal reboot. `./verify` reports this camera state. - -## Idle, power, and background services - -hypridle runs the idle timeline from **Settings → Power → Idle**, unless a -regular file at `~/.config/cybexos/hypr/hypridle.conf` replaces it. A new -installation locks after five idle minutes, turns the screen off after ten, -and suspends after 30 minutes only on battery, so a machine on mains power, -including every desktop, stays awake. A laptop that reaches the suspend -timeout on mains power checks again every minute until the next input, and -suspends if it is unplugged meanwhile. Existing installations keep their -stored values: the shell writes every key to `shell.json`, so a stored Never -cannot be told apart from a deliberate one. - -A locked screen turns off about a minute after the last input, whether it was -locked by hand or by the idle lock; a Screen off setting of Never keeps it lit. -Every suspend waits for the lock. For a lid close or any other sleep request, -hypridle's `inhibit_sleep = 3` holds the sleep until the compositor reports -the session locked, for as long as logind allows a delay. The shell's Suspend -and idle suspend call `systemctl suspend` only after `hyprctl locked` -confirms the lock. Without that confirmation within eight seconds the machine -stays awake, and a lock screen that is still starting is left running rather -than stopped. - -hypridle reads its configuration only when it starts. A converge that changes -its configuration, its unit, the runtime resolver, or the timeout renderer -restarts a running hypridle, so new timeouts apply without logging in again. -hypridle only tracks idle time, so the restart neither locks nor unlocks the -session. - -Docker is socket-activated: `docker.socket` starts dockerd, and with it -containerd, on the first client request instead of at boot. Containers with a -`restart=always` policy therefore wait for the first `docker` command after -boot. A converge leaves a daemon that is already running alone. - -Docker commands need `sudo` unless the saved `docker_sudoless` answer is -`true`. Membership in the `docker` group controls a root daemon, so it is -root-equivalent; the installer asks for it explicitly and defaults to no. -Earlier releases granted the group without asking: a converge revokes it from -a CybexOS-managed Docker installation whose saved configuration has not -accepted sudoless use. The revocation applies to new login sessions. Run -`cybex configure` to opt in again. `Super+D` starts lazydocker directly with -socket access and through `sudo` otherwise. - -The weekly Btrfs scrub runs only on AC power and reads at most 200 MiB/s per -device. A run skipped on battery is not caught up when the charger returns; it -waits for the next weekly trigger. - -Dictation requires the `developer_tools` feature, which also downloads its -model and binds its keys. Without it the Voxtype user unit is not installed, -and a converge stops and removes one left by an earlier run. The daemon loads -the model when a recording starts and releases it when idle, so the first -dictation after an idle period has a short load delay. - -Game mode (`Super+Shift+G`, or `game-mode on|off|toggle|status [--json]`) -turns the compositor's animations, blur, shadows, window rounding and gaps -off, including the margins around a lone window on an external display, and -holds idle locking and idle suspend off with a systemd idle inhibitor -(`cybexos-game-mode-inhibit.service`, part of `hyprland-session.target`). -An explicit suspend, a lid close and the lock key still work. Turning it off -restores exactly the values it replaced, including any set in `user.lua`; with -power saver also on, whichever ends last restores the original animations. -The helper records the state in `$XDG_RUNTIME_DIR/cybexos/game-mode.json` -(`{"active": true, "instance": ..., "since": ...}`), which the menubar -watches for its indicator. The file names the Hyprland instance, so a -configuration reload applies game mode again once the whole configuration -has loaded, and a new login always starts with it off. When it seems stuck, -compare `game-mode status --json` with -`systemctl --user status cybexos-game-mode-inhibit.service`, and check -`hyprctl repl 'return __cybexos_game_mode.error'`; `game-mode off` followed -by `hyprctl reload` restores the configured values in any case. - -mpv prefers hardware decoding: a managed block at the top of -`/etc/mpv/mpv.conf` sets `hwdec=auto-safe`, which uses only the decoders mpv -considers reliable and falls back to software otherwise. Lines below the block -and a personal `~/.config/mpv/mpv.conf` override it; uninstall removes only -the block. - -## The strict source gate - -`tests/run` executes every stage even after a failure and returns nonzero when -any stage failed. Missing Node, Python, Ansible, qmllint, or QML runtime tools -are failures rather than silent skips. The exact stage order and count come -from the executable itself: - -```bash -./tests/run --list -``` - -The stages cover whole-source/Ansible syntax, ShellCheck, ansible-lint, -yamllint, Ruff, Node unit tests, Hyprland workspace fixtures, QML static -analysis, offscreen helper contracts, real-Quickshell component lifecycle -coverage in CI, Quickshell deployment integration, callback and Python -fixtures, transactional agent-skill lifecycle coverage, XPS hardware -integration, Plymouth layout, the durable updater, screenshot/brightness -workflows, Btrfs snapshot retention, and the fedora-config name migration. -QML static analysis also lints the three real-engine test harnesses, each over -a scratch copy of the tree, so a harness that drifted from the components it -drives fails locally even while the managed shell keeps the real-engine run -itself CI-only. The deploy's own lint (`tests/qml-lint --shell-only`) checks -the shell alone. - -Independent stages run side by side, longest first, and their results print -in the fixed order with the verdict last. `--jobs N` bounds the concurrency -(`--jobs 1` runs serially) and `--only STAGE` runs a single stage. Every stage -has a time limit (300 seconds; 600 for static lint and the Python fixtures), -and a stage that exceeds it fails with whatever it printed rather than holding -the gate open. - -The GitHub workflow runs the same `./tests/run` command in a Fedora 44 -container. The lower-level worker stops before Ansible if this gate fails or -does not finish within 30 minutes. A separate `Fedora preview` workflow runs it -in the next Fedora release's container with the same dependencies. It is not a -required check, never runs for a release, and does not make that Fedora -release supported; see "Fedora 45 qualification" in -[the major-upgrade runbook](fedora-major-upgrade.md#fedora-45-qualification). -`--skip-tests` is reserved for an already verified release candidate and is -not part of the public command interface. - -The weekly and manually dispatchable `Fedora VM convergence` workflow adds a -slower system boundary: it checksum-verifies the pinned official Fedora 44 -Cloud image, boots it under QEMU, applies every role twice, requires a -zero-change second pass, checks preserved user collisions and firewall state, -and then verifies uninstall restoration. Run the same test locally with -`./tests/fedora-vm-convergence`; set `FEDORA_VM_TAGS` to a comma-separated role -subset only for a deliberately narrower development run. - -## Reduced motion - -Quickshell treats `QS_REDUCED_MOTION=1`, `true`, `yes`, or `on` -(case-insensitive) as a request to remove shared transitions, movement, -stagger, and press animation. Qt does not currently supply this preference to -the shell automatically. Make it durable with a user-service drop-in: - -```ini -# ~/.config/systemd/user/quickshell.service.d/reduced-motion.conf -[Service] -Environment=QS_REDUCED_MOTION=1 -``` - -Then run `systemctl --user daemon-reload` and restart the managed -`quickshell.service` at a safe time. Remove the drop-in and repeat those two -commands to restore motion. Repository runtime tests set the variable only in -their isolated offscreen process. - -The power saver profile is a reduced-motion request too, for as long as it is -on; it changes neither this variable nor **Settings → Appearance → Reduce -motion**. The compositor follows it as well: it uses one blur pass and -Hyprland animations turn off, and the values it replaced return when power -saver ends. diff --git a/docs/overview-and-dock.md b/docs/overview-and-dock.md index e418f53d..7ccdc86b 100644 --- a/docs/overview-and-dock.md +++ b/docs/overview-and-dock.md @@ -6,7 +6,7 @@ list ship on; floating workspaces show the dock automatically. ## Window overview `Super+Tab` opens the overview on the focused screen. A three-finger swipe up -on the touchpad, and `cybexos-runtime ipc overview toggle` (also `open` and +on the touchpad, and `nerdos-runtime ipc overview toggle` (also `open` and `close`), open the same overview. Every workspace that has windows is a tile, along with the workspace on screen @@ -54,7 +54,7 @@ the focused app has a wider accent mark. Until you pin or unpin something, the dock suggests your default browser, Files and the terminal. Pins are desktop entry ids stored in `dockPinned` in -`~/.config/cybexos/shell.json`. A pinned app that is not installed is not +`~/.config/nerdos/shell.json`. A pinned app that is not installed is not shown, but it stays pinned. Browser web-app windows, including Brave's URL-based app windows, match their @@ -77,7 +77,7 @@ The Control Center's Overview tab lists the six busiest processes under the CPU and memory meters. **CPU** and **Memory** choose the ranking. CPU is each process's share of the whole machine since the previous reading, on the same scale as the CPU meter. The list is read every two seconds only while it is on -screen, by one short-lived helper (`scripts/processes`) that uses shell +screen, by one short-lived helper (`desktop/quickshell/scripts/processes`) that uses shell builtins only. Hide it under the Control Center widget's **Overview** settings (**Top processes**). @@ -98,7 +98,7 @@ Node tests cover the pure rules: `overview-helpers`, `dock-helpers`, and `window-commands`. `desktop-navigation.test.cjs` covers the QML lifetimes and wiring. The real-engine lifecycle harness builds the overview view on an empty desktop and runs two samples of the process list. -`image/test_desktop_payload.py` checks that both installation paths ship the +The shared NixOS module checks checks that both installation paths ship the same files and defaults. Live behaviour still needs checking on a Hyprland session: thumbnail capture, drag-to-move, multi-monitor tiles, dock auto-hide over a bottom bar and the touchpad gesture. diff --git a/docs/quickshell-notes.md b/docs/quickshell-notes.md deleted file mode 100644 index 8b8933b2..00000000 --- a/docs/quickshell-notes.md +++ /dev/null @@ -1,938 +0,0 @@ -# Working on the Quickshell shell - -Notes for anyone changing `roles/desktop/files/quickshell/` — how to test it, -the traps that cost real time to find, and the things already decided against. - -Distilled from `docs/qml-improvement-plan.md`, a 43-package refactor completed -2026-08-08 (42 done, 1 measured and declined). That plan is in git history if -you need the reasoning behind a particular change; `git log --oneline --- roles/desktop/files/quickshell` is usually faster. - -## Ground rules - -- The Ansible role is the vendor source of truth. Its deployed runtime is - `~/.local/share/cybexos/runtime/quickshell`; never edit that generated - copy expecting the change to survive. User settings and overrides live in - the paths documented by `docs/architecture/ownership.md`. -- Match the surrounding style. **Do not run qmlformat** (see "Already decided - against"). -- Theme values come from `Common/Theme.qml`. Add a token rather than a literal - when the value expresses a design role. -- New shared components: PascalCase file in the directory that owns the - concern, and **add it to that directory's `qmldir`** — a directory carrying a - `qmldir` is no longer implicitly scanned, so an unlisted type fails at - runtime as "X is not a type". `tests/quickshell/qmldir.test.cjs` enforces it. -- Pure logic goes in a `.js` module in `Common/` with a Node test in - `tests/quickshell/` — that suite runs in under a second without Qt. -- `tests/run` is the strict source gate: language-aware static analysis, Node - tests, QML static/runtime checks, integration contracts, and the - repository's other fixtures. `update --full` runs it before deploying, and - the Ansible role lints the tree before copying a changed one. See - `./tests/run --list` and - [the operations guide](operations.md#the-strict-source-gate). - -## Testing without a GUI - -The built-in [authentication dialog](authentication-dialog.md) uses Quickshell's -Polkit service. Its presentation and startup checks are covered below; its -authentication backend remains the system Polkit/PAM stack. - -Run `./tests/run` first; it needs no live shell. External widget tests require -`sway` for a disposable headless Wayland compositor (also installed by CI); -this is a test dependency, not a change to the desktop's compositor. -For a live deployment, keep -`quickshell.service` as the only `qs` process and use the shared safety harness -at both boundaries: - -```sh -set -euo pipefail -source tests/lib/quickshell-live -cleanup() { - rc=$? - trap - EXIT INT TERM - qs_live_end || rc=1 - exit "$rc" -} -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - -qs_live_begin -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags quickshell -qs_live_wait_ipc 20 popouts close >/dev/null -qs_live_wait_ipc 20 popouts toggle t3code # or: audio, control, wifi, notifications, … -qs_live_wait_ipc 20 settings open notifications -grim -g "1020,50 400x420" /tmp/shot.png -qs_live_end -trap - EXIT INT TERM -``` - -`qs_live_begin` compares the service MainPID with every `pgrep -x qs` result, -inspects command lines and cgroups, and only terminates a confirmed unmanaged -`qs -d`/`qs -p` developer process. `qs_live_end` requires the service to be -active, its MainPID to be the sole `qs`, and the current invocation journal to -be free of known QML/runtime errors. IPC readiness checks target the service PID -so runtime migration cannot send them to a dead default configuration. Never -replace this with `pkill qs`. - -The Ansible role is the supported deployment path. A test that temporarily -edits the deployed tree must be trap-protected, restore the exact managed -manifest (including destination-only file removal), wait for IPC readiness, -and then call `qs_live_end`; `tests/t3-contract-snapshot` is the working -example. Do not commit while a throwaway live copy is deployed. - -### Techniques that work here - -- **`console.log` never reaches stdout or the qslog**, under any - `QT_LOGGING_RULES`. `console.warn` *does* reach - `journalctl --user -u quickshell.service`. A harness that must report a value - writes a file: - `Quickshell.execDetached(["sh", "-c", "printf '%s' \"$1\" > \"$2\"", "sh", text, path])`. -- **Offscreen harness**: `tests/run` points HOME/XDG paths at a scratch - directory. Generic `qmltestrunner` covers helpers; Quickshell's plugin is - statically linked into `qs`, so the production-component lifecycle harness - runs through the real engine in CI and refuses to run beside an active local - `qs`. If another direct source-tree `qs -d`/`qs -p` probe is indispensable, - stop `quickshell.service` first and install a trap that terminates the exact - developer PID, restores the service, and finishes with the same sole-PID and - current-journal checks as `qs_live_end`. Never run the probe beside the - managed service. -- **Triggering internal code paths live**: add a throwaway `IpcHandler` target - to the *deployed* `shell.qml`. Cheaper than staging the real event, and it - exercises the shipped code rather than a copy. -- **Keyboard**: `wtype -k Tab` / `wtype -k space` sends real key events, which - is how focus order and activation get tested end to end. -- **Updates drawer states without updating anything**: write a run record - (`status.json`, `dnf.log`, `flatpak.log`, `firmware-events.log`) under - `~/.local/state/cybexos/update/logs//` and put the id in `../current`. - The id has the form `YYYYmmdd-HHMMSS-N-N`. Set the record's `unit` to a - disposable `systemd-run --user … sleep 900` unit, never a real service: - `status` treats an inactive unit as an abandoned run, and a stray Cancel - stops whatever unit the record names. Restart `quickshell.service` so it - attaches, rewrite the record with the same log prefixes to advance it, and - remove the run and `current` afterwards. -- **Pointer**: `hyprctl dispatch 'hl.dsp.cursor.move({ x = …, y = … })'` takes - absolute screen-logical coordinates. Call it in a loop until - `hyprctl cursorpos` agrees — the first call after the pointer has been - elsewhere can land short. With `grim -c` this makes cursor shape and hover - state observable. -- **Multi-monitor without hardware**: `hyprctl output create headless`, then - read the name back (`HEADLESS-1`). The shell keeps a bar mapped on every - output, so `hyprctl layers -j` can directly verify one `qs-bar` surface per - output and only one `qs-bar-popout` on the output whose bar was clicked. -- **This Hyprland speaks a Lua dispatch dialect** — - `hyprctl dispatch 'hl.dsp.focus({ monitor = "" })'`. Plain - `focusmonitor` / `movecursor` do not exist. -- **Prove the probe has teeth.** Reconstruct the pre-fix code in the throwaway - copy and confirm the probe fails. A check that cannot fail has not verified - anything. - -### Pixel diffs - -`compare -metric AE` is **not** a pixel count in this ImageMagick 7 — it -returned 6.7e7 for a 120×80 crop. Use: - -```sh -magick a.png b.png -compose difference -composite -colorspace Gray \ - -threshold 8% -format '%[fx:mean*w*h]' info: -``` - -and always check it against a deliberately broken variant before believing a -zero. Then subtract the noise floor: capture the *same* tree twice and diff -that too. Live values move constantly — the control centre differs from itself -by ~134k px eight seconds apart (CPU/RAM/temp), the battery popover by its time -estimate, several settings pages by their clock previews. A panel's outer edge -blends with whatever is behind it, so `-shave 12x0` before comparing. - -## Traps - -- **An unqualified reference to another singleton's property lints clean and - throws at runtime.** Inside a Quickshell `Singleton`, qmllint cannot resolve - the scope, so `threadMap` where `T3Threads.threadMap` was meant produces no - warning — and every call throws `ReferenceError`, invisibly except in the - journal. This shipped three times during the T3 split (WP5.1, WP5.2, and - settle/snooze after Phase 5). `tests/quickshell/t3-singleton-scope.test.cjs` - now enforces a watchlist; extend the list when a new cross-singleton name - appears. -- **A `Connections` handler that matches nothing on its target is silently - dead.** qmllint has no opinion, the configuration loads, and Quickshell logs - one WARN at reload and never calls it — so the code reads as wired and does - nothing. The T3 façade makes this easy: views only talk to `T3Code`, so a - handler for state that still lives on `T3Drafts`/`T3Detail` looks right at - both ends. Shipped three times (`threadDrafts`/`userInputDrafts`, - `newThreadConfirmed`, `detailThreadId`). Re-export on the façade — a - property binds, a signal needs its own declaration plus a relaying - `Connections`. `tests/quickshell/connections-handlers.test.cjs` now resolves - every handler against its target singleton's real surface. -- **In-place mutation never re-evaluates a binding.** Mutating an object or - array in place is invisible to QML; reassigning it notifies. Both behaviours - are useful — a memo cache wants the former, an invalidation wants the latter - — but mixing them up silently breaks either the update or the performance. -- **A counter written from inside a binding can feed back into the binding - graph.** Instrumenting `iconSource` with `property int` counters drove the - shell to 171% CPU and 6.6 GB and filled `/run/user/1000` with a 3.1 GB log, - after which the restarted instance could not create its IPC socket. Count in - a `.pragma library` script instead — module scope is not a QML property, so - nothing can capture it. Note such scripts are **cached past a hot reload**; - changing one needs `systemctl --user restart quickshell.service`. -- **A `.js` imported without `.pragma library` gets a separate copy per - importing component.** Fine for stateless helpers, useless for shared state. -- **`readonly property int` silently truncates.** Card heights are text metrics - plus padding and land on fractions; an `int` cost a pixel and shifted - everything below it. Use `real` for anything derived from text. -- **Quickshell does not watch `qmldir` files.** A qmldir edit needs a `.qml` - touch before it reloads. -- **`Loader.active` defaults to true**, so `onActiveChanged` fires only for - slots that evaluate false. Absence of `active=true` lines is a logging - artifact, not a gate that failed. -- **`signal-handler-parameters` cannot be satisfied** for - `Process.exited(int, QProcess::ExitStatus)` — the enum is not registered with - QML. The apparent fix (`function onExited(exitCode) {}`) silences qmllint - *and* stops Quickshell calling the handler. Disabled in `.qmllint.ini` with - that reason. -- **Quickshell emits no `exited` at all when a binary cannot be launched** — - only the falling edge of `running`. Anything reading exit status must handle - a never-started process; `Common/ProcHelpers.js` has the sentinel. -- **Popout lifetime**: `PopoutHost` latches the Control Panel, so it is - constructed once and never destroyed. Anything refcounted must key on - `visible` via `Common/Claim.qml`, not on construction or destruction. -- **Idle CPU is not measurable with `top` on a machine in use** — sampling the - live shell gave 0.12%–6.44% across windows of *identical* code. Instrument - both trees and count timer firings in the journal instead. -- **A pixel-identical bar is not a working bar.** WP4.3 shipped a module - registration regression that looked perfect in a screenshot. `tests/run` - carries a duplicate-handler check because qmllint has no opinion on that and - the failure mode is a shell silently running stale code. -- **`hyprctl cursor.move` warps the pointer without delivering hover to the - client.** No `MouseArea` under it sees `onEntered`, and `containsMouse` stays - false — verified by probe, with the bar's own `HoverHandler` reporting - `hovered: true` and a live position at the same moment. The cursor *shape* - still changes, so a screenshot looks like a real hover and is not one. A - uinput virtual pointer emitting `EV_REL` a pixel at a time does generate real - motion, but Qt's legacy hover path still did not pick it up here — so testing - anything gated on `containsMouse` needs a human hand on the mouse. What is - testable without one: pin the raw hover state to `true` in the deployed copy - and check what the bar-wide validation does with it. -- **Binding an item's visibility to a descendant's `visible` latches it at - false.** `visible` reads back *effective* visibility — the item's own flag - ANDed with its parents' — so a wrapper written as `visible: child.visible` - depends on itself and can never leave false. It looks correct as long as the - child starts visible, which is why it survived review: only modules that turn - on *later* (a track starts playing, updates appear, a tray icon registers) - stayed missing. Bind to the underlying condition instead — `Loader.active`, - not `Loader.visible`. -- **A defaulted property that a safety check depends on will eventually be - left unset.** `BarIcon.host` looked like panel wiring, so the idle module — - which owns no panel — never set it, and `BarTooltip` silently fell back to - the local `containsMouse` it exists to second-guess. A missed exit event - then stranded "Idle inhibit off" on screen with no path back to false. - `host` is `required` on `BarIcon`/`BarChip`/`BarTooltip` now and - `RequiredProperty` is an error in `.qmllint.ini`; the general lesson is that - a null-degrades default turns a loud failure into a silent one. -- **Lock state comes from `hyprctl locked`, never logind's `LockedHint`.** - Nothing in this session sets the hint, so it reads "no" behind a working - lock screen. A lock helper that times out must leave a running locker alone: - stopping it unlocks the session with nobody at the keyboard, and staying - locked is the only safe failure (`cybexos-session-action`). -- **`hyprctl` speaks the Lua config too.** `hyprctl dispatch X` runs - `hl.dispatch(X)`, so X must be an `hl.dsp.*` expression; a bare `exit` is - nil and does nothing. `hyprctl keyword` is refused under a Lua config and - still exits 0; set options with `hyprctl eval 'hl.config({ … })'`, which - fails loudly. `HyprlandToplevel.address` is bare hex, while the `address:` - selector needs the `0x` prefix. A window selector that does not resolve - falls back to the *active* window, so closing or moving a window that has - just gone away acts on whatever has focus: check the address is still in - `Hyprland.toplevels` right before dispatching, and build the string with - `Common/WindowCommands.js`, which refuses anything it cannot quote. -- **`HyprlandToplevel.lastIpcObject` comes only from a toplevel refresh.** - Quickshell follows titles, workspaces and focus from Hyprland's events, but - the `hyprctl clients` record (geometry, class) arrives with - `Hyprland.refreshToplevels()`; a window opened since the last refresh can - have `{}` there. The window overview refreshes when it opens and after - window events while it is up. For a live application id, read - `toplevel.wayland.appId` instead. -- **`ClippingRectangle` draws its content through a `ShaderEffectSource`.** - Around live content (a `ScreencopyView`) that is an extra offscreen pass on - every frame, and nesting multiplies it. Clip static layers only; the - overview clips each workspace's wallpaper, not its window captures. -- **A bare `qs ipc call` reaches nothing.** The shell runs by path - (`qs -p `), so there is no default configuration to find. Scripts, - bindings and docs use `cybexos-runtime ipc TARGET FUNCTION …`, which - resolves the path the service started with, development checkout included. -- **`luajit` reading a script from stdin without `-` exits 0 after a failed - assertion**, so a heredoc fixture can never fail. Write `luajit - <<'LUA'`. -- **FileView `setText` skips text it believes it already holds.** It compares - against the last bytes it read *or tried to write*, a failed attempt - included, and a match emits neither `saved` nor `saveFailed`, so a write - guard waiting for one never clears. Settings and Notes track that text - (`storeText`), settle without writing when the file already has the - content, and retry identical content with one extra trailing newline. - Quickshell logs a failed atomic commit (fsync or rename) and still emits - `saved`. Never call `setText` under a live async write; a `reload()` issued - under a write is dropped, which is why Settings' `reloadStore()` defers it. -- **Qt refuses to clear `activeFocusOnTab` on the focused item.** A roving tab - stop that follows the selection must `forceActiveFocus()` the new target - before committing it, or the old item keeps a second tab stop. -- **hypridle's `condition_cmd` runs synchronously in its loop** (0.1.8), so - keep it fast and bounded. A failing condition skips the listener's - `on-resume` as well as its `on-timeout`, and any input cancels pending - `condition_retry` attempts. -- **Python retries `poll()` and `sleep()` after a signal handler runs**, so a - handler alone never ends an unbounded wait: register the loop's pipe with - `signal.set_wakeup_fd` and poll it too (`xps-haptic-touchpad`). -- **`pactl` translates the headers of its long listings** ("Sink Input #" is - "Afvoer-invoer #" in Dutch). Parse them under `LC_ALL=C`. -- **An EDS connect wait of 0 means "wait forever".** `calendar-events.py` - connects its sources in parallel with a one-second wait, and at its deadline - returns the calendars that answered plus a `sourceErrors` entry for each - that did not. - -## Churn and lifetime invariants (2026-09 robustness pass) - -A review of efficiency and robustness left these contracts in the code. Keep -them when changing the surrounding files. - -- **Streaming transcripts update rows, not arrays.** `T3ThreadPage` and - `HermesTranscript` draw from a `ListModel` keyed by message id and apply - edit scripts (`T3CodeHelpers.historyRowOps`, `HermesHelpers.listSyncOps`); - a token is one `setProperty`. Per-row UI state (expanded, editing) lives on - the page keyed by id. T3 detail histories stay sorted, so `upsertHistory` - inserts by binary search instead of re-sorting. Hermes stores messages in - place: bind to `transcriptRevision`/`transcriptChanged`, not to - `messagesByConversation`. -- **Repeaters over derived lists take a structural key.** Bar clusters, - indicators, workspaces, the drawer network list, and the - GitHub Inbox and repository rows parse their model from a JSON string of - ids, and delegates look up live data by id, so a value change flows through - bindings instead of recreating delegates. GitHub Inbox sections and drawer - Bluetooth sections are fixed models; drawer Bluetooth and Sound rows go - through `ScriptModel`, which diffs them by device and node identity. -- **`Settings.applyLoaded` assigns only changed keys** (`assignChanged`) and - ignores a reload whose bytes equal the last write. Reassigning an unchanged - var key still notifies and rebuilds every bar module. A settings file from a - newer schema is applied read-only and never saved over. -- **Popout slots are visible only while fronted, fading or requested.** An - outgoing panel turns invisible when its fade ends, which releases its - `Claim`s and visible-gated timers. A presented slot is fronted before its - card turns visible; otherwise the latched drawer counts as visible for one - turn, and its tab's claims start and release their pollers on every - unrelated open. A spinner's `running` includes its own `visible`, since the - outgoing panel stays alive until it closes. -- **One `nmcli monitor`** lives in `NetworkStatus`; `EthernetState` listens to - its `monitorEvent`. Both 30 s safety polls run only while it is down, and - restarts back off 5 s → 60 s. Battery health follows the same rule with - `upower --monitor-detail`: a safety poll runs only while its event stream is - down. Tailscale polls every 120 s for plain `acquire()` claims and every - 30 s for `acquireLive()` views, neither while idle; a missing binary drops - it to an hourly probe, and a view that opens probes at once. -- **Tailscale sign-in** uses the Network overlay for first-use setup and the - shared `Tailscale` singleton for the pending session. Continue releases the - overlay's keyboard focus before browser handoff or a polkit prompt. Login - URLs stay in memory; closing the dialog keeps sign-in running. A two-second - status poll lasts up to five minutes, and only a successful status snapshot - with a Tailscale address reports connection success. `NeedsMachineAuth` - displays administrator approval separately. Stop connecting uses `down`, - preserving the account. Commands are bounded by `timeout`; permission - failures retry once through `pkexec`, without changing the machine's - operator or polkit policy. First sign-in consumes `up --json` incrementally; - reconnection uses bare `up` to preserve non-default preferences (even adding - `--json` changes Tailscale's preference checks). The existing-account path - also recognizes a standalone HTTPS authentication URL on stderr. -- **The Network panel's live figures cost no process per sample.** Throughput - reads `/sys/class/net//statistics` through FileView on the 1.5 s tick, - and `primary` carries those live counters; latency comes from two - long-running `ping -n -O` processes. The device/route/profile/scan snapshot - runs on `NetworkStatus.monitorEvent` (debounced), on a change in the scanned - SSID set, after actions, and on a 10 s safety poll. All of it runs only - while a view holds the panel. -- **Plugin discovery** watches `plugins.json`, which `plugin update` touches. - Package trees are polled every 5 min only while a plugin is enabled (2 s - while Settings is open), never while idle, and a package is rehashed only - when its stat signature in `/.revisions.json` changes. A - registry error keeps the last good plugins. A successful scan clears - discovery failures even when its output matches the last good scan. Scan - health and mutation failures are separate: a rescan cannot hide a failed - preference write. Registry lock acquisition waits at most five seconds, - then reports that preferences are busy and asks for a retry without - changing preferences or interrupting the holder; queued edits continue. - Git clone and fetch remain outside the registry lock. -- **Long-lived helpers are bounded.** `gh` reads, brightness reads/writes, - matugen, `calendar-events.py`, wallpaper thumbnails and the plugin scanner - each have a timeout or watchdog. Helpers settle on the falling - edge of `running`, so one that never starts cannot wedge its queue; that - includes the Reminders list, the plugin writer, the launcher's search - processes and the brightness re-read a mid-read refresh leaves pending. -- **Settings, Notes and launcher usage write asynchronously.** Settings and - Notes keep one write in flight (see the FileView trap); launcher usage is - written 2 s after a burst of launches, and synchronously if the shell goes - first. - -### Idle and energy (2026-09-23) - -- **`Common/Activity.qml` is the one idle and power signal.** `idle` comes - from an `IdleMonitor` (300 s, respecting inhibitors, off outside a Wayland - session); `onBattery` and `powerSaver` follow UPower and the power profile; - `resumed` fires on the first input after idle. Background pollers stop while - idle and refresh what went stale on `resumed`. A timer longer than an idle - spell (wallpaper rotation) keeps running, and work that falls due while idle - is owed to `resumed`. -- **GitHub gates scheduled timers on `scheduleActive`**: the module - is on, the session is not idle, and the network is not known to be down. - Manual refreshes are never gated. -- **Poll only for a consumer.** Updates checks in the background only for its - widget or its notifications; opening the Updates panel or the drawer - Overview refreshes a stale count. Weather fetches only for a set location - with its widget on or a Day sheet claim; the calendar polls only for a Day - sheet claim. -- **Updates retries only what failed.** The retry budget resets on the online - edge, startup, a manual refresh or an all-success check, never on a - scheduled poll, and each source has its own notification baseline. dnf's - answer is reused while the repomd/repo/rpmdb signature is unchanged; manual - and post-run checks force a real read, and one happens at least every 6 h. -- **Never reload a watched FileView on a timer.** Each reload rebuilds its - inotify watches, and the directory watch already sees creation and atomic - replacement. Poll only while the file is missing, and make sure its - directory exists (Recorder, Dictation). -- **Parsed commands set `LC_ALL` through the Process `environment`**, not an - `env` process per run. -- **Integration sockets exist only while wanted.** `T3Connection.enabled` and - `HermesConnection.enabled` follow their bar module, or their panel while it - is open, and `T3Connection.connect()` is the single gated entry. Naming a - connection singleton constructs it, so ShellHealth checks `Settings.mods` - first. Backoff starts over only once a link has proven healthy (T3: its - first shell snapshot via `markHealthy`, or 60 s connected; Hermes: 60 s), - never merely because a socket opened. T3 retries rest while the machine is - known offline (a loopback server is exempt) or idle, Hermes's only while - idle. An expired T3 Connect session is a state, not an error: the ticket - helper exits 3, the link goes signed-out without retrying, and the panel - offers Sign in. -- **Nothing ticks faster than it reads.** The recording mark steps with - `Recorder.elapsed` instead of an infinite animation; the mic meter samples - at ~15 Hz into whole pixels; reminder countdowns wake at - `CountdownHelpers.soonestChangeMs`; HH:mm captions use a minutes clock that - runs only while visible. -- **Power saver** is part of `Theme.reducedMotion` (`Activity.powerSaver`). - The compositor side is `cybexos_power_saver()` in `looknfeel.lua`, whose - state lives in `_G` so a config reload reapplies it. - -## Layout - -- `Common/` — singletons (services, settings, theme), pure `.js` helpers, and - the shared controls both other directories draw (`Toggle`, `HSlider`, - `NotifCard`, `NotifIcon`, `NotifActions`). -- `Bar/` — the menubar, the popout host, and 13 modules under `Bar/Modules/` - sharing a `BarModule` base. `Cluster.qml` is what turns a run of adjacent - modules into one shared pill (see `LayoutHelpers.groupModules`); `Bar.qml` - owns the furniture at either end and the fit pass. -- `Popovers/` — panel contents, all built on `PopoutPanel` / `Surface`. -- `Settings/` — the settings window; rows build on `Settings/SettingsRow.qml`. - Keyboard and Touchpad edits belong to the session-owned `Settings/InputDrafts` - singleton: search, page navigation and closing the window retain them until - Apply or Discard. Their original backend version stays with the draft, and - a completion settles only the submitted section, even after its page unloads. - Network checkpoints and display trials retain their own rollback lifetimes. - Search matches catalog translations and English aliases; non-preference - destinations use `searchAnchor`, separate from `shell.json` keys. -- `tests/quickshell/` — Node tests. `shell.cjs` locates the source tree; - `load("X.js")` pulls a helper out of `Common/`. - -Two conventions worth knowing: a settings row that names a `settingKey` gets -its value, dirty state, commit and undo from the base, and per-surface styling -travels as a single `var` object (`Theme.switchRow`, a card's `style`) rather -than as a dozen properties. - -### Launcher providers and actions - -`Common/LauncherProviders.qml` owns command-palette routing, results and side -effects; `LauncherView.qml` only renders its normalized rows. Every open and -every close resets to the Apps tab; Emoji, History (clipboard), and Actions -are discoverable tabs beside it in a compact 460px card. The strip is 34px -tall, the search field is 44px, and an up-to-eight-row viewport uses 42px rows -with 28px icons. The Apps tab keeps every visible desktop entry in its model -and scrolls inside that fixed viewport; keyboard selection keeps the active -row in view. Rows show one line only; action subtitles and keywords remain -searchable metadata but do not add visual bulk. - -The launcher's layer surface is the card's full-height envelope plus its -entry travel, anchored top-left at offsets from the output edge, not the whole -output: the compositor blurs every pixel of a blurred surface. Clicks outside -it clear the `HyprlandFocusGrab`, which closes the launcher. - -`Ctrl+Tab` and `Ctrl+Shift+Tab` cycle tabs. `Left`/`Right` also cycle tabs -while the input is empty. With text or an argument prompt, `Left`, `Right`, -`Home` and `End` retain normal caret, word and selection editing (including -Ctrl and Shift). With empty input, `Home`/`End` select the first/last result. `Up`, `Down`, -and result-navigation `Tab` wrap at the list ends; `PageUp`/`PageDown` jump six -rows and clamp to the first or last result. `Alt+1…8`, immediate -`Enter`, and clipboard `Shift+Delete` remain available. Escape clears a -non-empty query and returns to the active tab's full results; a second Escape -closes the launcher. The result highlight and its glyph color change -immediately without a transition. Switching tabs clears the search field so -results never carry across provider boundaries. - -Typed prefixes temporarily override the selected tab: `/` files, `>` command, -`=` calculator, `@` web, `$` windows, `;` clipboard, `:` emoji, and `!` actions. -Removing the prefix returns to the selected tab, so the compact tab strip does -not displace the existing keyboard-first routes. - -Clipboard history is collected by `cliphist`. The store helper skips offers a -password manager marks as secret (`x-kde-passwordManagerHint`, reported by -`wl-paste --watch` as `CLIPBOARD_STATE=sensitive`). In the History tab, -`Enter` copies the selected entry and, once the launcher has released keyboard -focus, pastes it into the previously active window; `Shift+Enter` only copies. -`Ctrl+P` pins or unpins the selected entry, and the search field names that -key. Pinned entries are listed first with a pin glyph. They are copies kept by -`scripts/clipboard-tool.py` under `~/.local/share/cybexos/clipboard/` (at most -50 entries and 64 MiB; text up to 256 KiB, images up to 10 MiB), so cliphist's -500-entry pruning and `cliphist wipe` do not remove them; a full store refuses -a new pin rather than dropping an old one. `Shift+Delete` or right-click -removes a history entry, or unpins a pinned one. Image entries show a -thumbnail, and the selected image grows its row to show a larger preview. -Previews are decoded into `$XDG_RUNTIME_DIR/cybexos/clipboard-previews/`, -which holds only the images the current history lists and is bounded to 64 -files and 48 MiB. `Super+Ctrl+V` opens the launcher on the History tab. - -Emoji names come from Fedora's `unicode-emoji` data. Activating an emoji -copies it and pastes it the same way; `Shift+Enter` only copies. Both -providers degrade to a readable empty-state error when their package is -unavailable. - -`cybexos-runtime ipc launcher open ` opens the launcher on a tab -(`apps`, `emoji`, `clipboard`, `actions`), or on Apps with a provider's prefix -typed (`files`, `command`, `calculator`, `web`, `windows`). An open launcher -switches to it rather than closing. - -#### Actions: the command palette - -The Actions tab is a curated catalogue in `Common/LauncherProviders.js`, -grouped by verb in this order: Install, Remove, Set up, Toggle, Update, -Capture and System. Each row shows its group on the right. Entries run a -command from `~/.local/bin` (where both installation paths put the CybexOS -helpers), open a Settings page, or call the shell directly. Captures start only -after the launcher has left the screen. Entries for a feature the installation -left out are hidden: the AI agent entries need developer tools. Factory reset -is deliberately not in the launcher; it stays in Settings. - -A typed Apps query also lists up to three actions after the app matches, each -marked "Action", when the query starts a word of the action's name or -keywords. The empty Apps query stays the plain application directory. - -Some actions need one value first. **Install web app…** asks for a web address -(a bare host gets `https://`), **Install theme from Git…** for an https or SSH -repository, and **Remove web app…** lists the installed web apps; the chosen -row asks for a second `Enter` before it removes anything. The search -field becomes that prompt, its chip names the action, and `Escape` returns to -the list it came from. `Enter` runs the command with the value as its own argv -element; no shell parses it. Commands without a value run under a fixed -wrapper that raises a notification when they fail. - -`launcher-actions.json` at the shell root adds the user's own actions; they -are listed with the tag Custom. The file is watched for changes. Each user -action must provide a display name and an argv-style command; a string shell -command is rejected deliberately. For example: - -```json -[ - { - "id": "notes", - "name": "Open notes", - "subtitle": "Open the notes folder in Nautilus", - "keywords": ["documents", "writing"], - "command": ["nautilus", "/home/alex/Documents/Notes"] - } -] -``` - -## Dialogs are the menubar unrolled - -Every surface in the shell — the settings workspace, T3 Code, the GitHub -workspace, the control centre, the network and audio panels, the notification -centre and its toasts, the launcher, the OSD, the shortcut sheet — used to be -a stack of filled, bordered cards on a lighter surface, and two of them were -in a face of their own. They all follow the menubar now. Four rules, and -`Common/Theme.qml` carries the tokens: - -- **One surface.** A dialog sits on `Theme.panelSurface` (the shell's deepest - surface, glass-aware) with no card stacked on it. `Theme.chip` / - `Theme.chipHover` are the only fills left inside: a text field, a row that is - current, a segment that is taken. -- **A section is a label plus a hairline.** `Settings/SectionHeader.qml` is the - shape — uppercase `fontMicro`, letter-spaced, then a rule to the edge. - T3's inbox groups and the GitHub workspace draw the same mark inline. - `SettingsGroup.qml` is a layout, not a Rectangle; there is nothing left to - paint. -- **One accent, four places.** The current workspace pill, a live status dot - and its working label, the current page's icon in the settings rail, and an - on-switch track or selected swatch ring. Never a nav-row background, never a - selected segment fill, never a wash behind a title, never a slab behind the - selected launcher result or a connected device. `typography.test.cjs` bans - `accentBg*` / `accentSoft` / `accentSubtle` / `accentContainer` as a `color:` - or `border.color:` shell-wide, with a short allow-list naming the four fills - that earn it: a slider's value readout, a switch or quick-toggle track, the - one primary action per panel, and the current-day / current-workspace pill. -- **One face.** `Theme.fontMenu`, the Typography setting, everywhere. - `T3Theme.fontUi` is the T3/GitHub indirection. `Theme.fontSans` is now only - what `fontMenu` falls back to; **naming it in a view is how a surface opts - out of the setting**, which is exactly the bug this closed, so - `typography.test.cjs` bans it outside Theme itself. - -Metrics live in Theme's `---- dialog metrics ----` block: `panelRadius` follows -`Settings.barRadius`, so squaring the menubar squares the panels under it; -`panelRowHeight` 28 is a settings row, `listRowHeight` 34 is one menubar-tall -list row, `panelTileHeight` 48 is the occasional two-line form, -`sectionHeaderHeight` 22 is the mark above them, and `panelHeaderHeight` / -`panelFooterHeight` are a panel's title block and its one-line footer. - -Two aliases changed meaning rather than value: `Theme.cardFill`, -`Theme.tile` and `Theme.insetSurface` now resolve to `Theme.chip`, and -`cardRadius` / `rowRadius` / `tileRadius` to `chipRadius`. There are no cards -left, so the names that meant "a container with a fill and a border" mean the -menubar's resting chip — which is why most panels needed no edit of their own. -`popRadius` follows `panelRadius`. `surfaceRadius` did **not** move: it is -Hyprland's window rounding (`roles/desktop/files/looknfeel.lua`) and the Hug -corners that must match it, and `bar-geometry.test.cjs` pins the pair. - -Two things this pass had to fix, both worth remembering: - -- **A fixed pixel lane beside a text label breaks when the face changes.** The - GitHub inbox positioned its Settled count at `leftMargin: 62`, which cleared - the word only in a proportional face; in JetBrains Mono the two overlapped. - Anchor a count to `label.right`, never to a measured constant. Lanes that - clear a fixed-size *icon* (the 30–32px ones) are fine. -- **Compact a row as a layout change, not a token change.** T3 first moved its - inbox to one line; GitHub later followed. GitHub's Inbox is deliberately only - a coloured status glyph and meaningful title; workflow rows prefer GitHub's - run display title over generic workflow names such as `CI`. Repositories and - commits keep their context in bounded lanes beside the title. Simply - shortening the old two-line card would draw its detail through the next - section header. - `github-inbox-structure.test.cjs` requires all three lists to use the shared - flat row and pins the Inbox's quieter status treatment separately. - -`SettingsHelpers.semanticPalette` also gained a real step at every level. It -built the ladder with `ensureContrast`, which only ever *raises* a colour, so a -Material palette whose `onSurfaceVariant` already cleared 7:1 returned the same -tone for all five steps — in wallpaper mode every label, value and piece of -metadata rendered identically. `paletteTone` folds the tone back toward the -background when it over-clears, so each step lands on its own floor. - -## The edge drawer and the Day sheet (2026-09 redesign) - -The 2026-09-03 redesign ("Quickshell Menubar", Claude Design project -`8cf85161`, direction 2) introduced the attached surface family: - -- **The Control Dashboard** (`Popovers/Drawer/`) is one edge-drawer surface - with six tabs — - Overview · Sound · Network · Bluetooth · Power · Notifications. Every - established popout name (`control`, `audio`, `wifi`, `bluetooth`, - `tailscale`, `battery`, `notifications`) still works from IPC and - the bar; each one presents its tab of - `DrawerPopover.qml`. The tab is derived from `Popouts.currentName` at - creation, and the drawer's own tab strip navigates by reopening the - canonical name for the wanted tab (`PanelRegistryData.nameForTab`), so the - bar's held states, hover-crossing and the module-ownership sweep all keep - working unchanged. -- **Updates is a dedicated edge drawer** (`Popovers/UpdatesPopover.qml`). The - widget and `updates` IPC name open its full pending, running, completed, and - failed views rather than deep-linking to Overview. It still carries the new - drawer template's width, attached geometry, Hug corners, palette, and type. - Every state uses one layout. A one-line header states what is happening. - Fixed System, Apps, Firmware and CybexOS rows move from a count to progress - to a result. There is one primary action (Update, Restart now or Try again). - A collapsed **Details** section holds package names, the live transaction, - dnf's own error and the recovery point. Keep backend vocabulary (dnf, - Flatpak, poll cadence, log paths) and negative results ("no restart - needed") out of the rows and header. -- **Firmware installs inside the update run, never in a terminal.** When - firmware is pending, `Updates.run()` passes `--firmware`. The durable worker - then runs `cybexos-firmware-update` (libfwupd over D-Bus, as root) after - the packages and streams JSON events to `firmware-events.log`, which the - panel reads like the dnf and Flatpak logs. fwupd's device requests (such as - replugging a dock) appear as a card under the Firmware row. Capsules staged - for the next boot turn the run's restart recommendation on. On battery, a - device that requires AC power is left out of the run, and the row asks for - power instead of letting fwupd fail the flash. A firmware failure never - fails the package update; the row explains it. -- **T3 Code is a separate attached panel**, not a seventh status tab. Its - existing `t3code` panel name, bar ownership, source, and IPC route are - unchanged; unlike the status drawer, it is not pinned to a screen edge, so - it follows the T3 widget when that widget is reordered or moved between bar - sections. It uses the wider `Theme.t3MaxWidth` measure and hugs the active - page's content until it reaches the host's usable-height cap, at which point - only the page's content viewport scrolls. - Inbox search and connection chrome stay fixed around a scrolling grouped - list; thread headers and response/composer controls stay fixed around the - transcript; New Thread scrolls its form below a fixed header. Below 360px of - effective content width, inbox rows become two-line tiles and composer - reasoning moves into Run settings. Picker geometry is clamped to the drawer - body and New Thread reserves popup room in its own scroller rather than - extending a transparent surface tail. -- **The Day sheet** (`Popovers/DaySheetPopover.qml`) hangs under the clock - (and the weather pill): big time, today's sky, a Monday-first week strip - with per-day forecast and calendar event dots, and the next three events. -- Clock and Weather options share `Settings/WeatherLocationPicker.qml`. - City search uses Open-Meteo geocoding (GeoNames), debounces typing, and - distinguishes matches by region, country, and coordinates. Typing previews; - Enter applies a sole match, while ambiguous results require selection. - A selection saves the name and both coordinates in one settings write. - Clearing or dismissing a search preserves the saved location; timeouts and - stale replies cannot overwrite it. Manual coordinates remain available. - This sets the forecast location, not the system clock's time zone. -- The registry gained two flags: `attached` (flush under the bar, squared - bar-side corners, Hug-corner bridges drawn by `Bar/PopoutHost.qml`) and - `edge: "right"` (pinned to the screen edge instead of centred on the - trigger). Both are read by the popout host; detached panels are untouched. -- The visual system moved with it: warm charcoal surfaces (`#1a1917` bar and - panel, chartreuse `#d3d283` accent), Figtree as the default UI face, and - `Theme.fontNumeric` (Geist Mono) for every instrument reading — the clock, - percentages, meters, resets. Clock+weather group into one filled `time` - pill; notifications joins the filled vol/wifi/bt/batt `status` pill by - default and can be separated under Widgets → Notifications. Grouping still - follows adjacency (`SettingsHelpers.MODULE_GROUPS` / `FILLED_GROUP_KINDS`); - the usage chips - carry a 2px remaining meter; the bell wears an unread dot instead of a - count. -- The pre-drawer popovers (`AudioPopover`, `WifiPopover`, - `ControlCenterPopover`, …) are no longer reachable from the registry but - remain in the tree with their tests until a deliberate removal pass. - -## Layered Hug, glass, and the wallpaper palette - -The 2026-08-15 redesign ("QuickShell Menubar", Claude Design project -`facd7f56`) replaced an opaque bar and its bar-fused popouts with translucent -glass and detached panels. What that added, and what it needs: - -- **Pinned shell fonts.** The UI typeface remains configurable. Generic - interface icons use bundled Tabler 3.48.0, loaded once by - `Common/TablerIcons.qml`; no icon font installation or font-cache refresh is - needed. Product marks still use `BrandIcon`, and application icons use the - desktop theme. -- **Icons use an explicit registry.** `Sym { name: "wifi" }` resolves through - `Common/TablerGlyphs.js`. Existing semantic names remain as compatibility - aliases for built-ins and plugins; canonical bundled Tabler names also work. - Unknown names show help-circle and empty names draw nothing. `Sym` reserves - a square slot and always draws the outline variant, including active states - and playback controls. Selection uses colour, backgrounds, borders, labels - and checkmarks; pinned thread actions use accent ink and favourite stars - use amber. Icon colour fades, press feedback and spinners remain. Legacy - `fill`/`animateFill`/`glyphFill`/`symbolFill` inputs are accepted but inert, - as are `symWeight`/`grade`. The filled font is no longer shipped. - `tests/quickshell/tabler-icons.test.cjs` checks names and actual codepoint - coverage in the bundled font, without system dependencies. Brand/application - artwork and functional shapes such as switch tracks and progress meters - retain their own presentation. - Update assets with `scripts/update-tabler-icons`; see `assets/tabler/README.md`. -- **Blur is the compositor's.** `roles/desktop/templates/looknfeel.lua.j2` - defaults to one blur pass to reduce desktop GPU work while keeping - animations and display refresh rates unchanged. Both Ansible deployment - and the ISO desktop package render this template. - It exports the named `quickshell_blur_rule` matching the `qs-*` namespaces. - The Appearance switch calls that handle through `hyprctl eval`; its initial - `enabled` value is read from the persisted JSON so compositor reloads retain - the choice. - Layer namespaces stay fixed because changing one after a Wayland surface is - connected does not update the compositor rule safely. -- **Nothing that floats over the desktop may draw a drop shadow.** Blur is - applied per pixel of the *surface*, and every one of these layers is larger - than the shape it draws — the menubar's runs past the slab to leave room for - tooltips, a panel's runs past the card. Anything painted into that margin is - blurred with the shape, at the full size of the layer, so a shadow does not - read as a shadow: it reads as a haze band the height of the whole surface. - Both the design's `0 20px 50px` shadows shipped that way and both were - reported. Raising `ignore_alpha` only trims the falloff — the shadow is at - full strength directly under the shape, which is exactly the band you can - see. Glass over a real blur already reads as floating; the hairline border - and the rim highlight do the rest. Glows *inside* a surface (the focused - workspace pip, the T3 running dot) are fine — they composite over the glass, - not into the margin. -- **Render semantic surfaces, never raw variants.** `Theme.barSurface`, - `surfaceStrong`, and `surfaceMenu` select translucent glass or their opaque - references from `Settings.glassEnabled`. Directly painting `Theme.glass*`, - `popBg`, or `barBg` bypasses that switch. Modal scrims are deliberately - separate: they remain translucent safety layers when glass is off. -- **Wallpaper mode is one validated Material palette.** `Common/Palette.qml` - runs Matugen's tonal-spot scheme for the selected wallpaper, whitelists the - semantic roles in `PaletteHelpers.js`, and atomically caches both light and - dark variants at `~/.local/state/cybexos/shell/wallpaper-palette.json`. Theme - changes select the cached variant. The menubar background remains the user's - independent bar-color choice while its accents follow this palette. Missing - or malformed Matugen output leaves the user's mode unchanged and renders the - stored fixed colors as fallback. Copy-bearing tones are still forced to a - 4.5:1 floor against their opaque reference surface. -- **Bar style is explicit.** `hug` is the default edge-attached slab with local - `QtQuick.Shapes` concave corners; `floating` alone uses the stored gap and - radius; `attached` is full-width and square. Hug/attached reserve exactly the - bar height, and the decorators travel with auto-hide without joining its - input mask. -- **State layers are shared.** `Common/StateLayer.qml` supplies the 8% hover - and 12% pressed/focused overlay used by bar primitives, workspace targets, - shared actions, toggles, and settings controls. Controls retain their press - scale and accessibility behavior. -- **One spring for continuous motion.** `Theme.springCurve` drives controls and - in-place movement. Bar popouts use a faster directional enter/exit and a - lower-overshoot morph between triggers. Colour and opacity never spring — an - overshooting fade reads as a flicker — so they use the ease curves. -- **The launcher is always keyboard-ready.** Its view and first eight - alphabetically sorted apps are constructed at shell startup, while - `Super+Space` reaches it through - Hyprland's global-shortcut protocol instead of spawning an IPC client. It - takes exclusive keyboard focus while mapped, forwards an early character or - Enter across the mapping frame, and never stages result rows behind an - animation. Launcher-only motion is brief and purely visual. -- **Schema 7 adopts the softer type and density pass.** A stored `Urbanist` - value from an older schema follows the new `Google Sans Flex` default; - OPPO Sans, IBM Plex Sans, and JetBrains Mono remain explicit choices. Shared - popovers gain modest width and padding, metadata floors at 11px, and soft - inner hairlines recede while outer surface boundaries remain intact. -- **Schema 6 adds bar style and palette mode.** A v5 attached bar remains - attached. A v5 floating bar adopts Hug only when height, radius, and gap are - pristine; custom geometry remains floating. Old wallpaper-accent users and - untouched colors adopt wallpaper mode, while active custom colors select - fixed mode without discarding either stored choice. Module order is never - part of this migration. `SettingsHelpers.adoptRedesign` still gives a v3 - file the schema-4 geometry only where the user never moved it. - -## Layout and foreground roles - -- Settings use `settingsRowSpacing` for ordinary rows, `settingsContentSpacing` - for related rich content, `settingsSubsectionSpacing` before a subsection, - and `settingsGroupSpacing` between groups. Do not use a page's group gap - inside a compact run of controls. -- `SettingsSubsection` owns its heading and leading separation. Put it **inside** - a `Revealer` so the separation collapses with the subsection. Keep full-width - content for `SettingsRow` children; set `insetContent` only for rich content - that does not already reserve `settingsMarkInset`. -- Standard rows keep the modified-state gutter in wide and stacked layouts. - Picker height follows its wrapped pills at either width; narrow captions - sit below the control. Reset lanes remain reserved. Section headings reserve - reset geometry even when clean; non-resettable subsections opt out of the - trailing lane. -- Use `SettingsField` for native settings text inputs, including standalone - forms. `SettingsTextRow` retains ownership of commit/reset/persistence wiring. - Plugin-kit fields continue using their configurable `Ui`/`Commons` styling. -- `SectionLabel` owns the bounded label/count/rule layout used by ordinary - popovers and the T3/GitHub group headers. Product-specific colors remain - overrides; their list row implementations remain separate. -- `Theme.accent` is the chosen fill/swatch color. `Theme.accentText` is its - contrast-adjusted foreground counterpart for native copy, icons and focus - outlines. Do not darken the stored accent to make a light-mode label legible. - T3 and Hermes retain their existing independently adjusted accent roles. - -## Already decided against — do not pick these up - -- **qmlformat one-shot reformat**: most files would churn and the tool fights - the deliberate hand-wrapped style. Revisit only as a dedicated commit with a - tuned `.qmlformat.ini`. -- **Restarting the shell on every converge**: Quickshell hot-reloads, and a - restart is more disruptive than the problem it solves. A converge that - changes the deployed tree or unit restarts once from the complete tree, - because sequential copies can trip rejected intermediate hot reloads, and - verifies it; an unchanged converge leaves the shell alone. Installing a new - shell font is the other exception: Qt does not add a newly cached face to an - already-running process, so the apps role `try-restart`s Quickshell after a - font install and leaves an inactive service alone. -- **The remaining perf items** (toast countdown timer, memoising - `Notifs.iconSource`, a `Clock` singleton, a launcher token index): measured - 2026-08-08 and declined. Two premises were already false in the code, and the - third does not reproduce — with eight notifications the icon lookups go - 8 → 24 → 96 and then flat, identically with and without a memo. Do not reopen - without new numbers. -- **Merging the remaining list rows into one `ListRow`**: they differ more than - the shared action buttons did. Reopen only if a fifth consumer appears. -- **`Theme.fontSans` → `Theme.fontMenu` in the launcher and toasts**: those are - overlay surfaces, not menubar chrome, so they follow the general UI face and - do not track the menu font setting. `typography.test.cjs` enforces the split. - -Still open: **deeper QML state-machine coverage**. The mandatory runtime stage -now exercises helpers under `qmltestrunner-qt6` and constructs, mutates, -signals, and destroys production controls under the real Quickshell engine in -CI. The next targets are the Settings load/merge/save cycle and the -`T3Connection` process/socket lifecycle against controlled test doubles. - -## Bluetooth drawer - -Bluetooth has its own tab immediately after Network by default. Existing drawer -settings gain the new tab beside Network while retaining their saved order and -visibility; it can then be hidden or reordered in Drawer settings. The Bluetooth -bar button and `popouts open bluetooth` open this same tab. - -The tab lists connected and paired devices for the default adapter, with battery -levels when available. Opening the tab while Bluetooth is on automatically starts -a 60-second discovery session and shows named nearby devices. Unpaired discoveries -with empty, address-only, UUID-like or hexadecimal identifier names are hidden; -paired and connected devices remain visible even without a readable name. -Turning Bluetooth on -while the tab is open also starts discovery. **Stop scan** ends it early, and -**Scan again** restarts it after it stops; revisiting the tab starts a fresh scan. -Pairing supports PIN entry, passkey entry/display and code -confirmation inline; successful pairing trusts and connects the selected device. -Errors remain visible for retry. Closing the drawer, switching tabs or powering -off Bluetooth terminates the tab's helper, rejects pending prompts, cancels its -pairing attempt and releases its discovery session. Other applications' scan -sessions and default pairing agent are left alone. - -`bluetooth-tool.py` uses the existing `python3-gobject` dependency and BlueZ's -application-scoped agent API. `tests/bluetooth-tool.py` checks input validation, -authorization, cancellation, action failures and discovery lifetime without -operating the host radio. - -## Widget editor - -Settings → Bar opens with a live preview of the bar pinned above the page: -the wallpaper, the bar at its position, style, height, gap, radius and -background, and each section's enabled widgets as their icons. Clicking a -widget there opens its options. Below it, the **Widgets** group lists Left, -Center, and Right as rows of compact pills with leading icons (no cards). -This includes widgets whose runtime conditions currently hide them from the -bar. Plugins without a declared catalog icon use the extension symbol. -Clicking a pill opens built-in or plugin settings in an embedded, scrollable -dialog; closing it returns focus to the pill. Each pill's ⋯ menu offers -Widget settings…, Move earlier/later, Move to Left/Center/Right, and Remove -from bar. The bar's Layout, Background, and Behavior rows follow on the same -page. This interaction design follows Noctalia's legacy QML bar editor; our -implementation uses Cybex components and storage. - -Disabled widgets sit in the **Add widgets** tray below the sections. A click -adds one to its own section; its ⋯ menu picks another. Control Center is a -normal widget: the Fedora button can move, be removed, and be restored. Its -options contain the tab, overview, and behavior controls. Settings search -opens this widget dialog too; there is no separate Control Center sidebar -entry. - -Schema 24 adds the Fedora widget at the right edge of older layouts and keeps -all other widget placements. Schema 25 retires the first built-in Model usage -widget (`usage`), its drawer tab and its `modOpts.usage`/`pollMax` settings. -Schema 26 adds `modelusage`, the Model Usage widget vendored from the -`digitalpals.model-usage` Omarchy plugin (see `ModelUsage/README.md`), at the -start of the right section of older layouts. It starts on when the install -has connected widgets. Its settings are `modOpts.modelusage`; sources, -credentials and cost servers stay in the panel's own forms, which Settings -opens. - -Drag a pill to reorder or move it between sections. The drag ghost and insertion -marker follow wrapped grid positions and the arrangement scrolls near its edges. -Alt+arrow keys reorder; right-click, Menu, or Shift+F10 exposes placement, -earlier/later, settings, and removal actions. Plugin blocks match the native bar: -before built-ins on the left and right, after them in the center. Drop indicators -snap to those supported boundaries. `Common/WidgetEditor.js` translates visible -gaps to stored indices without changing disabled entries or compaction preferences. - -Removal retains placement and settings and offers an eight-second Undo that -restores only that widget. Plugin success is confirmed by both the write result -and the refreshed membership/section before showing success. Section-specific -plugin adds enable and place the instance in one atomic registry write. The settings -writer skips already-persisted bytes before taking its in-flight guard: FileView -does not emit `saved` for an identical `setText`, which would otherwise block -subsequent edits after opening a form that re-applies an unchanged value. - -The layout actions menu contains presets, plugin management, built-in layout -reset, and layout Undo. Presets preview their enabled built-ins before applying, -preserve placement and plugin preferences, and use the existing eight-second undo. - -Plugin widget details expose width, saved/default setting values, and an -advanced key/JSON-value field. `configure-widget` changes widget enablement, -width, or the destination of a newly added widget atomically. Removing a widget -does not disable its package services or sibling instances. Settings are merged -through the plugin registry, never written into `shell.json`. The Omarchy plugins -page retains package installation, updates, cloning and removal; shared plugin -appearance controls live under Appearance. diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 00000000..cee4e819 --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,119 @@ +# Recovery, persistence and garbage collection + +The updater serializes operations with a machine lock and publishes status and +pending records with fsync and atomic rename. Downloads use atomic cached files. +Evaluation/build happens in a private staged configuration; neither changes the +working system. Each transaction retains its full configuration, lock file, +input archive identities, candidate and previous system, and actual diff. +Indirect Nix GC roots protect these inputs and known-good systems. + +Activation tests the candidate before selecting its boot configuration. It saves +a durable pending record first. Failure restores the previous system and its +configuration. Before rollback, working personal edits are copied into the +transaction's `personal-before-rollback` directory; they are not discarded. +NixOS service-warning exit 4 is checked through the declared health services; +unrelated failed units do not prevent restoration. Genuine activation errors +still fail closed. A busy recovery unit defers to the supervised operation that +holds the lock, and rollback disarms a rejected trial before updating boot files. +An interruption at application boundaries is recovered by `nerdos-recover`, +which stays completed for the rest of that boot so activation cannot start a +second recovery worker. Interrupted staging is reported and can be staged again. + +`nerd update status` reports `recovery-failed` if restoration itself fails. +The durable pending record remains, and further previews are blocked. Inspect +`nerd update log ID` for private build, activation and restoration output; +correct the reported cause, then run `nerd update rollback ID` to retry the +retained restoration. The desktop keeps its Rollback and private-log controls +available. Rebooting into the retained known-good boot entry also resumes +recovery. A failed restoration never reports the previous generation as +restored, and its transaction retains both the original activation error and +the restoration error. These steps preserve personal configuration and state; +they do not promise application-data rollback. + +The previous generation stays the persistent systemd-boot default. The candidate +is a one-shot trial, selected once. A failed boot requires a user-selected reset +or reboot; the next boot returns to the previous default. There is no unattended +reboot or hardware watchdog reset. A successful fresh boot and authenticated +Hyprland/Quickshell login must pass `nerd update confirm` before the candidate +becomes the persistent default. Failed systems are quarantined against normal +reapplication. `nerd update history` and `nerd update rollback [ID]` expose the +retained identities and restore coherent system/updater configuration. + +System health requires the boot target, NetworkManager and login manager to be +active. When Libreswan is enabled, its managed `ipsec.service` must also be +active so Cisco IPsec connections have their required daemon. Add critical +personal services in `/etc/nixos/local/*.nix`, for example +`nerdos.updates.additionalHealthServices = [ "personal.service" ];`. +The shared modules generate `/etc/nerdos/health-services.json` for the updater; +disabled optional services are not required. Failed checks identify the unit +and retain the pending recovery record instead of accepting the deployment. +Selected XPS hardware also requires its haptic daemon and, in an authenticated +desktop, speaker tuning. The selected processed camera requires a separate +booted-kernel ABI check and the relay; an ABI failure blocks relay startup and +trial acceptance. These service checks do not prove processed frames, privacy, +image quality or suspend/resume; those still require physical qualification. + +From live media, unlock/mount the target filesystem and ESP, inspect +`/var/lib/nerdos/transactions` and its deployment records, and select the +retained previous system using its `switch-to-configuration boot` inside +`nixos-enter`. Restore `/etc/nixos` to that record's retained configuration as +well. This is an offline repair requiring explicit administrator intent, not a +routine update shortcut. Keep backups of ESP contents and enrolled Secure Boot +public certificates. + +Regular configuration Git metadata remains persistent outside source snapshots; +selected writable trees reference the retained metadata directory. Follow the +`.git` target before deleting any old working directory. Repeated rollback makes +a fresh working selection and preserves edits in earlier selections instead of +reusing them as the retained deployment configuration. + +System generations are not filesystem snapshots. Home files, credentials, +NetworkManager profiles, VPN keys, plugin data and service databases remain +persistent through activation and rollback. Personal backups remain separate. +Future data/schema migrations require an application-aware backup, explicit +migration metadata, tested rollback policy and compatible old readers. Never +silently downgrade a newer database or unknown preference schema. + +Open **Settings → Storage & cleanup** to inspect store filesystem capacity, +review cleanup, set retention, and pin confirmed recovery points. Manual cleanup +requires authentication and a current review token. Pin/configuration/history +changes invalidate an earlier review. Estimates count uncompressed NAR bytes; +shared paths and filesystem compression can make reclaimed disk space differ. + +Retention keeps at least five confirmed deployments and 30 days of history, +plus pins, the running system and its fallback, selected personal configurations, +and unresolved or failed transactions. A failed build without pending activation +can still be cleaned up, while its input roots and diagnostics are retained. +Before the first confirmed deployment, +manual cleanup collects garbage while retaining every recovery reference; +automatic cleanup skips that boot. Another kept transaction's input and +fallback roots remain protected. Cleanup removes only managed GC references +and matching obsolete system-profile generations. Other Nix profiles and roots +remain authoritative. Documents, downloads, application data, logs, transaction +records, lock files and saved personal edits are not deleted. Retired records +remain visible in history and cannot be rolled back after their store roots +have been released. + +A durable cleanup journal and the updater's operation lock serialize profile +pruning, boot-entry regeneration, root release and Nix garbage collection. +Boot entries are regenerated from the confirmed running system before old +kernels can be collected; its confirmed boot selection is restored. Secure Boot +configuration is unchanged. An interruption keeps the journal and blocks new +deployments. **Resume cleanup**, `nerd maintenance resume`, or boot recovery +continues idempotently from the initiating confirmed system. Inspect the private +`/var/lib/nerdos/maintenance.log` and the supervised system service on failure. +Settings detects a stopped worker and offers recovery from the durable journal, +including after reopening the desktop. + +Weekly cleanup is enabled by default on fresh installations. Disable it or +change retention in **Settings → Storage & cleanup**; preferences change the +machine-owned `local/maintenance.json` through a staged preview and apply. An +explicit personal opt-out remains respected. The persistent weekly timer runs +with idle I/O priority and randomized delay. It +obeys retention/pins and skips active updates, an unconfirmed boot or unresolved +recovery. Generic Nix automatic GC remains disabled by default so the NerdOS +retention policy owns automatic cleanup. Physical boot/recovery qualification +is a separate launch requirement. + +For scripting, use `nerd maintenance status`, `preview`, `clean REVIEW TOKEN`, +`pin DEPLOYMENT true|false`, `policy JSON`, `request ID`, and `resume`. diff --git a/docs/releasing.md b/docs/releasing.md index 5bdae14f..ce68c2b7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,207 +1,147 @@ -# Publishing CybexOS releases - -CybexOS has two release surfaces. Source-checkout releases publish a -versioned source archive; ISO releases publish signed desktop and offline-apps RPMs, a bootable -ISO, and RPM update metadata. Both use a semantic-version Git tag and share -the repository source contract and generic Fedora VM gate. The release also -runs the image-source contract, then requires trusted PXE-host qualification -before publication. See [ISO and desktop RPM releases](iso-releases.md) for -runner setup, signing, the installer matrix, reconstruction, and channel -enrollment. - -The large offline application seed has its own version in -`image/offline-payload.json`. Run `python3 image/offline_payload.py --version X.Y.Z` -with a newer version after changing its reviewed application inputs. Packaging -refuses a stale input digest. Shell-only changes keep that version and RPM -release unchanged; desktop RPMs retain their source/build identity. The desktop -also requires the exact application input digest, preventing an unrelated -newer offline package from satisfying its installation contract. - -Treat a published offline NEVRA as immutable. Reuse its artifact for unchanged -inputs; if a moving upstream tool resolves differently, review it and advance -the offline version before publishing different payload bytes. Sign and ship -both RPMs together. The upgrade qualification installs both in one offline DNF -transaction and binds their checksums in the release report and SPDX SBOM. -The signing workflow authenticates the previous channel manifest and rejects -changed file contents or ownership under an existing offline NEVRA. Manual -repository signing must likewise pass `--previous-repository` with the previous -signed directory or its HTTPS URL. A missing public manifest (HTTP 404) allows -the first release; invalid signatures and other download failures stop signing. - -An older installed baseline can need additional Fedora RPMs for a candidate's -SDK dependencies. Supply those only as an optional qualification fixture with -`image/qualify ... --candidate-dependencies DIR`. Do not publish them as -first-party release artifacts. -Keep a flat directory of signed, verified candidate-build downloads and a -separate provenance JSON mapping each RPM filename to -`{"id": "fedora", "url": "https://.../original-repository/"}`. On a host with -`rpm`, create and verify its manifest with: - -```bash -python3 image/offline_upgrade_inputs.py create DIR --provenance provenance.json \ - --baseline-iso BASELINE.iso --candidate-rpm DESKTOP.rpm --candidate-offline-rpm OFFLINE.rpm -python3 image/offline_upgrade_inputs.py verify DIR \ - --baseline-iso BASELINE.iso --candidate-rpm DESKTOP.rpm --candidate-offline-rpm OFFLINE.rpm -``` - -Manual generators may write `DIR/manifest.json`: the exact top-level keys are -`schema` (integer `1`), `baseline_iso_sha256`, `candidate_rpm_sha256`, -`candidate_offline_rpm_sha256`, and `rpms`. Each RPM record has exactly -`filename`, `name`, `nevra`, `architecture`, `sha256`, `size` (bytes), -`signature` (`{"tag": "RSAHEADER", "value": ""}`), and -`repository` (`id` and HTTPS `url`). NEVRA includes the numeric epoch, including -zero: `name-0:version-release.x86_64`. Signature values come from -`LC_ALL=C TZ=UTC rpm -qp --qf '%{RSAHEADER:pgpsig}'`; `DSAHEADER`, `SIGPGP`, and `SIGGPG` are also -supported. The JSON file's SHA-256 is the bundle identity, binding every RPM's -identity, bytes and provenance to all three input artifacts. - -Admission rejects symlinks, traversal, duplicate identities, unknown fields, -extra/missing files, non-x86_64/noarch RPMs and mismatches. Limits are 256 RPMs, -512 MiB per RPM, 2 GiB total and a 1 MiB manifest. Root copies both candidates -and all dependencies into fresh private staging files using bounded, -no-symlink reads, then verifies the copied hashes, NEVRAs and signature headers. -Only those root-owned copies are checked and installed; changing the original -account-owned transfers cannot change the transaction. Dependencies pass -`rpm --checksig --verbose` using installed trust keys; no keys are imported. -Root staging is removed on success or failure. Qualification creates a bootable recovery -point before installing both candidates and all dependencies in **one** DNF -transaction with every repository disabled. `--recovery-check` additionally -boots and restores that point. `qualification.json` records the bundle digest, -identities and verified signatures. This input targets exact prebuilt upgrade -artifacts. Omitting the option retains the existing upgrade path. - -For `image/release-gate`, which always builds new candidates, supply the reviewed -raw RPMs and the same separate filename-to-`{id, url}` provenance JSON as a pair: - -```bash -image/release-gate --output NEW_OUTPUT --baseline-iso BASELINE.iso --tag vX.Y.Z --execute \ - --upgrade-dependency-rpms RAW_RPM_DIR --upgrade-dependency-provenance provenance.json -``` - -This optional path requires host `rpm`. The raw directory contains only the -reviewed, signed third-party RPMs, without a prebound manifest. An incomplete flag -pair, malformed inputs or RPM metadata stop the gate before building or starting VMs. -The gate copies bounded inputs into private staging before the build, records -their hashes, identities and provenance, then binds those copies to the actual -baseline ISO and both freshly built RPM hashes. Caller inputs are never changed; -later caller changes cannot affect the staged fixture. The gate forwards the -bound bundle only to the original upgrade qualifier, retaining recovery and all -signature, checksum and single-transaction checks. It also checks that the -returned qualification evidence identifies that exact bundle and artifacts. -`release-gate.json` records the raw fixture content digest, provenance digest, -bound manifest digest and upgrade report digest; `upgrade-dependency-manifest.json` -retains the exact bound manifest. Private dependency RPM copies are removed on -success or failure and are never included in release artifacts. Omitting both -flags preserves the existing release-gate behavior. - -The source archive is published by `.github/workflows/release.yml` after all -required jobs pass. Its prerequisite job checks the tag against `VERSION`, -requires a non-empty `CYBEXOS_BASELINE_ISO` repository variable and a root -license file, and validates semantic versioning. The ISO job additionally -validates that the baseline file and checksum sidecar exist on the PXE host. - -## Source archive setup - -1. Immutable GitHub Releases are enabled for the repository. Keep Actions - permitted to create attestations and write release contents; the workflow - grants only those job-level permissions. -2. The `desktop-release` environment and its `CYBEXOS_RPM_SIGNING_KEY` secret - provide signing access only to the GitHub-hosted signing job. The PXE - qualification runner never receives that secret. The `github-pages` - environment deploys stable repository metadata after release publication. -3. Keep `CYBEXOS_BASELINE_ISO` set to the verified older ISO and configure the - on-demand trusted self-hosted PXE runner as described in - [the ISO release guide](iso-releases.md). -4. Branch protection requires pull requests, up-to-date branches, and the - GitHub Actions checks `Fedora 44 source contract` and `Image tooling, - installer fixtures and account isolation`. Admins are subject to the rule; - force pushes and branch deletion are disabled. The generic Fedora VM is a - release-tag gate, not a required branch check. - -The updater refuses a release when GitHub reports `immutable: false`, even if -the archive checksum is otherwise correct. - -GitHub Pages is configured at the desktop update URL but is currently empty; -the first stable ISO release workflow will deploy signed metadata there. -Prerelease tags publish downloadable assets but do not update the stable Pages -repository. - -## Source archive release checklist - -1. Review `release-manifest.json`, `VERSION`, the Fedora release, - configuration schema, minimum updater version, and all dependency pins. -2. Run `./tests/run` and `./tests/fedora-vm-convergence` locally when practical. - Run `python3 -B image/check-source` as well. Both source suites enforce the - [ISO-leading installation contract](installation-parity.md); changes to - applications, defaults or policy must cover both installation paths. Build - and qualify ISO/RPM artifacts from the same release revision rather than - reusing an older image as parity evidence. - The source gate includes an N to N+1 ownership test that advances vendor - runtime while requiring every user customization sentinel to remain - byte-identical. - The user-widget fixture also loads a fixed API 1 package outside the - runtime through simulated replacement and rollback. Retain supported API - adapters when changing the shell; see [the widget contract](architecture/user-widgets.md). -3. Commit the intended source and create a signed semantic-version tag, such - as `git tag -s v1.0.0 -m 'CybexOS 1.0.0'`. -4. Push the commit and tag. A version containing a hyphen, such as - `v1.1.0-beta.1`, is published as a prerelease for the beta channel. -5. Wait for the workflow to build, attest, verify the provenance bundle - offline, upload, publish, and confirm the immutable release before - announcing it. -6. On a clean supported machine, run `cybex update --check --json`, - apply the release, and run `cybex verify --system`. - -Do not edit an existing release. Immutability makes correction explicit: fix -forward, increment the version, and publish a new tag. If rollout must stop, -remove the bad release from channel discovery and publish a corrected release. -On the managed Btrfs layout, failed application or health validation selects -the previous root and restores the vendor desktop checkpoint while preserving -personal settings; users must restart to enter the restored root. Interrupted -application is recovered before login. On other filesystems the updater reports -`transactionProtection: unavailable`: failed activation restores the previous -`current` and saved configuration, but partially deployed files can remain -(`mixedState: true`). Converge the active release with -`~/.local/share/cybexos/current/install` or apply a corrected release. -See [update recovery and its limits](operations.md#update-recovery-points). - -## What the source workflow publishes - -The release contains a versioned source archive, its provenance bundle -`cybexos-VERSION.tar.zst.sigstore.jsonl`, and `SHA256SUMS`. The checksum -entries use the asset basenames, so they can be verified directly after the -files are downloaded into one directory. The archive -is reconstructed from the tagged Git tree, its `VERSION` and manifest version -are set to the tag, its installer is checked, and Ansible syntax is validated. - -The bundle is the Sigstore bundle of the `actions/attest` provenance -attestation. Before publishing, the workflow verifies it offline without a -token, exactly as installed systems do: - -```bash -gh attestation verify cybexos-VERSION.tar.zst \ - --bundle cybexos-VERSION.tar.zst.sigstore.jsonl \ - --repo DigitalPals/CybexOS \ - --signer-workflow DigitalPals/CybexOS/.github/workflows/release.yml \ - --source-ref refs/tags/vVERSION --deny-self-hosted-runners -``` - -The same attestation has a second subject: the ISO's SPDX SBOM, -`CybexOS-Live-44-BUILD-ID.spdx.json`, which the workflow also verifies offline -with this bundle. A separate SPDX attestation binds the SBOM to the ISO digest -and is published as `CybexOS-Live-44-BUILD-ID.iso.sbom.sigstore.jsonl`; -`SHA256SUMS` lists that bundle. See [the SBOM section of the ISO release -guide](iso-releases.md#software-bill-of-materials) for its contents and -verification. - -That offline attestation check, the immutable-release flag, and the API -SHA-256 digest form the updater trust boundary. Updating needs no GitHub -login, and a release without its bundle is refused. Files copied from an -arbitrary branch or mutable URL are not accepted as updates. - -Until a source archive release is published, source-checkout -`cybex update --check` reports that no release exists on its channel. On ISO -installations, use `cybex update-channel status --json`; that command's channel -status is separate from source archive discovery. See [ISO and desktop RPM -releases](iso-releases.md) for channel enrollment after the first stable -desktop release has been published. +# Release delivery and publisher trust + +NerdOS uses semantic tags (`v1.2.3`, `v1.2.3-beta.1`), immutable GitHub +Releases, and small signed GitHub Pages documents under `channels/stable.json` +and `channels/beta.json`. Normal clients use public HTTPS without GitHub +credentials. A release is always selected by exact commit and NAR content +identity, never by checking out a moving branch after discovery. + +The installed `release/keys/trust.json` is deliberately empty. An owner must +supply real Ed25519 publisher public keys before production installation or +NerdOS channel updates can work. Upstream-only updates do not use this +channel. Do not ship a generated development key as publisher trust. + +Keep private signing keys outside the repository and Nix store. The protected +`nerdos-release` and `nerdos-channel` GitHub environments hold separate +`RELEASE_ED25519_PRIVATE_KEY` and `CHANNEL_ED25519_PRIVATE_KEY` secrets, with +`RELEASE_KEY_ID` and `CHANNEL_KEY_ID` environment variables. Require owner +review, protected tags, and restricted workflow changes. Enable immutable +releases in GitHub settings before publication. Enable GitHub Pages with +Actions delivery and prepare its `gh-pages` branch; promotion preserves the +existing site. These settings and trust provisioning are release prerequisites. + +The release environment also needs `IMMUTABILITY_CHECK_TOKEN`: a repository- +scoped GitHub App or fine-grained credential with **Administration: read**. +The ordinary Actions publishing token cannot read this policy. The workflow +checks GitHub's immutable-release setting before draft creation and again before +publication, and fails closed on a disabled setting or an unavailable API. +This separate credential cannot change the setting; publication still uses the +Actions contents token. Keep repository policy changes protected during release +publication. Installed users need neither credential nor a GitHub account. + +Full Nix/product/image jobs require a `nerdos-isolated` self-hosted runner; +standard hosted runners do not provide enough space for the complete desktop, +build dependencies, ISO copies and installation VMs. Provision a fresh, +single-job disposable x86_64 Linux VM with at least 256 GiB of disk, 32 GiB +RAM, four CPU cores and accessible nested KVM. The preflight requires 160 GiB +free for the Nix store plus 32 GiB for work (192 GiB when they share a +filesystem), at least 24 GiB available memory, and KVM for VM/image jobs. +Build concurrency is bounded to two builds and four cores per build. +Discard the runner, workspace and attached disks after each job. It must have +no workstation data, persistent credentials, production network access or +production PXE mount. Untrusted pull-request checks receive no signing secrets; +only the protected publisher step receives its scoped signing secrets. +Runner provisioning and isolation are owner-managed release prerequisites, +not an instruction to enroll the running workstation as a runner. + +1. Commit the final source, set VERSION and release notes, and create a semantic + tag. Never reuse a tag. Record `nix flake metadata --json` revision/NAR. +2. Run source checks, real ISO installation and updater tests, plus representative + physical hardware/security qualification on that exact revision. A complete + report must use `installation-update-and-physical-qualification`, include + `revision`, `narHash`, and every check in `release/manage.py:REQUIRED`, with + passed results and reviewable evidence. A fixture report cannot pass the gate. +3. Run the protected release workflow with that tag and the reviewed report URL. + It repeats checks/builds, prepares the ISO, versioned source, notes, checksums, + qualification report and signed manifest, authenticates the local signature + and every asset, then uploads all assets to a draft. + Publication must produce an immutable release. The report URL is evidence + supplied for owner review; it is not an independent hardware attestation. +4. Verify public assets against the authenticated manifest. Only then run the + separate promotion workflow with a strictly increasing channel sequence. + Stable rejects prereleases. Promotion authenticates the previous signed + channel/release, rejects version downgrades and nonadvancing sequences, and + verifies the selected release and every public asset before changing Pages. + The signed version, exact commit and NAR must match the requested tag's + checked-out source; a valid manifest copied from another release is rejected + before its asset URLs are fetched. + Expired channel documents can be renewed for the same release. An expired + signing key requires the documented trust/recovery procedure. + +Ed25519 signatures authenticate the exact JSON bytes using OpenSSL; detached +signature envelopes identify a trusted key and its purpose. The signed manifest +contains version, exact source revision/NAR, supported architectures and NixOS +releases, configuration/plugin APIs, minimum updater API, declared vendor inputs, +notes, and each asset's name, URL, size and SHA-256. The channel authenticates +the manifest hash, expiry and sequence. Unknown/expired keys, malformed metadata, +unsupported APIs/platforms, mismatched identities, replays and normal downgrades +are rejected before selected code evaluation. The machine records successful +channel sequence high-water marks outside user caches. Explicit retained-generation +rollback is separate from channel selection. + +Clients use bounded downloads, atomic cached responses, ETags, and actionable +unavailable/rate-limit errors. An endpoint failure means unknown availability; +it never means current. Interrupted downloads do not change the working lock. +Expired channel documents must be re-signed and promoted with a greater sequence, +including when the release itself remains unchanged. + +For rotation, first publish a release signed by an existing trusted release key +that adds the new public keys, validity dates and purpose restrictions. Keep +old and new keys overlapping until supported machines have installed that trust +update. Promote using the old channel key during the overlap, then switch to +the new channel key. Do not expire the only key usable by supported updaters. +Revocation of a compromised sole key requires an independently authenticated +recovery/trust update; signatures by that compromised key cannot establish new +trust. Increasing updater/schema requirements requires an overlap release that +old updaters can authenticate and install. + +Nixpkgs supplies Quickshell, Qt, Hyprland, applications and fonts through the +machine's stable Nixpkgs input and upstream binary cache. NerdOS supplies the +desktop interface, assets, native notification-image module and extensions. +Narrow Intel camera/OMaWrite/font source pins and the temporary Hyprland/native +library compatibility overlay are source derivations, not another Nixpkgs +universe; requalify their ABI/license constraints when updating them. +T3 Code is an exact hashed upstream AppImage wrapped with Nixpkgs because the +selected stable package set does not provide the application. Replace that +source recipe with the maintained Nixpkgs package when available, after product +runtime qualification; its successful wrapper build is not desktop validation. +The selected stable Nixpkgs ChatGPT recipe is macOS-only. The Linux recipe pins +OpenAI's versioned Debian pool object and SHA-256, reviewed against the signed +OpenAI repository metadata; it executes no distribution maintainer scripts or +repository enrollment. Preserve the Chromium sandbox and exact-executable +AppArmor user-namespace allowance when updating it. Replace the recipe with a +maintained native Nixpkgs Linux package after runtime qualification. + +Brave Origin reuses the selected Nixpkgs Chromium wrapper/libraries and pins the +official Origin Debian asset because stable Nixpkgs does not yet select that +edition. Review both exact proprietary package pins on every vendor release; +replace the Origin override when a maintained equivalent enters Nixpkgs. +They remain part of the explicit proprietary-application choice and require +installed runtime, security and redistribution qualification. + +The maintained NetworkManager Libreswan plugin uses Nixpkgs sources and a +scoped helper-path correction: the selected recipe points to `libexec/`, while +Libreswan 5 installs its daemon/controller helpers in `libexec/ipsec/`. +Build-time assertions check each replacement target. NerdOS uses the native +NixOS `ipsec.service` to manage the backend; the plugin's legacy standalone +startup expects a removed `_stackmanager` helper. A working managed service is +therefore required and must pass installed health qualification. +Remove the override after the upstream recipe is +corrected and the installed VPN path qualifies; it does not suppress or patch +the archived vpnc plugin's vulnerabilities. +The reviewed desktop runtime series live in `desktop/runtime-compatibility.json`; +a candidate outside that matrix fails staging until qualified. + +No durable NerdOS binary cache is configured. Before advertising fast public +updates, provision a public Nix cache (for example an object-store-backed Nix +cache or Cachix), publish its independent Nix cache signing public key and +substituter URL, and prebuild each release/architecture. Add cache trust through +the shared module after reviewing that key. GitHub Actions artifacts and GitHub +Release download archives are not a public Nix binary cache. Source builds remain +possible; publisher metadata signatures and Nix cache signatures have different +purposes. Redistribution/license review remains required for the actual payload. + +Physical/security qualification must include `mandatory-access-control` and +`firewall` evidence. Record enforced profile/process coverage, negative access +probes and packet-level policy behavior. AppArmor being enabled alone cannot +satisfy the mandatory access control check. diff --git a/docs/remote-server-widget.md b/docs/remote-server-widget.md index 3985f34c..7e3ec0fa 100644 --- a/docs/remote-server-widget.md +++ b/docs/remote-server-widget.md @@ -1,7 +1,8 @@ # Remote Server widget -**Remote Server** is included and enabled in the menubar on new installations, -including ISO installations. Its host and display name start empty: the bar +**Remote Server** is included but hidden in the menubar on new installations, +including ISO installations. Enable it in Settings → Menubar → Widgets. +Its host and display name start empty: the bar shows **Set up** and the dashboard offers **Choose SSH host**. No SSH connection starts until a host is configured. Existing visibility and host preferences are preserved when upgrading. @@ -67,7 +68,7 @@ overlay mounts are excluded from capacity collection. ## Implementation and lifecycle `Common/RemoteServer.qml` owns a single `Process` for the whole shell, independent -of monitor count. `scripts/remote-server.py` validates the destination and execs +of monitor count. `desktop/quickshell/scripts/remote-server.py` validates the destination and execs SSH, sending the self-contained `remote_server_probe.py` as a quoted Python program. One persistent SSH channel streams newline-delimited JSON. Its stdin accepts cadence/refresh messages, and EOF terminates the probe. The connection @@ -90,14 +91,14 @@ ignore the previous connection's late output. SSH failures appear in the view. Read-only diagnostics and manual refresh use the normal runtime IPC entrypoint: ```sh -cybexos-runtime ipc remoteServer status -cybexos-runtime ipc remoteServer refresh -cybexos-runtime ipc remoteServer configure -cybexos-runtime ipc popouts open remote +nerdos-runtime ipc remoteServer status +nerdos-runtime ipc remoteServer refresh +nerdos-runtime ipc remoteServer configure +nerdos-runtime ipc popouts open remote ``` -Tests cover counter resets, memory semantics, cache lifetime, transport quoting, -SSH restrictions, stream cadence/EOF, malformed samples, interface selection, -metric formatting, warning levels, chart windows and scales, filesystem and -sensor presentation, history bounds and settings migration. Run `./tests/run`. +`tests/quickshell/remote-server.test.cjs` covers interface selection, metric +formatting, warning levels, chart windows, presentation and settings. SSH +transport restrictions, stream cadence/EOF and real remote hosts still require +isolated runtime qualification. Run `./tests/run`. For live checks, use `tests/lib/quickshell-live` begin/end and the managed service. diff --git a/docs/rename-artifacts/grub-menu.png b/docs/rename-artifacts/grub-menu.png new file mode 100644 index 00000000..fb4b6537 Binary files /dev/null and b/docs/rename-artifacts/grub-menu.png differ diff --git a/docs/rename-artifacts/menubar-1x.png b/docs/rename-artifacts/menubar-1x.png new file mode 100644 index 00000000..7bca2156 Binary files /dev/null and b/docs/rename-artifacts/menubar-1x.png differ diff --git a/docs/rename-artifacts/menubar-2x.png b/docs/rename-artifacts/menubar-2x.png new file mode 100644 index 00000000..5dcca6c9 Binary files /dev/null and b/docs/rename-artifacts/menubar-2x.png differ diff --git a/docs/rename-artifacts/plymouth-password.png b/docs/rename-artifacts/plymouth-password.png new file mode 100644 index 00000000..3c0919ea Binary files /dev/null and b/docs/rename-artifacts/plymouth-password.png differ diff --git a/docs/rename-artifacts/plymouth-splash.png b/docs/rename-artifacts/plymouth-splash.png new file mode 100644 index 00000000..370e4ca8 Binary files /dev/null and b/docs/rename-artifacts/plymouth-splash.png differ diff --git a/docs/rename-artifacts/plymouth-unlock.png b/docs/rename-artifacts/plymouth-unlock.png new file mode 100644 index 00000000..57d209e5 Binary files /dev/null and b/docs/rename-artifacts/plymouth-unlock.png differ diff --git a/docs/rename-artifacts/web-about.png b/docs/rename-artifacts/web-about.png new file mode 100644 index 00000000..09c1615b Binary files /dev/null and b/docs/rename-artifacts/web-about.png differ diff --git a/docs/rename-artifacts/web-control-center.png b/docs/rename-artifacts/web-control-center.png new file mode 100644 index 00000000..ab670da8 Binary files /dev/null and b/docs/rename-artifacts/web-control-center.png differ diff --git a/docs/rename-artifacts/web-fastfetch.png b/docs/rename-artifacts/web-fastfetch.png new file mode 100644 index 00000000..bab27d73 Binary files /dev/null and b/docs/rename-artifacts/web-fastfetch.png differ diff --git a/docs/rename-artifacts/web-lock.png b/docs/rename-artifacts/web-lock.png new file mode 100644 index 00000000..a11b4cf9 Binary files /dev/null and b/docs/rename-artifacts/web-lock.png differ diff --git a/docs/rename-artifacts/web-welcome.png b/docs/rename-artifacts/web-welcome.png new file mode 100644 index 00000000..0eb6a9d0 Binary files /dev/null and b/docs/rename-artifacts/web-welcome.png differ diff --git a/docs/rename-to-nerdos.md b/docs/rename-to-nerdos.md new file mode 100644 index 00000000..45ab5be8 --- /dev/null +++ b/docs/rename-to-nerdos.md @@ -0,0 +1,277 @@ +# Rename to NerdOS + +NerdOS is the Opinionated System at **nerdos.org**, formerly CybexOS. +This work targets fresh NixOS 26.05 installations. The Fedora/RPM/Ansible +bridge-release and installed-system migration instructions in the supplied +older rename kit do not apply to this repository's lifecycle contract. + +## Implementation plan + +Baseline: commit `0cf9be2`, clean `main`; implementation branch `rename/nerdos`. +The regenerated inventory has 2,260 old-name occurrences in text, compared with +the kit's 6,250-reference Fedora-era snapshot at `4f31668`. The current tree +uses NixOS modules, systemd-boot/Lanzaboote and Ed25519 release metadata. + +1. Rename the NixOS module/input namespace, lifecycle Python package, CLI, + installer, system/user units, desktop helpers, state paths and fixtures in + reviewed groups. Preserve machine-owned inputs and personal ownership rules. +2. Rename Quickshell/native module identifiers, themes, widgets, translations, + browser demo, documentation and the bundled skill. Preserve external source + names and historical attribution; no command aliases or installation shims. +3. Import the supplied brand masters unchanged. Integrate the glasses, icons, + fastfetch and favicons; package/select Plymouth through the shared NixOS + module, including initrd assets and LUKS unlock. Keep theme colours unchanged + outside brand surfaces. +4. Update release identity and infrastructure instructions without publishing, + changing owner settings or touching signing material. Check source/ISO + contract parity, staged lifecycle, authenticated delivery, web and leftovers. + +## Result and scope + +- Product spelling is **NerdOS**, machine/module/input namespace `nerdos`, + uppercase/environment prefix `NERDOS_`, and user command `nerd`. + `OS` means **Opinionated System**. Helpers use `nerdos-*`. +- Nix modules, installer, lifecycle Python package, services, policy/PAM names, + disk labels, GC roots, personal skill discovery, default theme and state + paths use the new namespace. `quickshell.service`, native upstream services, + NixOS `ID=nixos`, the stable branch and `system.stateVersion` are preserved. +- The supplied package lives in `assets/brand/`. Deployment copies, their + original SHA-256 identities and font notices are included. Both menubar + icons, native application icons, README, web welcome/lock/about screens, + favicons, ASCII output and terminal installer use the new identity. +- The shared NixOS module packages/selects the `nerdos` Plymouth script theme, + allowing NixOS to include its script/images/plugins in the initrd. The script + retains its display-geometry recentering and password handling; positioning + uses the new logo's measured dimensions. The ISO gets a GRUB theme and an + ISOLINUX background made from the unchanged supplied Plymouth logo. +- Brand red becomes lime, with charcoal text/icons on lime controls. The + wallpaper-derived desktop palette and all fastfetch modules are preserved. +- Source archives, ISO names, release tooling, CI, documentation and issue + links use NerdOS. Ed25519 authentication, immutable exact revision/NAR + selection, replay/downgrade rejection and the staged update lifecycle remain. + +There is no Fedora/RPM/DNF/Ansible packaging or Cockpit/new-owner/welcome QML +application in the current tree. Those surfaces are retired, so they were not +reintroduced. The existing NixOS terminal installer is the fresh-install UI. +The native NerdOS launcher opens the existing desktop settings through the +runtime's IPC helper. The browser welcome remains an interactive demo. + +## Fresh-install policy + +The former Fedora bridge release, RPM Obsoletes/Provides, firewalld-zone +migration, old-name environment fallbacks, unit aliases and deprecated `cybex` +forwarder are deliberately omitted under the current `AGENTS.md` contract. +There is no supported one-line in-place conversion from an older CybexOS +installation. Back up personal data and perform a fresh qualified NixOS +installation. Do not run a rename script over existing personal configuration. + +Normal future NerdOS updates continue to preserve `/etc/nixos`, its unrelated +inputs, personal packages/services/VPNs, widget code and preferences, skills and +persistent data. Installer seeding remains absent-only; updates never repair a +build by deleting personal content. + +## Maintainer runbook + +These are **John's actions**. No external setting, key, release, tag, running +workstation or PXE image is changed by this branch. + +1. Review the **NerdOS trademark** and project-artwork licensing. Set the + product website/description to **nerdos.org** when ready. +2. Rename the existing GitHub repository to **DigitalPals/NerdOS** before the + first authenticated NerdOS NixOS release. Preserve GitHub's existing + redirects; do not create a replacement repository under the old name. + Review external links and integrations independently. The code's production + identity is the exact new repository; development source paths continue to + work locally before this infrastructure step. +3. Relabel isolated CI runners **nerdos-isolated**, and provision protected + environments **nerdos-release** and **nerdos-channel** with the existing + publisher isolation/review rules. Secrets retain their unbranded names: + `RELEASE_ED25519_PRIVATE_KEY`, `CHANNEL_ED25519_PRIVATE_KEY`, and the + `IMMUTABILITY_CHECK_TOKEN`; variables retain `RELEASE_KEY_ID` and + `CHANNEL_KEY_ID`. Check the workflows and [release guide](releasing.md) + before setting permissions. Do not expose these secrets to untrusted PRs. +4. Production trust is still empty. Supply owner-controlled public Ed25519 + keys, validity dates and purposes in `release/keys/trust.json` through the + reviewed first-release process. This rename does not generate, rotate, + relabel or replace signing keys. No RPM/GPG key or package repository exists + in the supported product. +5. Configure the repository's Pages channel publication so the updater can + reach `https://digitalpals.github.io/NerdOS/channels/{beta,stable}.json` + and detached `.sig` documents. The channel base is derived centrally from + `REPO` in `lifecycle/nerdos/trust.py`. GitHub Pages project URLs do not follow + repository redirects. There is no old-channel fallback or old installed + population to bridge in this unreleased, fresh-install NixOS product. + A future custom channel domain requires explicit source/host-allowlist and + authenticated-delivery review; setting a website CNAME alone is insufficient. +6. Qualify the **same final source revision and NAR** used for release artifacts: + source checks, both fresh ISO installations, password/LUKS login, real staged + upstream/vendor/combined/local update and recovery, personal persistence, + plugins, hardware and security. Preserve the existing blockers in + [validation status](validation.md), including Secure Boot installation. + Never treat this rename's build/fixture results as physical qualification. +7. After those gates pass, follow `docs/releasing.md`: create a new semantic + tag, publish an immutable signed release with its complete qualification + report, verify public assets, then promote a signed channel with a strictly + increasing sequence. Do not edit historical tags or published release assets. + +There is no Fedora package-domain switch, bridge release, compatibility expiry +release or tested straggler command for this fresh-install architecture. + +## Status + +Implementation and development verification are complete on local branch +`rename/nerdos`. Nothing has been pushed or published. + +Topic commits: + +- `8bd21d3`: record the current NixOS scope and reviewed rename batches. +- `9fb8d7f`: import the supplied masters, deployment artwork and licensing. +- `e08cd56`: browser identity, supplied marks, fastfetch, favicons and accents. +- `8e2c873`: NixOS namespaces, lifecycle, installer, desktop, release tooling, + tests and CI, including Plymouth packaging and staged-update coverage. +- `cb8b2e2`: README, contributor/agent guidance and NixOS operating documentation. +- `ab1506d`: supervise the real Plymouth daemon and verify masked keyboard input + and its password callback. + +These are development checks. No release artifacts or physical qualification +report were produced. The owner must repeat the release gates on one final +committed source revision and NAR identity before publishing. + +### Baseline and final checks + +The baseline is `0cf9be2`. Baseline host `tests/run` reached 420 Python tests +with one failure, one error and 15 skips; the failures were fingerprint D-Bus +enrolment tests. Supplementary host checks also lacked WebSockets and Lua. +The **entire baseline `nix develop -c tests/run` passed** in a disposable +rootless Nix container, including 420 Python tests, 1,360 Node tests (1,341 +passes and 19 environment/optional skips), native helpers, plugin/theme suites +and Model Usage. The final renamed source suite passed with 424 Python tests +(including four new artwork tests) and the same 1,360 Node tests, 1,341 passes +and 19 optional/environment skips. The remaining helper/plugin/theme/Model +Usage groups also passed. + +Baseline browser CI passed: 10 files / 116 tests, lint, TypeScript, production +build and production render/asset checks. Baseline `nix flake check --no-build` +also passed. The isolated environment uses the existing locked Nixpkgs and Nix +2.35.3; no host Nix installation or workstation activation is involved. + +| Check actually run | Baseline | Final | +| --- | --- | --- | +| `nix flake check --no-build` | Passed | Passed | +| `nix develop -c tests/run` | Passed in Nix environment | Passed; 424 primary Python tests | +| Browser: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`, `npm run test:render` | All passed | All passed; 116 component tests and 2 production checks | +| `nix develop -c tests/qml-lint` | Not separately run | Passed: 355 files, 4 harnesses, no errors; 8 warnings | +| `nix build .#plymouth .#checks.x86_64-linux.lifecycle .#checks.x86_64-linux.parity --no-link -L` | Not separately run | Passed; shared live/installed module contract includes boot branding | +| `nix build .#checks.x86_64-linux.vm --no-link -L` | Not separately run | Passed; combined boot, graphical Plymouth/password callback and actual local staging, 321 seconds under software emulation | +| `nix develop -c python3 -B tests/nixos-native` | Not separately run | Passed: real lock/prefetch, unrelated-input preservation, isolated HTTPS/ETags, signature rejection, unavailable channel and installation-source transfer | +| `nix develop -c python3 -B tests/user-plugins.py` | Storage/portable coverage in source suite | Passed with actual isolated Quickshell/Sway: N, N+1, rollback and all six supported Omarchy kinds | +| Bundled skill `quick_validate.py agent-skills/nerdos` | Not separately run | Passed | +| Supplied artwork hash/PNG/SVG/contrast checks | Absent | Four regression tests passed | + +The VM exercises the packaged `nerd` command, NerdOS paths/PAM/skill/icon and +fastfetch discovery, personal initialization, real compositor configuration +validation, the supervised local stage/archive/build/checker/closure-diff path, +and unchanged hashes for the machine flake, lock and personal sentinel. A local +stage reached `ready` with its NerdOS input identity and review token. The +Plymouth fixture uses a supervised unit, ignores the VM's serial console to +select the graphical theme, and uses an absolute password-callback executable +because Plymouth sanitizes its environment. An initial direct daemon launch +held the driver's shell open. An earlier fixture also used an absolute module +path rejected by pure evaluation; it now uses the machine's `local/` module. +These initial fixture issues are distinct from a product update or installation. + +The combined boot/staging VM used development source NAR +`sha256-gwTFPpjTxp5P1XGdIE7+iNepKMDbmSoGVdoR9OUviME=`. The code matches the +implementation/test commits above; the final handoff commit adds this report +and review images. This working-tree run does not qualify an authenticated +release revision. + +**Not exercised in this task:** a fresh ISO installation, actual LUKS disk +unlock, update application/reboot/confirmation/rollback on the renamed source, +upstream/vendor/combined installed update paths, final full proprietary-app/XPS +closure builds, physical 1×/2× displays, Secure Boot, camera/NVIDIA or other +hardware qualification. No ISO was built and no workstation service was changed. +The existing installation/hardware qualification tools and release blockers +remain in place. The signing trust store remains empty. + +### Fresh-install and ownership coverage + +| Contract | Evidence | +| --- | --- | +| Old installations are not converted | Current installer is live-only and fresh-only; VM rejects invocation on the installed profile; no migration/forwarder/unit aliases were added | +| ISO and installed profiles select the same product | `nix/tests/parity.nix` evaluates the public module contract, including boot theme/package/logo/label, services, PAM, firewall, fonts and hardware declarations | +| Saved personal choices and unrelated inputs remain machine-owned | Existing lifecycle/configuration/personal-init tests; real native lock-delta checks; local-stage VM hashes | +| User widgets, skills and data remain outside vendor replacement | Existing source tests plus actual N/N+1/rollback Quickshell plugin tests and absent-only personal initialization | +| Release authentication stays strict | Existing signature/source-NAR/replay/downgrade and owner/URL rejection regressions, real isolated native transport tests | +| Boot artwork reaches initrd and runtime | Built Plymouth package; shared NixOS selection; installed VM theme/asset discovery and splash/password UI checks | + +### Leftovers + +`scripts/check_leftovers.py` is the supplied checker, with its default pointing +at [`scripts/rename-leftovers-allowlist.txt`](../scripts/rename-leftovers-allowlist.txt). CI runs it. The inventory tool is +retained as `scripts/rename-inventory.py`. + +Every allowlist rule is confined to a named file and reason: README name history; +this runbook; the verbatim supplied usage guide; the original wallpaper creator, +generator and its recorded digest; two historical camera-patch author lines; +three pinned Model Usage design-reference attribution lines; and the checker, +inventory and allowlist's own retired-name patterns. There are no broad runtime, +workflow, migration, package or old-repository exceptions. + +Final scanner command (also checked with `--untracked`): + +```console +python3 scripts/check_leftovers.py --repo . --allowlist scripts/rename-leftovers-allowlist.txt +OK: no unexpected 'cybex' references (62 allowlisted). +``` + +Case-insensitive searches for the four retired brand-red hex values and the +retired wordmark filename found only the inventory tool's detection patterns; +no product references remain. The provenance manifest, +original wallpaper generator and pinned Model Usage notice are byte-identical +to baseline. `git diff --check` passes. + +### Review artifacts + +Artwork masters and required deployment assets are product source. Retained +review images live in [rename-artifacts/](rename-artifacts/): + +| File | Bytes | Exercised surface | +| --- | ---: | --- | +| `web-welcome.png` | 132578 | Production browser welcome, outlined logo and charcoal/lime controls | +| `web-fastfetch.png` | 72170 | Production browser ASCII, lime tape, unchanged system-detail modules | +| `web-control-center.png` | 97757 | Glasses button and NixOS 26.05 base identity | +| `web-about.png` | 111598 | Supplied outlined logo in browser settings | +| `web-lock.png` | 131929 | Browser lock screen and supplied identity | +| `menubar-1x.png` | 2030 | Actual Qt/Quickshell component, 320×160 pixels at `QT_SCALE_FACTOR=1` | +| `menubar-2x.png` | 4527 | Actual Qt/Quickshell component, 640×320 pixels at `QT_SCALE_FACTOR=2` | +| `grub-menu.png` | 11416 | Actual packaged theme in standalone GRUB 2.12 UEFI at 1024×768, with fixture menu entries | +| `plymouth-splash.png` | 5502 | Packaged NerdOS script rendered by Plymouth in the booted VM | +| `plymouth-unlock.png` | 6083 | Actual password field below the supplied logo | +| `plymouth-password.png` | 6168 | Masked input before submission; callback returned the fixed test password | + +The native renders show canonical, resting and hovered marks left to right. +They use the actual `Theme.barIconSize` (15 logical pixels at default type +scale), isolated headless Sway with Mesa software OpenGL and the actual +`BrandIcon`/`BarBrandIcon` components. Qt's basic software scenegraph cannot +render the tint effect; the retained renders use its working RHI/OpenGL path. +They are component checks, not a physical display/session qualification. +The Plymouth images are 1280×800 software-emulated VM screenshots. Its real +password callback passed; no encrypted block device was opened by this UI test. +Browser captures use a 1280×800 viewport. The boot-menu background is retained +as product source at `desktop/boot/splash.png`. The GRUB capture boots a small +disposable FAT/UEFI VM with the built ISO theme, supplied logo and packaged +font; it does not boot an ISO or exercise installation. The generated ISO +menu entries, ISOLINUX menu and terminal installer were not visually exercised. +Retired Cockpit/new-owner/QML welcome surfaces have no render because they do not +exist in the current tree. + +Cleanup is complete. The task's rootless validation container and downloaded +Nix image were removed, including their Nix store and VM disks. The task staging +directory, temporary logs, render processes, preview server and browser tab, +dependency installation and generated browser outputs were removed. Checks +confirmed no task processes or mounts remain and no generated ignored outputs +remain in the checkout. Existing unrelated outputs and container images were +preserved. No ISO was built or left on PXE. The 11 review images listed above +are the only retained test artifacts: **581,758 bytes** in +`docs/rename-artifacts/`, retained for review of the requested brand surfaces. diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index c9f5e45b..00000000 --- a/docs/security.md +++ /dev/null @@ -1,207 +0,0 @@ -# Fingerprints and security keys - -CybexOS accepts a fingerprint or a FIDO2/U2F security key as an alternative to -the password for the **lock screen**, **sudo** and **system prompts** (the -shell's Polkit dialog). The password always keeps working. Signing in at the -login screen after a restart always asks for the password. - -Manage both in **Settings > Security** (`cybexos-runtime ipc settings open -security`). The page has two groups: - -- **Fingerprint**: the reader, the enrolled fingers, **Add fingerprint** (choose - a finger, then scan it until the bar fills), **Remove…** with a confirmation, - and **Use fingerprint to unlock and authorise** once a finger is enrolled. -- **Security keys**: the registered keys, **Add security key** (touch the key - when it flashes) and **Remove…** with a confirmation. - -Without a reader, or without key support, each group shows one short line -instead of controls. - -## Installation - -Every installation, on any hardware, gets the same packages and policy; the -checkout (`./install`, `./bootstrap`) and the ISO apply them identically. - -| Part | Source | Checkout | ISO | -| --- | --- | --- | --- | -| `fprintd`, `fprintd-pam`, `libfprint`, `pam-u2f`, `pamu2fcfg`, `libfido2` | `base_required_packages` | base role | compose; `cybexos-desktop` `Requires` for upgrades | -| `authselect` feature `with-fingerprint` | `cybexos-auth-policy apply` | base role | kickstart selection, then the same helper during installation, repair and reconciliation | -| Security-key steps and `/etc/pam.d/cybexos-lock` | `cybexos-auth-policy apply` | base role | the same helper | -| Settings switch helper | `roles/base/files/cybexos-auth-policy` | `/usr/local/libexec/` | `/usr/libexec/` | - -`roles/base/tasks/authentication.yml` runs the helper from the role in -workstation convergence (`main.yml`), ISO installation and repair -(`image.yml`) and upgrade reconciliation (`reconcile.yml`). A failure is -reported and leaves the password unchanged; it does not stop the install. - -The XPS 2026 role keeps only its hardware-specific part: the detector that -rejects the IPU7 camera's USB bridge, the `xps-fingerprint-check` diagnostic, -and a report when the detected reader is not one libfprint supports. See -[Dell XPS 2026 hardware support](xps-2026-hardware.md). - -## PAM design - -The rule throughout: every addition is `sufficient` and sits in front of an -otherwise unchanged stack, so a missing reader, finger, key, module or daemon -falls through to the password. Nothing removes or reorders `pam_unix`. - -### Fingerprints - -Fedora's managed `authselect` feature `with-fingerprint` adds -`auth sufficient pam_fprintd.so` to `system-auth`. That stack serves sudo, -Polkit, `su` and console login. It is not added to `password-auth`, which -serves SDDM. `pam_fprintd` returns at once when fprintd has no device or the -person has no enrolled finger, so they are asked for the password as before. - -CybexOS enables the feature once (recorded in -`/var/lib/cybexos/authentication/fingerprint-adopted`). After that, -`authselect`'s own state is the person's choice: the Settings switch runs -`pkexec cybexos-auth-policy fingerprint on|off`, which calls -`authselect enable-feature`/`disable-feature`, and no later install, repair or -upgrade turns it back on. The switch applies to every account on the computer. -If authselect does not manage the system, CybexOS leaves fingerprint sign-in -alone and the switch reads *Managed outside CybexOS*. - -### Security keys - -pam_u2f is added as the first `auth` step of exactly three stacks: -`/etc/pam.d/sudo` (and therefore `sudo-i`), `/etc/pam.d/polkit-1` (Polkit's -vendor stack from `/usr/lib/pam.d/polkit-1`, regenerated on each run so a Polkit -update is followed) and the lock screen's `/etc/pam.d/cybexos-lock`: - -```text --auth sufficient pam_u2f.so cue nouserok userpresence=1 origin=pam://cybexos appid=pam://cybexos authfile=.config/Yubico/u2f_keys [cue_prompt=Touch your security key] -``` - -- `nouserok`: an account without registered keys is unaffected (pam-u2f 1.4 - returns `PAM_IGNORE` for it). -- `userpresence=1`: a touch is always required. -- `origin`/`appid` `pam://cybexos`: fixed, so renaming the computer does not - invalidate keys (pam_u2f's default is `pam://$HOSTNAME`). -- `authfile=.config/Yubico/u2f_keys`: relative to the account's home and opened - as that account, never by root through a path the person controls. -- `-auth`: if the module is missing the line is skipped, not an error. - -authselect's own `with-pam-u2f` feature is deliberately not used: it would put -the key step into `password-auth` as well, where a touch at the SDDM login -would sign in without the password that unlocks GNOME Keyring. - -The helper leaves an administrator's own configuration alone. A file that -already contains a `pam_u2f` line (for example a required second factor) gains -no CybexOS step, an administrator's own `/etc/pam.d/polkit-1` gains only the -key line, and a `cybexos-lock` without the CybexOS header is never rewritten. If authselect's `with-pam-u2f` or -`with-pam-u2f-2fa` is enabled, CybexOS removes its own key steps and Settings -reports that keys are managed with authselect. - -Registrations live in `~/.config/Yubico/u2f_keys` (folder `0700`, file `0600`, -SELinux label restored after each change). Settings runs -`pamu2fcfg -n -o pam://cybexos -i pam://cybexos` and appends the result. A key -that asks for its PIN to register cannot be added from Settings, which has no -terminal for the PIN prompt; register it in a terminal instead: - -```bash -mkdir -p -m 0700 ~/.config/Yubico -pamu2fcfg -o pam://cybexos -i pam://cybexos >>~/.config/Yubico/u2f_keys # first key -pamu2fcfg -n -o pam://cybexos -i pam://cybexos >>~/.config/Yubico/u2f_keys # further keys -chmod 0600 ~/.config/Yubico/u2f_keys -``` - -Keys registered with another origin (for example pamu2fcfg's default) do not -verify with the CybexOS steps. - -### Login (SDDM): password only - -SDDM's stack includes `password-auth`, which has neither `pam_fprintd` nor -`pam_u2f`. This is intentional. The login password is what unlocks GNOME -Keyring: on an encrypted installation with automatic login, the LUKS -passphrase cached by `pam_systemd_loadkey` does it (the `sddm-autologin` stack -is unchanged); otherwise `pam_gnome_keyring` uses the password typed into SDDM. -A fingerprint or key login would leave the keyring locked until the first -application asked for it. After the first login, the lock screen, sudo and -Polkit accept fingerprints and keys. - -### Lock screen (hyprlock) - -`pam_fprintd` cannot run beside the password in one PAM conversation: the stack -is sequential, and while it waits for a finger the password cannot be checked. -hyprlock therefore uses its own fprintd D-Bus check, which runs in parallel -with PAM, and a PAM service without `pam_fprintd`: - -```text -# /etc/pam.d/cybexos-lock --auth sufficient pam_u2f.so … -auth include password-auth -``` - -`cybexos-runtime exec hyprlock` adds the authentication to the vendor or themed -lock screen at each lock (written to `$XDG_RUNTIME_DIR/cybexos/hyprlock.conf`): - -- `auth:pam:module = cybexos-lock`, only when that file exists. Without it the - configuration is used as is: naming a missing PAM service would refuse every - password. -- `auth:fingerprint:enabled` follows `with-fingerprint` in - `/etc/authselect/authselect.conf`, with the prompt *Touch the fingerprint - reader or enter your password* shown under the field (`$FPRINTPROMPT`). With - no reader or no enrolled finger the prompt stays empty. -- When the account has a registered key, the field reads *Password or security - key*, an empty Enter is passed to PAM, and while PAM waits for the touch the - field shows *Touch your security key if it flashes*. hyprlock starts PAM only - when the field is submitted and does not display PAM's own cue. - -A person's own `~/.config/cybexos/hypr/hyprlock.conf` is used unchanged; add an -`auth` section to it to use these factors. - -### Sudo and Polkit prompts - -These stacks are sequential: with a registered key plugged in, pam_u2f waits -for its touch first; with an enrolled finger, `pam_fprintd` waits for a finger -(up to 30 seconds and three attempts); then the password is asked. The shell's -Polkit dialog recognises both steps from PAM's messages and says *Touch the -fingerprint reader, or wait to enter your password.* or *Touch your security -key, or wait to enter your password.* instead of showing a password field that -cannot take input yet. A mismatched scan adds *Not recognised.* In a terminal, -sudo prints PAM's own messages. - -## Recovery - -Nothing here can lock anyone out: the password is accepted by every stack. -If a reader misbehaves, turn the Settings switch off, or run -`sudo authselect disable-feature with-fingerprint`. Remove a misbehaving key's -line from `~/.config/Yubico/u2f_keys`, or delete the file. `cybex uninstall` -runs `cybexos-auth-policy remove`, which removes the CybexOS key steps and lock -service and leaves authselect's fingerprint feature as chosen. - -## Manual verification on real hardware - -The repository tests stub authselect, fprintd and pamu2fcfg, and a Fedora 44 -container checked the generated stacks with `pamtester` (password accepted and -rejected correctly for sudo, `sudo-i`, Polkit and `cybexos-lock`, with and -without a registered but absent key, and with fprintd unavailable). The -following still needs a person, a reader and a key: - -1. A machine without a reader: Settings > Security shows *No fingerprint reader - was found.*; sudo and Polkit ask for the password with no delay. -2. Enrol a finger from Settings: the Polkit dialog asks for the password first - (fprintd's enrol action), the counter and bar advance with each scan, retry - messages appear for a short or off-centre scan, and the finger is listed. - Cancel an enrolment midway and confirm the reader is released (a second - enrolment starts). -3. Remove a finger; Polkit asks again; the list updates. -4. Lock the screen: the prompt appears under the field, a finger unlocks, a - wrong finger shows the failure, and typing the password still unlocks at - once while the reader is idle. Suspend and resume while locked. -5. sudo in a terminal and a Polkit action (for example a timezone change): the - finger works; ignoring the reader leads to the password prompt after the - timeout; the dialog's wording is as above. -6. Turn the switch off: the lock screen shows no prompt, sudo asks for the - password at once; `cybex update` does not turn it back on. -7. Register a key from Settings (with and without the key inserted beforehand; - a key with a PIN shows the terminal hint). Then: lock screen (Enter, then - touch), sudo, Polkit, and the same with the key unplugged (password only, no - delay). Rename the computer and confirm the key still works. -8. Restart: SDDM asks for the password; the keyring is unlocked (for example - in a browser that stores passwords). With encrypted automatic login, the - desktop starts and the keyring is unlocked as before. -9. On an XPS 2026 with a supported reader, `xps-fingerprint-check "$USER"` - passes; on the tested SKU `0DB9`, the SVP7500 camera bridge is still not - reported as a reader. diff --git a/docs/shell-appearance.md b/docs/shell-appearance.md deleted file mode 100644 index c27ec281..00000000 --- a/docs/shell-appearance.md +++ /dev/null @@ -1,196 +0,0 @@ -# Shared shell appearance - -Settings → Appearance owns the font family, text size, interface scale -(75–200%), density and panel borders/corners; the base font size (10–24 -logical pixels) sits under **Advanced text options**. These feed native panels, notifications, launcher and the -Omarchy compatibility components. Bar corner geometry remains separately -configurable; panel corners no longer inherit the bar radius. Attached drawers -and sheets use the same radius on all four corners, without square edge -overrides or concave bridges to the bar. - -The **Text & size → Interface font** dropdown is near the top of Appearance, -draws each family in its own face, and can also be reached by searching for -“font”. A preview above it shows the size text renders at. It includes the current default, -JetBrainsMono Nerd Font, and applies immediately across the shell. The installer -ships every offered family: JetBrainsMono Nerd Font, Figtree, Google Sans Flex, -Urbanist, OPPO Sans 4.0 and IBM Plex Sans. System/browser fallbacks include -Liberation Sans, Serif and Mono, the full Noto family, CJK, emoji, Font Awesome -and iA Writer (Mono, Duo, Quattro and Duospace), matching Omarchy’s base font -coverage. These use the managed -Fontconfig defaults independently of the shell font choice. - -Dark or light mode, the palette, the accent, the font and the text size also -reach the desktop outside the shell: the terminal, window borders, GTK -applications and the lock screen follow them (see -[System theme](system-theme.md)). - -## Themes - -A theme is a named set of appearance choices applied in one step: dark or -light, the accent and where it comes from, the bar colour, optionally a font -and a wallpaper, and optionally its own palette — background, panel and menu -surfaces, three text tones, and red, amber and green. **Theme** is the first -row of Settings → Appearance. Its dropdown previews every theme in three dots -(base, accent, text); picking one applies it at once, with the footer's Undo -for eight seconds. Changing anything a theme set afterwards (the mode, the -accent, the bar colour…) shows it as “Nord (modified)”; settings that match no -theme show “Custom”. A fresh installation shows **CybexOS**, the default look, -and existing settings are untouched until a theme is picked. The row's reset -returns every value a theme sets to that default; the wallpaper stays. - -The save button beside the dropdown opens **Theme name**: **Save** stores the -current mode, colours, bar and font as a user theme and selects it. The -wallpaper is not included. - -Built-in themes ship with the shell runtime (`themes/` beside `scripts/`): -CybexOS, Catppuccin Latte and Mocha, Everforest, Gruvbox, Kanagawa, Nord, -Rosé Pine and Tokyo Night, from their published MIT-licensed palettes (see -[Licensing](licensing.md#built-in-theme-palettes)). User themes live in -`~/.local/share/cybexos/themes//`, a user-owned root that updates never -prune ([ownership](architecture/ownership.md)); a theme folder added or -removed there by hand appears in the dropdown without a restart. - -### Palettes and readability - -Applying a theme copies its palette into the settings (`themePalette`), so the -look loads with the shell and survives the theme's removal. `Common/Theme.qml` -draws it only while its mode is the shell's mode and the accent source is -Fixed; switching to the wallpaper palette or the other mode falls back to the -shell's own references. Colours a theme leaves out are derived from the ones -it sets. The shell's floors still apply to every theme: panel surfaces are -held to their mode (7.5:1 against white in dark mode, 9:1 against black in -light mode), primary text to 7:1 and every quieter tone, status colour and -the accent's text to 4.5:1 against the theme's least favourable backdrop. A -theme's own colours are kept wherever they already read; the floors only lift -the others (`SettingsHelpers.themeTextPalette`, tested against every -built-in theme and a sweep of random palettes). - -### Sharing themes - -```bash -cybex theme list [--json] # built-in and user themes -cybex theme apply nord # apply now, or at the next shell start -cybex theme install ~/Downloads/harbour # a folder, .tar.gz/.zip, or https:// Git URL -cybex theme install https://github.com/someone/harbour-theme -cybex theme export nord ~/Desktop # writes ~/Desktop/nord/ -cybex theme export CURRENT ~/share.tar.gz --name "Night shift" -cybex theme remove harbour # user themes only -``` - -`apply` hands the running shell the theme over its `theme` IPC target -(`cybexos-runtime ipc theme use …`), where it is checked against the settings -schema again and applied with Undo. Without a running shell it writes the same -values to `~/.config/cybexos/shell.json` through the shell's own transactional -writer, keeping every other value and refusing a file from a newer release. -`export CURRENT` describes the current appearance and includes the current -wallpaper; exporting a theme includes its own wallpaper, if it has one. - -A theme is data only: - -```json -{ - "schema": 1, - "name": "Harbour", - "mode": "dark", - "accent": "#5e81ac", - "bar": "#202830", - "font": "plex", - "palette": {"background": "#202830", "surface": "#28323c", "text": "#e6ecf2"}, - "wallpaper": "harbour.jpg", - "author": "Someone", "license": "CC-BY-4.0", "source": "https://example.com" -} -``` - -`name`, `mode` and `accent` are required. `accentSource` is `fixed` (the -default) or `wallpaper`, which takes no palette. `bar` is a Bar page preset -(`default`, `macos`, `black`, `graphite`, `slate`, `white`), a `#rrggbb` colour -or `{"hue", "saturation", "lightness"}`; `font` is a shell font id (`figtree`, -`google`, `urbanist`, `oppo`, `plex`, `mono`). `palette` takes `background`, -`surface`, `surfaceRaised`, `text`, `textMuted`, `textDim`, `red`, `amber` and -`green`. `id` defaults to one made from the name. Unknown keys are refused. - -`install` is deliberately strict. A theme holds `theme.json` and the one PNG, -JPEG or WebP image it names, checked by content; a README, LICENSE or -`.gitignore` beside them is tolerated and not copied. Anything else — scripts, -QML, other dotfiles, folders, symbolic or hard links, device files, paths that -leave the theme, submodules — refuses the whole source, as do files over the -size limits (64 KiB for `theme.json`, 16 MiB for an image, 64 entries). -Archives are read member by member without extracting them. Git sources must -be `https://` URLs without credentials; the helper makes a shallow, -blob-less clone with hooks disabled and only https allowed, checks the whole -tree listing, and then reads the two files it needs with `git cat-file`, so -nothing from the repository is checked out. The installed folder records its -source in `.source.json` (the URL and commit for Git). An existing user theme -is replaced only with `--replace`; a built-in id cannot be taken. - -The command is `assets/scripts/cybexos-theme` on both installation paths -(`~/.local/bin` on a checkout, `/usr/share/cybexos/bin` from the RPM). It -runs the active runtime's `scripts/theme-packs.py`, which Settings uses too, -so the schema and the theme-to-settings mapping (`theme_packs.settings_patch`) -exist once. - -Fresh installations leave **System tray** disabled. Add it from Settings → -Bar when wanted; existing saved widget choices are preserved. - -`Common/ShellMetrics.js` calculates a rounded effective font size once from -base size × interface scale × accessibility (text size) scale. Typography uses this size relative -to a 12px reference. Geometry and padding additionally multiply by density -(Compact 0.92, Default 1, Comfortable 1.16). Compact changes geometry without -shrinking text. Panel widths track that shared geometry scale without the old 115% cap and -clamp to output bounds. The launcher reduces its scrolling results viewport -when vertical space is limited. Settings fields and narrow rows grow with text. -Qt/Wayland alone applies output device scaling; these are logical pixels. - -Omarchy Style receives the same effective font and density, plus the optional -plugin scale. Advanced Omarchy tokens can intentionally diverge from the shared -settings. Existing explicit plugin border overrides remain valid; choose -**Shell** under Settings → Appearance → Omarchy plugins to inherit shared -surface borders. - -The defaults use Dark mode, a Hug bar, wallpaper colors, opaque surfaces and -numbered workspaces. Typography uses JetBrainsMono Nerd Font at 14px, 100% UI -scale and standard spacing. Panels have 16px corners and no border (width 0); -plugins inherit the shared appearance. Appearance has no preset actions. -Existing saved preferences remain in effect; section resets use these defaults. -New ISO-installed accounts explicitly seed the same JetBrainsMono font choice. -Empty or older settings without a font also inherit this default; the historical -Google Sans migration applies only to a stored pre-schema-7 Urbanist value. - -The shared library and usage rules are documented in [Shell typography](shell-typography.md). - -Typography derives from [Omarchy's scale](https://github.com/omacom/omarchy/blob/quattro/default/themed/shell.toml.tpl) -and [default monospace family](https://github.com/omacom/omarchy/blob/quattro/default/fontconfig/conf.avail/50-omarchy.conf), -with a larger default base: 12px captions, 13px secondary copy, -14px body/control/bar text, 16px titles, 18px headings, and 28/33px display -values. The heading multiplier is tuned to 18px at the new base. -Settings labels, inputs, pickers and actions use the same body role as plugin -controls. Regular copy uses -weight 400; headings can use medium, semibold or bold. - -Native body/caption and plugin body/caption share the same reference sizes. -Other semantic roles (headings and hero values) can have distinct sizes -while deriving from that common scale. Fixed-format numerical content follows -the monospace family when the monospace font is selected. - -## Verification - -The appearance unit matrix covers all density/accessibility combinations, -base-font and UI-scale boundaries, output width clamping, settings persistence, -shared/plugin border precedence and one-time opacity application. Existing -launcher, Settings and notification contracts check their scalable controls -and bounds. Live checks use `tests/lib/quickshell-live` around the managed -service and compare `shell debugPluginTheme` with screenshots of the same -Model Usage tab/data, native Settings, popovers and a local test notification. -The diagnostic reports native and plugin body, caption, title, heading and -display sizes so their equality can be checked directly. No second live -Quickshell process is needed. - -Validated on 2026-09-21: the full repository gate passed, including 744 unit -tests. Live checks passed all nine density/accessibility combinations, shared -custom border color/opacity, light/dark updates and the then-available presets against the -same Model Usage data. Launcher, Settings, audio and notification surfaces -were checked live, including enlarged text with comfortable spacing. Both -connected outputs used 2× device scaling; fractional output scaling was not -tested live. The isolated QML lifecycle test was skipped while the managed -desktop was active; managed-service checks confirmed a single Quickshell -process and no QML errors in its current invocation. diff --git a/docs/shell-recovery.md b/docs/shell-recovery.md index ae1d81fd..1e86c3ec 100644 --- a/docs/shell-recovery.md +++ b/docs/shell-recovery.md @@ -7,7 +7,7 @@ select the standalone recovery configuration on the next start. Successful service stops, deliberate restarts, a long healthy run, and a new boot reset the failure counter. A late stop notification cannot override a recovery choice. -Recovery uses `quickshell/safe-mode/shell.qml`. It provides a bar on every output, +Recovery uses `/etc/nerdos/runtime/quickshell/safe-mode/shell.qml`. It provides a bar on every output, a clock, a terminal button, and **Retry desktop**. Super+Space opens a terminal. It imports no normal settings, theme, plugins or connected integrations, so a broken widget or personal setting cannot prevent the recovery UI from loading. @@ -15,26 +15,25 @@ The minimal desktop does not provide the normal notification or authentication interfaces. User settings and plugin enablement are never rewritten. ```sh -cybex shell status # JSON status, last exit and selected runtime -cybex shell safe # enter recovery and restart the managed service -cybex plugin list # inspect installed plugins while in recovery -cybex plugin disable PLUGIN_ID # disable an identified broken plugin normally -cybex shell recover # clear the crash counter and retry the full shell +nerd shell status # JSON status, last exit and selected runtime +nerd shell safe # enter recovery and restart the managed service +nerd plugin list # inspect installed plugins while in recovery +nerd plugin disable PLUGIN_ID # disable an identified broken plugin normally +nerd shell recover # clear the crash counter and retry the full shell ``` -**Retry desktop** performs the same action as `cybex shell recover`. If the -problem persists, the next three failures return to recovery. If it began in a -development checkout, `cybex dev disable` also remains available. Recovery does -not guess which plugin caused an exit: the status record and current service +**Retry desktop** performs the same action as `nerd shell recover`. If the +problem persists, the next three failures return to recovery. If a development checkout was explicitly +selected, `nerdos-runtime dev disable` restores the vendor runtime. Recovery +does not guess which plugin caused an exit: the status record and current service journal support diagnosis without discarding working settings. The private, atomic state record is -`$XDG_STATE_HOME/cybexos/shell-recovery.json` (normally -`~/.local/state/cybexos/shell-recovery.json`). It stores lifecycle metadata only. +`$XDG_STATE_HOME/nerdos/shell-recovery.json` (normally +`~/.local/state/nerdos/shell-recovery.json`). It stores lifecycle metadata only. Malformed state chooses recovery. Explicit recovery remains active across -reboots until the user retries; stale failure counts do not cross boots. Older -runtimes without this mechanism retain their normal launch behavior on rollback. +reboots until the user retries; stale failure counts do not cross boots. -`tests/shell-recovery.py` exercises crash accounting, stale stop callbacks, +`tests/nixos/test_shell_recovery.py` exercises crash accounting, stale stop callbacks, normal restarts, boot boundaries, recovery commands, malformed state and exact preservation of shell/plugin configuration. It does not launch a live shell. diff --git a/docs/shell-settings-manual-verification.md b/docs/shell-settings-manual-verification.md deleted file mode 100644 index 83a264b4..00000000 --- a/docs/shell-settings-manual-verification.md +++ /dev/null @@ -1,633 +0,0 @@ -# CybexOS Settings — manual verification - -The CybexOS Settings workspace makes bar geometry, appearance, -widgets, wallpaper, and system behavior live-configurable, persisted to -`~/.config/cybexos/shell.json`. Automated coverage: -`tests/run` — the Node suite (store merge/clamp rules, schema/property -agreement, qmldir completeness, IPC single-declaration, typography lint) plus -`tests/qml-lint`, a qmllint sweep over every shell QML file — and -`tests/verify-system` (settings IPC liveness). Everything pointer-driven below -is manual. - -## Opening and closing - -- [ ] `cybexos-runtime ipc settings toggle` opens the centered window; again - closes it. -- [ ] On an output with at least 900×664 logical pixels available, the card - opens at 900×664 with the labeled sidebar: Personalize (Appearance, - Wallpaper, Bar, Notifications), Devices (Displays, Sound, Network, - Touchpad) and System (Power, Region & formats, Online accounts, - Plugins, Backup & recovery, About), all visible without scrolling and with no dots on - changed pages. Below 860px available width, the same navigation becomes - an icon rail with hairlines between the groups, themed tooltips and - 42px targets. -- [ ] `cybexos-runtime ipc settings open bar` lands on the Bar page. The - retired ids still work: `open modules` lands on Bar, with its widgets - at the top, and `open system` on Power. The recovery-boot notification's action lands on Backup & - recovery's recovery points, and the power drawer's Idle settings on Power. -- [ ] The header reads " · " with the description in its - own capitalization (Network shows "IP addresses and DNS"). It carries - only Close, whose tip appears on hover (panel-colored, not a yellow - system box) and never merely because it has focus. -- [ ] With the window freshly opened, the first Tab lands in the search - field, then the current page in the rail, then the page; Close comes - last. No Tab-then-Enter sequence from the start resets anything. -- [ ] Gear in the Control Panel footer opens it (and closes the popout). -- [ ] Right-click anywhere on the bar slab opens it; left-clicks on modules - still open their popouts. -- [ ] `Super+,` opens Settings directly without making the menubar focusable. -- [ ] Drag the header to move the floating window; Super+right-drag resizes it. - The sidebar responds to the window width, and page contents scroll. -- [ ] Esc first clears search or closes a widget subpage, then closes Settings. - The close button and the normal compositor close shortcut also work. -- [ ] Clicking another application, opening the launcher, or opening a bar - popout leaves Settings open. It participates in normal window switching. -- [ ] Repeated `settings open` calls focus the existing window without creating - duplicates. Closing and reopening works through every entry point. -- [ ] Opening settings while a popout is open closes the popout first. - -## Launcher keyboard path - -- [ ] Restart Quickshell, then immediately press `Super+Space` and type. The - first character appears in the search field; no click or second key is - needed to establish focus. -- [ ] With the empty query and its first result selected (for example, - 1Password), press Enter immediately after `Super+Space`. It launches - even while the card is still completing its short entrance animation. -- [ ] Down/Up, Tab, Home/End, Alt+1…8, Enter, and Esc all work without moving - focus out of the search field. Closing restores focus to the prior app. -- [ ] Reopen repeatedly: results appear together without a row-by-row delay, - and the first open after shell startup feels the same as later opens. - -## Persistence - -- [ ] First slider drag creates the JSON; `watch -n1 stat -c %y` on it shows - at most ~2 writes/s during a continuous drag. -- [ ] Editing the JSON externally applies live (no restart); junk values are - clamped or reverted to defaults on the next save. -- [ ] A schema-5 pristine floating bar migrates to Hug; a customized height, - radius, or gap remains Floating; an old non-floating bar becomes Attached. - Module order and centered Clock/Weather remain unchanged. -- [ ] Deleting the file live restores defaults; restart keeps them. -- [ ] A changed row wears the accent mark and, on hover or focus, its reset - chip. A page with changed values ends in "Reset to defaults" - (Displays: "Reset night light to defaults"); pages with nothing changed, - and pages whose values live outside shell.json, show none. -- [ ] Reset controls reset exactly their row or page and show an eight-second - `… reset · Undo` line in the rail footer. Undo restores the snapshot; a - new reset replaces it; any manual edit clears it. A forced save failure - exposes Retry. -- [ ] The rail footer is empty at rest. A change shows "Saving changes…", - then "Saved" for about two seconds, then nothing; errors and the newer- - schema warning stay until resolved. -- [ ] Every page draws one bounded column centered in the pane: labels on - the left, every control ending on the same right-hand edge, a hairline - between rows, segmented choices on one track, sliders of bounded length, - and long choice lists as dropdowns. - -## Wallpaper page - -- [ ] The page keeps the bounded, centered column of the other pages: on a - wide window its rows end on the same right-hand edge as Appearance's, - and moving between the two does not shift the column sideways. -- [ ] A Browse row at the top chooses Library or Online on one segmented - track, with no changed mark or reset chip. -- [ ] Grid lists the configured wallpaper folder inside the row grid (from - the label lane to the controls' edge); clicking a thumb swaps the - wallpaper live, moves the accent ring + ✓, and the Current row under the - grid names the new file. -- [ ] "Shuffle now" on the Current row picks a different wallpaper each press - and is disabled while the folder holds fewer than two images. -- [ ] `Super+K` opens the shortcut sheet at up to 1180 logical pixels wide with - three balanced columns on a wide screen (fewer on a narrow one). There is - no Hardware keys group, and a long row such as Resize window wraps its keys - under the label without clipping. -- [ ] The page opens on Library and makes no network request. Choosing Online - (or `cybexos-runtime ipc wallpaper browse`) shows popular Wallhaven - results sized for the connected displays; Sort, Category and Size search - again, and their reset chips restore Popular / General / Fits my displays. - The Search row runs on Enter or its search button, never on leaving the - field, and Down moves from the field to the results. - Scrolling to the end, or pressing Down on the last row, appends the next - 24 results without jumping back to the top. -- [ ] Picking an online result shows download progress on the tile, then saves - `wallhaven-.` into the wallpaper folder and applies it (palette - follows). Picking another result mid-download cancels the first and leaves - no `.part` file. A saved result shows its download mark and applies - without a new download. Offline, the view reports a connection error. -- [ ] Rotate 15 min / 1 hour / Daily arms the timer ("Off" disarms). -- [ ] The Folder row shows the folder path as its label in the mono face, - with the image count on its hint line; an unusable folder shows its error - there instead. A long path elides in the middle before it reaches - Choose… and Open. -- [ ] "Choose…" stays inside the settings surface. Valid folders, - including paths with spaces, preserve the current basename or choose the - first alphabetic supported image. Empty/unreadable folders change nothing. -- [ ] "Open" opens the selected directory in the file manager. Large folders - scroll smoothly without constructing every thumbnail at once. -- [ ] Narrow the content below 520px: the gallery switches to one column, the - rows stack their controls under their labels, and the folder actions drop - under the path without clipping or covering each other. - -## Appearance page - -- [ ] Dark / Light changes the shell palette; Glass effect switches the bar, - popouts, launcher, notifications, OSD, shortcuts, tooltips, - and floating menus between blurred translucent and opaque surfaces. - The full-screen shortcut scrim stays translucent in both modes. -- [ ] Glass effect applies without closing Settings or remapping/flickering the - bar. Toggle it twice quickly, then reload Hyprland and restart Quickshell; - the final persisted state wins each time. -- [ ] Text & size opens with a live preview — a bar strip (workspaces, date - and clock, status icons) and a popover with two rows — captioned - "Preview · text renders at N px". Text size, Interface scale and Density - resize it along with the rest of the shell, and N matches the rendered - base size (12 at defaults, 14 at Large with 100%). -- [ ] Text size, Interface scale and Density are in view; Base font size is - under "Advanced text options", which stays closed on opening. Searching - "Base font size" opens the disclosure and highlights the row. With a - changed base size and the disclosure closed, the page foot still offers - "Reset Appearance to defaults". -- [ ] Interface font is a dropdown at the controls' edge. The closed button - and every option draw in their own face; picking one reflows the bar and - popovers instantly. Test every menu font: option names stay within the - list without clipping. -- [ ] Accent source Wallpaper shows a Wallpaper palette row: six swatches at - the controls' edge (hover names each) and a hint that reads "Generated - from " or the generation status. Switching Dark / Light selects the - cached variant without a new Matugen process; changing wallpaper - regenerates once after the debounce. -- [ ] Change wallpapers rapidly: no stale palette flashes. Temporarily hide - `matugen` or feed malformed output: the selector remains Wallpaper, - the fallback error appears on the palette row, and the stored fixed - palette renders. -- [ ] Accent source Fixed reveals an Accent color row (six presets, right - aligned, their name and hex on the hint line) and an Accent hue row - directly under Accent source, and scrolls them into view. The presets - are one Tab stop; arrow keys, Home and End move and pick. Presets and - hue recolor the whole shell, and in Wallpaper mode they are neither - focusable nor exposed. -- [ ] Resetting Accent source returns to Wallpaper and restores the fixed - accent with it; Undo brings both back. -- [ ] On the Bar page (moved there from Appearance), Background offers Shell - Default, macOS, Black, Graphite, Slate, White, and Custom in both - Wallpaper and Fixed accent modes, and the accent choice leaves it - unchanged. -- [ ] A Black menubar changes its text/icons to light tones; White changes - them to dark tones. Accent, warning, error, workspace, weather, and T3 - marks remain legible, with no change to popover colors. -- [ ] Custom reveals Hue, Saturation, and Lightness sliders. Their tracks and - the real bar update live, the chosen HSL survives a preset round-trip, - and the Bar Background reset restores the adaptive Shell Default. -- [ ] Panels: Border color appears directly under Border only for Custom, and - resetting Border also restores the custom color. Border opacity is - disabled and says why at width 0. -- [ ] Plugins: with Match shell style off, Interface scale, Border and Corners - appear; Border color only for Custom, Border width and opacity for every - mode except Shell. -- [ ] Appearance has no preset actions. Fresh settings use Dark mode, Hug, - wallpaper colors, opaque surfaces, numbered workspaces, JetBrains Mono - at size 12 and 100% scale, and no panel borders. - -## Bar page - -- [ ] A preview of the bar stays pinned at the top of the page while the rows - below it scroll. Over the current wallpaper it draws the bar at its edge, - in its style (Hug with its inverted corners, Floating inset by the edge - gap with its corner radius, Attached square), at its height and in its - background colour, with each section's enabled widgets as their icons, - the workspace strip and the clock's time. Every Layout, Background and - Behavior row changes it as it changes the real bar; Auto-hide fades it, - and Reserve space off lets its window slide under the bar. -- [ ] Clicking a widget in the preview opens that widget's options; closing - the dialog leaves the page scrolled where it was. At a short window - height the preview shrinks before the rows lose their room. -- [ ] Position Bottom moves the bar; every popout opens above it with its - directional motion mirrored, content upright; tooltips flip - above modules; toasts hug the top edge; Esc/hover-switching still work. -- [ ] Style picker renders Hug as full-width with 16px concave corners, - Floating as the existing detached rounded slab, and Attached full-width - and square. Top/Bottom mirrors Hug's corners without mirroring content. -- [ ] Edge gap and Corner radius appear directly under Style only for - Floating and leave keyboard and accessibility traversal immediately when - hidden. Their stored values survive a round trip through Hug and - Attached; resetting Style restores them along with it. -- [ ] Height offers Compact (30), Classic (34), Default (36), Roomy (42) and - Custom, with the value beside them. A stored height no preset names - shows Custom. Custom reveals a Custom height slider (28–60 px) that keeps - the current value, and dragging it across 34 or 42 does not fold it away. - Resetting Height, the page, or everything returns the picker to its - preset. Where the bar cannot be that short at the current text size, the - row names the height it is drawn at. A search for Height lands on the row. -- [ ] Bar background is one row: the label on the left, the seven swatches on - the control edge. Arrow keys, Home and End move and pick; the hint names - the colour, its hex value, and whether it adapts to the theme. Custom - reveals Hue, Saturation and Lightness as attached rows; the row's reset - restores Shell Default together with the custom values. -- [ ] Changing Height or Corner radius dirties/reset-enables Bar only; - Appearance remains clean. Reset Bar owns and restores both values. -- [ ] Auto-hide: bar slides away after ~1.6 s without hover; hovering the - screen edge reveals it; it stays out while a popout or the settings - window is open; clicks pass through the vacated strip. -- [ ] Reserve space off lets tiled windows extend under the bar - (exclusive zone released; Hyprland re-tiles once per toggle). -- [ ] Every connected output keeps its own bar while focus moves between - monitors; hotplug creates/removes only that output's bar. -- [ ] Opening a module or CybexOS Settings from either bar shows exactly one - panel, attached to the bar that was clicked. - -## Bar page — widgets - -- [ ] Left, Center and Right are rows of the Widgets group, separated by - hairlines rather than cards: the section's name in the label column and - its enabled widgets as chips in bar order, including widgets currently - hidden by runtime conditions (drawn quieter). An empty section says so - and still takes a drop. There is no per-section widget picker. -- [ ] Clicking a chip opens its options in a dialog sized to its settings, with - scrolling for longer forms. Close or Esc returns focus to the chip - without closing Settings. -- [ ] Every chip has a ⋯ button with Widget settings…, Move earlier/later, - Move to Left/Center/Right and Remove from bar; right-click, Menu and - Shift+F10 open the same menu. Remove retains settings; Undo restores - only that widget and its placement. -- [ ] Add widgets lists every disabled built-in and every installed plugin - widget instance as an outlined chip. Clicking one adds it to the section - it last lived in, settings retained, and keeps focus in the tray; its ⋯ - offers the other sections and its options. With every widget on the bar, - the tray says so. -- [ ] Drag within and between sections; the section under the pointer lights - up, the ghost and insertion marker track both axes, wrapped rows, empty - sections and edge scrolling, and the preview dims the dragged widget. - Escape cancels without writing. Alt+arrow keys and menu Earlier/Later - also reorder. -- [ ] Plugin adds are atomic, and reported success waits for the saved registry. - Plugin ordering matches the bar's separate plugin block in each section. -- [ ] At 480px window width, each section stacks its name above its chips, - chips use fewer columns, and menus/dialogs remain inside the window with - accessible controls. -- [ ] The ⋯ beside the caption under the preview offers Presets…, Manage - plugins…, Restore default built-in layout and Undo. Presets show the - chosen preset on the preview before it is applied; applying preserves - placement and all plugin preferences. -- [ ] Detail policy (Auto / Prefer detail / Always compact) is picked in the - dialog; Prefer detail compacts only after Auto widgets. -- [ ] Notifications → Grouping switches live between Separate and Status - group. It joins only adjacent Volume, Network, Bluetooth, or Battery - widgets, and every glyph keeps its own click target inside the pill. -- [ ] Per-module options apply live: clock seconds/date format, battery and - volume percentage toggles and thresholds, media title format and width, - T3 label and pulse, - workspaces min slots / hide empty / dots, notification grouping. -- [ ] Option rows in the dialog follow the page grammar: controls end on one - edge, rows are separated by hairlines, and explanatory copy (notes title - privacy, notification grouping, the sign-in policy) sits on the row's - own hint line. -- [ ] Indicators expands inline with Clock hover / Always show / Active only, - per-action switches, and drag/keyboard ordering. A hidden Dictation or - Screen recording action returns while running so it can always be stopped. -- [ ] The OCR clock-side action starts region selection, copies recognized text - to the clipboard, and remains hidden in Active only mode. A region with - a QR code copies the code's content instead; a link is not opened and a - Wi-Fi code's notification names the network without its password. -- [ ] `Print` with a click on a window (tiled, floating, on a second output, - with fractional scaling) captures exactly that window; a drag still - captures a region; a window hidden behind a fullscreen one is not offered. -- [ ] `Alt+Print`: the hint shows while selecting and is gone before capture; - the menubar's red recording indicator stays lit while scrolling (its - tooltip offers to finish), Enter, Escape, the shortcut again, or a - click on the indicator each finish, and Enter/Escape reach applications - normally afterwards. A page with a sticky header joins without repeats. -- [ ] `` Super+Ctrl+Shift+` ``: the round camera bubble appears in the recorded - area's corner, is in the saved video, can be dragged to another corner, - and the camera light goes off when the recording stops. Without a camera - one notification says so and the recording still runs. -- [ ] `screen-record --encoder` names VA-API on Intel/AMD with H.264 encode - support, and recordings play; on NVIDIA it names NVENC or an installed - gpu-screen-recorder, and the menubar indicator still tracks and stops it. -- [ ] `Super+Print` / `Super+Shift+C`: clicking a pixel copies its hex value and - the notification shows a swatch of that colour; Escape is silent. -- [ ] In Clock hover mode, reveal the indicators and click the clock without - moving the pointer. They stay expanded; moving into Calendar or switching - to another bar view causes no collapse or flicker. Close the view and - leave the bar: indicators collapse normally. A view on another output - does not hold them open, and opening an unrelated view with indicators - already collapsed does not expand them. -- [ ] Indicator action options apply live: dictation languages/model, recording - region/window/screen and elapsed label, reminder icon/count and quick-add - duration, DND click lifetime, and Stay awake click duration/countdown. -- [ ] Night light, DND, and Stay awake each honor Remember / Off / On after a - new desktop login. Restarting only `quickshell.service` preserves the - live state; timed DND/idle requests keep their original absolute deadline. -- [ ] Weather place/latitude/longitude edits commit on Enter or focus loss and - refetch; Esc inside a text field restores the value without closing - anything; junk input snaps back to the stored value. -- [ ] GitHub's account and watched repositories sit on the row grid without - cards: add a repository with Enter or Add, remove one with its ×, and a - watch error shows under that repository. -- [ ] Reset Bar resets layout, detail policies, and all module options (with - Undo); per-row undo chips reset one option. -- [ ] Toggles apply to the bar instantly; auto-rules keep working (Media - only while playing, Bluetooth only when connected, Battery on - laptops). -- [ ] Disabling a module whose popout is open closes that popout. -- [ ] T3 Code and Model Usage can each be toggled, reordered, and moved - across columns, including between built-in widgets; a fresh install - with connected widgets shows Model Usage first in the right section. -- [ ] Model Usage's options (bar display, bar providers, thresholds, refresh, - account privacy, square cards) apply live, and "Source and accounts" and - "Cost sources" open the panel's own settings. -- [ ] Disabling T3 Code or Model Usage while its popout is open closes only - that popout; the other widget still opens normally. -- [ ] Volume, Network, Bluetooth, and Battery can be reordered within or - across columns; each dedicated popout follows its widget. The fixed - Fedora Control Panel button remains at the right edge. -- [ ] In a narrow/stacked settings panel, pointer and keyboard drops use the - correct column-relative index, edge dragging scrolls, and focus returns - to the dropped row. -- [ ] With the Everything preset, the preview's center section moves aside - rather than painting over a crowded left or right section. - -## Notifications page - -- [ ] The sample toast heads the Style group, above Density, in the page's - one column (no second column beside the rows at any width). It updates - live for density, icons, body lines and timeout progress; the line under - it reads position · duration · density. -- [ ] Body preview is a segmented choice of Off / 1 line / 2 lines / 3 lines, - and the sample and real toasts follow it. -- [ ] Quiet hours Off/Nights hides the Quiet from / Quiet until rows and - removes them from Tab/Orca traversal; Custom reveals both directly under - Quiet hours, preserving the stored range. Nights and Custom show the - range beside the choices. -- [ ] "Send test" sits at the sample's foot and sends a real toast using the - current settings. With Do Not Disturb or quiet hours active, a line - under it says the test only collects in the center. -- [ ] On-screen display → Placement Top shows volume/brightness pills - top-center, clearing the bar; Bottom returns them; slide-in direction - matches the edge. - -## Displays page - -- [ ] The arrangement is the display picker. Clicking a tile, or tabbing to it - and pressing Enter or Space, selects it: the tile gains a check and an - accent outline, and the group below is titled with its kind and maker - (for example BUILT-IN DISPLAY · BOE) over a connector · model note. - Arrow keys on a focused tile place it beside the others and keep focus - on it. -- [ ] Turning a display off, or setting it to mirror another, moves it out of - the layout to a chip under it ("DP-1 · off", "HDMI-A-1 · mirrors - eDP-1"). Selecting the chip opens its rows; turning it on returns it to - the layout. -- [ ] Every property is one row with its control on the right edge: - Resolution, Refresh rate, Scale, Mirror and Adaptive sync as dropdowns, - Rotation as one segmented control, Flipped and Use this display as - switches. Rotation and Flipped together round-trip all eight transforms. -- [ ] Any edit raises the Apply bar at the page foot: "N changes not applied - yet" and which ones. Discard restores the live values. An overlap or - other problem replaces the list with the reason, and Apply does - nothing. The last row scrolls clear of the bar. -- [ ] Apply turns the bar into "Keep these display settings?" with a - 15-second countdown and a draining line. Keep changes saves; Revert now, - Escape, closing Settings or the countdown restore the previous - settings, and a countdown that ran out leaves a note at the top. -- [ ] There is no standing Refresh. Loading and errors appear as a line at - the top, with Refresh only after an error. Night light and Warmth still - apply as they change. -- [ ] Warmth drag with Night light on retints smoothly (single hyprsunset - restart per pause, not per step). - -## Network page - -- [ ] Saved Wi-Fi and wired connections are a list with their type and a - Connected status; the physical adapters are a quiet line under it. - Selecting one opens its settings below, under its name. -- [ ] Connect to Wi-Fi… and Advanced connection editor share one row with the - certificates, bridges and routing hint. -- [ ] VPN lists each NetworkManager VPN and WireGuard profile with its type and - state and a switch. Connecting a password VPN opens the VPN plugin's own - sign-in dialog; Cancel there reads "The VPN sign-in was canceled" on the - row. Remove… asks on a second row before anything is deleted. With no - VPN the group says so; Import VPN… opens a file picker in Downloads that - lists only .ovpn and .conf files, and the import does not connect. -- [ ] Hotspot appears only with a Wi-Fi adapter. While Wi-Fi is connected on - the only adapter, a warning says that network disconnects. Turning it on - shows Show password and QR code, which opens the join card with the - password spelled out; a phone joins by scanning it and gets an address - and internet. After a reboot the hotspot is off. -- [ ] The drawer's Network tab shows a VPN switch under Tailscale only when a - VPN profile exists: the connected one, else the one used last. -- [ ] Autoconnect and Metered connection read as ordinary rows; the metered - label no longer runs into its choices. -- [ ] IPv4 or IPv6 Manual reveals labelled Address and Gateway fields. A - malformed entry outlines its field, names the problem under it and in - the Apply bar. The DNS field reads Additional DNS while Automatic DNS - is on and DNS servers while it is off. -- [ ] Typing in a field raises the Apply bar at once, and the other - connections dim until the edits are applied or discarded. Applying an - active connection starts a trial in the bar ("Keep these network - settings?"): Keep saves; Revert now, closing Settings or the countdown - restore. An inactive connection saves directly and says so. - -## Sound page - -- [ ] The page is settings rows only: no drawer header, no MICROPHONE 100 - heading, no standing Refresh. -- [ ] Output: Volume with a mute button and a percent readout (Muted while - muted); one row per output device with a check on the default, and - AirPlay/network outputs folded under "N network outputs". Port appears - for a device with several ports; Balance (Center, L n, R n) follows a - drag without snapping back and is greyed out with a reason on a - non-stereo output. -- [ ] Input: the device list, Input volume with mute, and a live Input level - meter lined up under the volume track. -- [ ] Hardware profiles are dropdown rows. Each application is one row with - mute, its level and its output or input device. Advanced audio - controls opens Volume Control. - -## Touchpad page - -- [ ] Scroll speed reads 1.0× at the default, snaps to its 1.0 tick, and - applies to touchpad scrolling as it changes. - -## Power page - -- [ ] The Idle timeline marks Screen off, Lock and Suspend at their current - delays on a 1m–2h axis, with Never as a dashed zone at its end. Delays - that share a value share one dot; neighbouring labels rise onto a longer - stem instead of touching, at 900px and 480px window widths. -- [ ] Set Lock screen to 30 min with Screen off at 10 min: the span between - them is shaded amber and the line under the timeline warns that the - screen turns off 20 minutes before it locks. Lock at Never warns that it - never locks. Otherwise the line reads the order back in words. -- [ ] Lock screen, Screen off and Suspend are dropdowns that save and restart - hypridle as before; the power drawer's idle link lands on Lock screen. -- [ ] With `~/.config/cybexos/hypr/hypridle.conf` present, the timeline dims - and says it is shown for reference; the three dropdowns and Only on - battery are disabled with "Set in your hypridle.conf". -- [ ] Stay awake Duration starts, retimes and stops the inhibitor; its hint - shows the time left. Bar indicator → Indicator settings opens the - Indicators widget on the Bar page. - -## Region & formats page - -- [ ] 12 h clock reformats the bar clock and the "Now …" caption. -- [ ] °F refetches weather in Fahrenheit (bar chip + popover + forecast); - the caption reads "… outside" in the new unit. - -## Online accounts page - -- [ ] With no accounts the group shows "No accounts connected" and an - accent-filled Add account, which opens the account window. There is no - standing Refresh. -- [ ] Each connected account is a row (address, provider and status) with a - Use calendars switch under it. An account needing attention says so in - amber and offers Reconnect. Remove… asks first; Cancel leaves it. -- [ ] Stop the accounts helper (or break GOA): an error row with Refresh - appears; Refresh clears it once the helper answers again. - -## Omarchy plugins page - -- [ ] The rail and header say "Omarchy plugins". Browse plugins opens - https://plugins.omarchy.org/ in the browser, as does the link in step 1. - Below 520 px the button drops under the intro copy. -- [ ] Three numbered steps explain finding, pasting and enabling a plugin. -- [ ] Install is disabled until Source holds text; Enter in the field installs. - Pasting `omarchy plugin add --enable` installs `` (still - disabled). The result or error reads under the Source row. -- [ ] With nothing installed, Installed shows the "No plugins yet" state; - otherwise its heading carries the count. -- [ ] Each installed plugin is one row: name, "id · version · adds …", an - Enabled switch and a ⋯ menu with Update, Preview update, Clone as a - custom copy… and Remove…. Clone opens a New ID row under the plugin; - Remove… opens a confirmation row. A command's output reads under the - plugin it ran on. -- [ ] There is no "Configure bar widgets" link; plugin widgets appear in the - Bar page's tray. - -## About page - -- [ ] Status shows a green dot for Healthy, amber for issues or a failed or - rolled-back deployment, red when the service is down. -- [ ] Last deploy check reads "Today at 19:16", "Yesterday at …", a weekday or - a date in local time, in the Region clock format; Refresh re-reads it. -- [ ] The settings file path elides in the middle; Open saves then opens it. - Reset all settings is red, resets every page and offers the rail's - eight-second Undo. -- [ ] About has no recovery points; they are on Backup & recovery. - -## Backup & recovery page - -- [ ] Without backups, the Backups group is one row, "Personal files · Not - backed up", with Set up…. -- [ ] Set up… shows Step 1 of 3 in the page: Drive or folder / SSH server, - Choose… opening at the mounted drives, Cancel and Continue. A folder in - the home folder is refused with a reason; an SSH host that is not yet - trusted fails with "does not trust … yet". -- [ ] Step 2 of 3 shows the recovery key once in large mono text with Copy; - the copied key pastes, and does not appear in clipboard history. - "I saved it, back up now" starts the first backup (Step 3 of 3) with a - percentage that keeps updating after closing and reopening Settings. -- [ ] Choosing a drive that already holds CybexOS backups asks for their - recovery key and connects them; a wrong key says so. -- [ ] Last backup reads "Today at …" with a green dot and "Next backup …"; - unplugging the drive turns Destination's hint into "… is not - connected." and Back up now fails with that reason in a notification. -- [ ] Automatic backups switches off and on; the hint then reads "Automatic - backups are off." -- [ ] Restore… lists the backups by local time; restoring "Documents" opens - Files at ~/Restored//Documents and leaves ~/Documents unchanged. -- [ ] Recovery points list as rows named by local time, with the UTC stamp the - boot menu shows for bootable points. Restore… turns the row into a - warning with Confirm restore and Cancel. With none, the row says - "None yet". -- [ ] Search for "backup", "restore" or "rollback" lands on this page. - -## Regression sweep - -- [ ] Volume, Network, Bluetooth, and Battery are distinct transparent-resting - buttons. Each opens its own Audio, Network, Bluetooth, or Battery view; - with one open, crossing another button switches the panel in place. -- [ ] The rightmost Fedora logo opens the Control Panel. Its SESSION row shows - five equal controls in order: Lock, Suspend, Log out, Restart, and red - Shut down. Each closes the panel before running its established action. -- [ ] The launcher's Power action and `cybexos-runtime ipc session power` - open/toggle the Control Panel on the focused output; - `cybexos-runtime ipc session lock` remains a direct lock action. -- [ ] All popouts open/close/hover-switch as before at default settings; - Calendar → Weather and other adjacent-module switches work without a - second click; with Settings open, hovering a module also switches. -- [ ] Resize/hotplug from a wide output down to 800 logical px: detail compacts - Media → Weather → Clock date → T3 → Volume → Battery, every - enabled module remains, clusters retain an 8px gutter, and the center - shifts only after all eligible detail is compact. -- [ ] Fine-grained touchpad scrolling over Volume changes it once per - accumulated wheel step, not once per raw event. -- [ ] Workspace cells keep a 22px width and full 30px target. Moving right - sends the lozenge's right edge in 120ms and left edge in 300ms; moving - left reverses those assignments. Hide-empty/model/settings changes snap, - while urgency, numbered mode, tooltips, and accessible actions persist. -- [ ] Tab/arrow traversal, automatic focus scrolling, roles/states/actions, - and Orca announcements work for navigation, custom controls, resets, - drag/drop, module cogs and their sub-pages, and save errors. -- [ ] Capture comparison screenshots for default Hug/Wallpaper, Floating/Fixed, - and a narrow panel. In each, check header title/description, preset/test - action copy, font samples, notification labels, config/folder paths, - module names, and preview chips for clipping or overlap. -- [ ] `journalctl --user -u quickshell.service` free of QML errors and - binding loops after exercising every page. -- [ ] `tests/verify-system` passes. - -## Consistency regression checks (September 2026) - -- [ ] At 900px and 480px window widths, switch Wallpaper/Fixed and Custom bar - color. Subsection headings, swatches and status text follow the label - gutter; closed revealers leave no subsection spacer. Long palette errors - wrap rather than disappearing after two lines. -- [ ] Picker captions stack below narrow controls. Long options can wrap in - wide rows too, without painting over the next row. Tab to a reset chip: - its announcement includes the setting name, and focus does not shift the - control or scroll the page when it moves into navigation/search. -- [ ] Dirty/clean group headers keep their height and label width. Long titles - wrap within the reserved reset lane. Plugin install/clone fields follow - the shell palette and show a focus outline in light, dark and contrast - modes. Test Enter, blur, Escape and normalized/rejected text edits. -- [ ] In the drawer, Tab reaches the current tab; Left/Right and Home/End select - adjacent/edge tabs. Labels elide before tabs overflow, and a single-tab - configuration fills the lane. -- [ ] Pale fixed accents remain readable as text/icons/outlines in Light and - High contrast. The actual swatch/fill is unchanged. Test default, larger - text/comfortable density, and 80% UI scale/compact density. -- [ ] Notification actions wrap inside their card. Shortcuts become one column - at narrow widths and scroll with arrows, Page Up/Down and Home/End. - Wallpaper filename overlays retain white copy over their dark scrim. - -### Scope and verification record - -The pass covers the Settings workspace and all eight pages, widget detail -controls, wallpaper folder dialog, Bar and tooltip/menu components, launcher, -notification cards/actions, OSD, shortcuts and network overlays, all six Drawer -tabs, Day sheet, updates, notes, reminders, media, GitHub, T3 and Hermes. Legacy -popover entry points and the configurable `Ui`/`Commons` plugin kit were -reviewed through their shared controls and foreground roles. The standalone -welcome UI was inspected separately: its installer typography, palette and -minimum window size are deliberate and remain independent of the live shell. - -Rendered checks use the managed service and `tests/lib/quickshell-live` at -both boundaries. Captures exercise each Settings page, the available popout -entry points, launcher and shortcuts, plus wide/narrow appearance combinations. -Runtime component tests additionally cover wrapping picker bounds, subsection -sizing, hidden trailing group content, stable dirty headings, constrained drawer tabs, and text-field commit -normalization. See `tests/qml-lifecycle/shell.qml` and -`tests/quickshell/settings-layout.test.cjs`. - -Service-dependent states need an appropriate account/device: this workstation -presents T3/Hermes authentication screens and an -updater endpoint error. Those data sources and commands were not changed for -visual verification. Existing compatibility surfaces keep their separate -cursor-navigation and theme APIs; dense transaction/transcript/list layouts -were not forced into the settings row geometry. - -Generated wallpaper palettes were also checked in dark, light and high-contrast -modes, including gallery captions and keyboard selection in Drawer tabs. The -Widgets catalog switches to one column at the settings breakpoint, with inline -settings beneath their owning row and secondary tags yielding to widget names. diff --git a/docs/shell-translations.md b/docs/shell-translations.md index 3641ecad..46b9abe3 100644 --- a/docs/shell-translations.md +++ b/docs/shell-translations.md @@ -1,7 +1,7 @@ # Shell translations The menubar, launcher, Settings, notifications and every other Quickshell -surface show their own text in the session's language. CybexOS ships English +surface show their own text in the session's language. NerdOS ships English and Dutch. There is no separate language setting: **Settings → Region & formats → System language** sets the language every application uses after the next login, and the shell follows the same rule. Text without a @@ -9,7 +9,7 @@ translation stays English. The language comes from the first of `LC_ALL`, `LC_MESSAGES` and `LANG` that names one (`C` and `POSIX` name none). An unsupported language falls back to -English, as gettext does. `CYBEXOS_LANG=nl` overrides all three for tests and +English, as gettext does. `NERDOS_LANG=nl` overrides all three for tests and screenshots. ## How it works diff --git a/docs/software-and-defaults.md b/docs/software-and-defaults.md new file mode 100644 index 00000000..e14027db --- /dev/null +++ b/docs/software-and-defaults.md @@ -0,0 +1,43 @@ +# Software and application defaults + +Open **Software** from the application launcher or **Settings → Applications**. +Search uses Nix, the installed machine's locked Nixpkgs and its personal overlays. +The first search indexes package metadata locally; subsequent searches use a +cache that is invalidated when the machine configuration changes. Discovery +neither refreshes inputs nor creates a second package universe. + +Select packages, choose **Build preview**, then **Review deployment…**. Review +the built closure diff and choose **Apply reviewed change**. This is the same +serialized, checked deployment, trial boot, confirmation and rollback path as +system updates. Authentication starts a supervised worker; closing Software +does not cancel its build. Errors and worker exits settle the request visibly. +A selection changed elsewhere requires refreshing before another preview. + +The fresh machine template imports `local/software.nix` and stores selections +in `/etc/nixos/local/software.json`. Software edits only that declaration in a +staged copy. Packages managed in another personal or vendor module are labelled +as such. Removing a Software selection never removes another module's package +declaration. The Installed list covers declarative system packages; personal +Nix profiles and Flatpak applications retain their own ownership and tools. +No migration or alternate installation/update path is provided. + +**Settings → Applications** also selects a browser, terminal, text editor, +PDF viewer, image viewer and video player. These per-account preferences apply +immediately, using GIO's standard MIME associations and `xdg-terminal-exec`'s +standard terminal preference list. System defaults are fallbacks; unrelated +personal associations remain intact. A missing saved choice is retained and +shown as unavailable, while an available application handles launches. An +explicit association changed outside Settings takes precedence. + +NerdOS browser/terminal shortcuts, agent windows, maintenance and firmware +terminals, web apps, and dock suggestions use the same defaults. Chromium-based +selected browsers can open web apps in app mode; other browsers open their URL +normally. Private browsing uses the selected browser's advertised desktop action; +unsupported private actions produce a notification. Explicit `VISUAL`/`EDITOR` +values and personal shortcuts retain their precedence. Arguments are passed as +argv, including filenames and URLs containing spaces; they are not shell code. + +The `nerdos-apps` helper offers `status`, `set ROLE DESKTOP_ID`, `browser`, +`browser --private`, `webapp URL`, `terminal [OPTIONS] [-- COMMAND ARGS…]`, and +`edit FILE…`. `nerd software list`, `search QUERY`, `stage JSON` and `request ID` +expose the native package workflow for scripting. diff --git a/docs/system-theme.md b/docs/system-theme.md deleted file mode 100644 index f82d3f08..00000000 --- a/docs/system-theme.md +++ /dev/null @@ -1,271 +0,0 @@ -# System theme - -The shell's appearance settings — dark or light, the fixed or wallpaper -palette (or a [named theme's](shell-appearance.md#themes)), the accent, the -bar colour, the font and the text size — reach the rest of the desktop, not -just the Quickshell surfaces. Changing any of them in Settings → -Appearance, or a wallpaper change in wallpaper mode, recolours the terminal, -the compositor, GTK applications and the lock screen within about a second. - -## How it works - -1. `Common/SystemTheme.qml` exports the colours `Theme.qml` has already - resolved as one JSON object (the *tokens*). Nothing is recomputed: the - wallpaper palette, the contrast floors and the bar colour are exactly the - ones the shell draws. Translucent tokens are flattened over the base, so - every exported colour is an opaque `#rrggbb`. -2. When the tokens change (debounced by 400 ms, and never while a wallpaper - palette is still being generated), the shell pipes them to - `scripts/theme-apply.py apply`. -3. The renderer validates them (`scripts/theme_tokens.py`), saves them to - `~/.local/state/cybexos/theme/tokens.json`, and lets each target module - render its files into the same directory. A target reloads its application - only when one of its files changed. - -| Token | Source in `Theme.qml` | -| --- | --- | -| `mode` | `dark` or `light` | -| `source` | `wallpaper` when the wallpaper palette is active, else `fixed` | -| `colors.background`, `surface`, `surfaceRaised`, `bar` | `background`, `popBg`, `menuBg`, `barBg` | -| `colors.text`, `textMuted`, `textDim` | `textHi`, `textMid`, `textDim` | -| `colors.accent`, `onAccent`, `accentText`, `accentContainer` | `accent`, `accentFg`, `accentText`, `accentContainer` | -| `colors.stroke` | `stroke` flattened over `background` | -| `colors.red`, `amber`, `green` | `redText`, `amber`, `ok` | -| `font.ui`, `font.mono` | `fontMenu`, `fontMono` | -| `font.size` | `fontBaseSize`: base size × interface scale × text size, in logical pixels (14 on a fresh install) | -| `radius` | `surfaceRadius` (the compositor's window rounding) | -| `glass` | `glassActive` | -| `wallpaper` | absolute path of the current wallpaper, or empty | - -The sixteen terminal colours are derived by `theme_tokens.ansi_palette()`: -fixed hues per mode, pushed toward the ink colour only where a hue would fall -below 4.5:1 on the base, with black and white taken from the shell's text -ladder. - -## Text size - -`font.size` is the size the shell itself draws body text at -(`Common/ShellMetrics.js`), so Settings → Appearance → Text size, Interface -scale and the base font size reach the terminal, GTK applications and the -lock screen too. The targets do not share a unit — kitty and hyprlock size -fonts in points, GTK takes a scaling factor — nor a default: kitty's is -12 pt, GTK's 11 pt, the lock screen's clock 84. Converting the shell's 14 px -to 10.5 pt would shrink all of them, so each keeps its own default at the -shell's default size and scales it by the same ratio, -`theme_tokens.text_scale()` = `font.size` / 14, bounded to GNOME's 0.5–3.0: - -| Shell text | `font.size` | Scale | kitty `font_size` | GTK `text-scaling-factor` | Lock clock | -| --- | --- | --- | --- | --- | --- | -| Default | 14 | 1.00 | 12.0 | 1.00 | 84 | -| Large | 16 | 1.14 | 13.7 | 1.14 | 96 | -| Larger | 18 | 1.29 | 15.4 | 1.29 | 108 | -| 150% interface scale | 21 | 1.50 | 18.0 | 1.50 | 126 | - -Tokens saved by an older shell have no `font.size` and render as before. -User overrides keep winning exactly as for colours; see each target. - -## Diagnostics - -```bash -cybexos-runtime ipc theme status # last report per target, and any error -cybexos-runtime ipc theme apply # re-render and reload every target -``` - -`theme-apply.py render` rewrites the files from the saved tokens without -reloading anything. - -## Ownership - -Everything the renderer writes lives in `~/.local/state/cybexos/theme/` -(state, not configuration). User overrides keep winning: a user -`~/.config/cybexos/hypr/hyprlock.conf` replaces the generated lock screen, and -`~/.config/cybexos/hypr/user.lua` loads after the generated compositor -colours. - -## Targets - -### Kitty - -`scripts/theme_kitty.py` renders `kitty.conf`: `font_size` (12 pt scaled by -the shell's text size), foreground and background -(`text`, `background`), the selection (`accentContainer` with the ink the shell -draws on it), cursor and URLs (`accentText`, kept at 4.5:1 on the base), tabs -(the active one filled `accent`/`onAccent`, inactive ones `surface` with -`textMuted`), window borders, the three marker colours and `color0`–`color15` -from `ansi_palette()`. The font family and opacity stay in the vendor fragment. - -The vendor fragment, `roles/dotfiles/files/kitty.conf` (installed as -`~/.config/kitty/cybexos.conf` and included from the user's `kitty.conf`), -carries the same settings for the default dark theme, and `font_size 12.0`, -as a fallback, and a test keeps it equal to that render. Its last line, -`include ~/.local/state/cybexos/theme/kitty.conf`, loads the generated file -over it. kitty expands `~` in `include` and only logs a missing file, so a -terminal started before the shell's first export still opens with the -fallback. `globinclude` is not usable here: it globs relative to the including -file's directory, and kitty 0.47's Python refuses an absolute pattern, which -aborts loading the configuration. - -Reload sends `SIGUSR1`, which makes kitty re-read its configuration and every -include, to the user's GUI kitty processes: -`pkill --signal USR1 --uid --full --exact '([^ ]*/)?kitty( \+open)?( [^+@].*)?'`. -The command-line match mirrors kitty's own `is_kitty_gui_cmdline()`; a plain -`pkill -x kitty` would also hit Python kittens (`kitty +runpy …`), which -SIGUSR1 terminates. No running kitty (pkill status 1) is not an error. kitty's -`auto_reload_config` watcher does not help: it watches only the top-level -`kitty.conf`. - -User colours and a user `font_size` win when they come later: put them in -`~/.config/kitty/kitty.conf` after the `CYBEXOS MANAGED INCLUDE` block. kitty -applies a changed `font_size` to open windows on the same `SIGUSR1` reload. - -### Hyprland - -`theme_hyprland.py` renders `hyprland.lua`: a data-only `return { ... }` of -`hl.config` colours. - -| Key | Colour | -| --- | --- | -| `general.col.active_border`, `inactive_border` | `accent`, `stroke` | -| `group.col.border_active`, `border_inactive` | `accent`, `stroke` | -| `group.col.border_locked_active`, `border_locked_inactive` | `red`, `stroke` | -| `group.groupbar.col.active`, `inactive`, `locked_active`, `locked_inactive` | `accent`, `stroke`, `red`, `stroke` | -| `group.groupbar.text_color`, `text_color_locked_active` | `text` | -| `group.groupbar.text_color_inactive`, `text_color_locked_inactive` | `textMuted` | -| `decoration.shadow.color` | dark: `background` at 0xee; light: `text` at 0x30 | - -Window borders are 0 px in the vendor look, so their colours only show once -`user.lua` sets a `border_size`. Rounding is not a theme value: it stays 16 px -to match `Theme.surfaceRadius`, whatever the panel corner setting. Blur, the -glass layer rule and power saver are untouched. - -`looknfeel.lua` applies the file right after its vendor `hl.config`, whose -colours are this renderer's output for the default dark tokens (a test keeps -them equal). A missing file keeps those colours. The file is read as text, -loaded in an empty environment and checked key by key: a syntax error, a call, -a value that is not `rgb(rrggbb)`/`rgba(rrggbbaa)` or an oversized file leaves -the vendor colours in place and is reported by - -```bash -hyprctl repl 'return __cybexos_system_theme.error' -``` - -Keys the compositor config does not know are ignored, so a newer renderer never -costs an older config the whole theme. - -A theme change reaches the running compositor through -`hyprctl eval 'cybexos_system_theme("/hyprland.lua")'`, which -applies the same checks and raises instead of half-applying. Without -`HYPRLAND_INSTANCE_SIGNATURE` reload does nothing and the next compositor start -reads the file. A full `hyprctl reload` was rejected: it drops an unconfirmed -display arrangement and would re-run the whole configuration on every -wallpaper change in wallpaper mode. - -**`user.lua` keeps winning.** It loads after `looknfeel.lua`, so at config load -its colours override the theme's. For the live path, `looknfeel.lua` records -what each theme key holds (`hl.get_config`) straight after it applies the -theme, before `user.lua` runs. A live change applies a key only if it still -holds that value; a key that `user.lua` — or a manual `hyprctl eval` — has set -since is left alone until the next config reload, which takes a fresh -baseline. A user value identical to the theme's is indistinguishable from it -and follows the theme. - -### GTK - -`scripts/theme_gtk.py` sets four `org.gnome.desktop.interface` keys with -`gsettings`, and records them in `gtk.json`: - -| Key | Value | -| --- | --- | -| `color-scheme` | `prefer-dark` or `prefer-light`, from `mode` | -| `gtk-theme` | `adw-gtk3-dark` or `adw-gtk3` (package `adw-gtk3-theme`), so GTK 3 applications match libadwaita ones | -| `accent-color` | the libadwaita accent (GNOME 47) nearest to `colors.accent` by hue; an accent with too little colour for a hue becomes `slate`. Skipped when the installed schema has no such key | -| `text-scaling-factor` | `theme_tokens.text_scale()` to two decimals: `1.00` at the shell's default text size (see [Text size](#text-size)) | - -Running GTK 3 and libadwaita applications follow the change, and so do -Firefox, Chromium and Electron applications, which read the same keys through -the settings portal (xdg-desktop-portal-gtk). `gtk.json` is only the record of -what was last applied: the keys are set again only when it changes, so after -changing one by hand run `cybexos-runtime ipc theme apply` to restore the -shell's values. - -`text-scaling-factor` is the one key people also set elsewhere (GNOME Tweaks, -an accessibility guide), so the shell only replaces a value it set itself -(recorded in `gtk-text-scale`) or GNOME's default 1.0. A value of the user's -own wins, including over `theme apply` and on the first export after an -upgrade, until `gsettings reset org.gnome.desktop.interface -text-scaling-factor` hands the key back. - -The shell owns the other keys. The converge only gives a key still at its schema -default (`color-scheme` `default`, `gtk-theme` `Adwaita`) the dark default a -fresh install's first session shows until the shell has applied its tokens, -so re-running Ansible never undoes a choice made in Settings. - -`GTK_THEME` is no longer set in `~/.config/environment.d`: it overrides -`gtk-theme` for GTK 3 and forces libadwaita's stylesheet, so no mode change -could reach either. The user manager lingers and read that file when it -started, so the converge also removes the old `GTK_THEME=adw-gtk3-dark` from -it (`unset-environment`, then a `daemon-reload`, because a value that came -from environment.d survives the unset alone) and restarts the GTK portal, which -draws every application's file chooser; the shell and the applications -it starts lose the variable at the next login (or -`systemctl --user restart quickshell.service`). Until then those applications -stay dark whatever the mode. The session bus copied the old environment when it -started, and it lingers too, so the few D-Bus-activated applications that are -not started through systemd keep the pin until the next reboot. - -### Lock screen (hyprlock) - -`scripts/theme_hyprlock.py` renders `hyprlock.conf`: the vendor layout (clock, -date, password field and the fingerprint prompt under it) in `font.ui`, sized -by the shell's text size (the clock's 84, the date's 24, the 300×54 field and -the gaps between them all scale together, so larger text never overlaps), with -the clock in `text`, the date, the field's placeholder and the fingerprint -prompt in `textMuted`, the field filled with `surface` at 85% and ringed with -`stroke`, turning `accent` while checking and `red` on a failure. - -With a wallpaper, the background is that image, blurred (3 passes of size 8, -enough to reduce it to soft fields of colour) under a full-screen scrim of -`background`, with `background` as the colour hyprlock falls back to if the -image cannot be loaded. The scrim does all of the dimming: hyprlock's -`brightness` only darkens, which would sink the light mode's dark text, and -its `contrast` curve never moves pure black or white, so both are set to 1.0. -The scrim's opacity is the least step from 50% to 90% that keeps `text` at -4.5:1 over a pure black *and* a pure white wallpaper (65% for the fixed dark -palette, 60% for the light one), and every text colour is then moved toward -black or white where needed to reach 4.5:1 on the worse of those two -backdrops, and on the field's fill over them. In the dark mode that lifts the -date nearly to `text`; the size difference carries the hierarchy. Without a -wallpaper, or with a file name hyprlang would misread (`#`, `$`, braces or -edge spaces), the background is plain `background`. - -`reload` does nothing: hyprlock runs only while the session is locked and reads -its configuration at every lock, so the next lock shows the new look and one -already on screen keeps the look it started with. - -`cybexos-runtime exec hyprlock` (the `cybexos-session-lock.service` command) -picks the configuration in this order: - -1. `~/.config/cybexos/hypr/hyprlock.conf`, when it is a regular file; -2. the rendered `$XDG_STATE_HOME/cybexos/theme/hyprlock.conf` (default - `~/.local/state/…`), when it is a non-empty regular file, not a link, whose - first line is the renderer's header; -3. the vendor `~/.local/share/cybexos/runtime/hypr/hyprlock.conf`. - -For the second and third, it then adds the sign-in factors (fingerprint and -security keys) at lock time; see [Fingerprints and security keys](security.md). - -The vendor file is the renderer's output for the fixed dark palette without a -wallpaper, so the lock screen looks the same before the shell's first export; -`tests/quickshell/system-theme-hyprlock.test.cjs` keeps the two equal. A -damaged rendered file cannot prevent locking: the renderer replaces it -atomically, and hyprlock ignores entries it cannot parse rather than refusing -to lock. - -## Adding a target - -A target is a `scripts/theme_.py` module listed in `TARGETS` in -`theme-apply.py`, with `NAME`, a deterministic `render(tokens)` returning -`{file name: content}` (at least one file), and `reload(tokens, directory)`, -which raises with a short, readable message on failure. Reload commands must be -resolved through `PATH`: the tests run every target with a PATH of stub -commands, so they can never signal or reconfigure the real desktop. diff --git a/docs/t3-composer-manual-verification.md b/docs/t3-composer-manual-verification.md deleted file mode 100644 index 19c41b0b..00000000 --- a/docs/t3-composer-manual-verification.md +++ /dev/null @@ -1,65 +0,0 @@ -# T3 composer manual verification - -Use a connected T3 Code server with at least one provider that advertises a -select trait and a boolean trait. - -## Disclosure and layout - -- [ ] Open an existing thread and New Thread. Confirm each uses one rounded - glass composer: prompt above, compact model/reasoning/access controls below, - and a circular send arrow at the lower right. -- [ ] Open New Thread and confirm the prompt starts focused with its placeholder - still visible, typing lands immediately, and `Escape` still returns to the - inbox. -- [ ] Activate the tune/summary row and confirm the Run settings drawer attaches - immediately above the composer. Leave the page, reopen it, and confirm the - drawer resets to collapsed instead of persisting the previous choice. -- [ ] Confirm the toolbar stays one row: model text elides before access text, - while the tune and send/stop actions never clip. Run settings must have a - Tabler close control rather than a typographic chevron. -- [ ] Confirm `Full access` is amber in both the toolbar chip and expanded Access - picker. Below 360px of effective composer width, confirm reasoning moves into - Run settings while model and access remain available in the toolbar and its - accessible description. -- [ ] On New Thread, confirm Project appears as a compact shoulder directly - above the composer rather than as a separate full-page form section. -- [ ] Confirm expanded controls use the wider T3 panel's available columns and - stack without horizontal clipping on an output-constrained width. On a short - screen, confirm the content-sized panel stops at the output cap and its form, - transcript, attachment, and picker scroll areas remain usable. - -## Input and state - -- [ ] Toggle the settings row with the mouse, then use `Tab`, `Enter`, and - `Space`; confirm keyboard focus has a visible ring and the drawer follows the - state. -- [ ] Repeat while the composer is read-only, running, or sending; the header - must still open for inspection while each setting remains disabled. -- [ ] Tab through provider, model, access, mode, select traits, boolean traits, - prompt, and Send. Exercise picker open/close, arrow-key selection, and - `Escape`. -- [ ] Open every picker in both New Thread and an existing thread. Confirm its - menu is opaque and stays above the other settings, prompt, buttons, and error - text; opening a second picker must close the first. Reopen it and click - elsewhere in the drawer without selecting an option; the menu must close and - the underlying control must still receive that click. Selected options should - show a check and the first nine choices should show shortcut numbers. -- [ ] Test idle, running, read-only, sending, plan-ready, and provider-locked - threads. Confirm the prior enablement, model-locking, prompt, and send behavior - is unchanged. -- [ ] Change provider/model and confirm the traits and summary chips update. - Confirm Mode only appears when supported, and exercise every advertised - select and boolean trait. -- [ ] Select Ultrathink and confirm prompt injection and the prompt highlight - still work. Trigger a trait validation error and confirm it remains visible in - the open settings drawer. -- [ ] On a thread with an approval or structured question, confirm the request - is in a bounded drawer immediately above the composer—not in the transcript— - and that overflow remains scrollable. Repeat with a ready plan. - -## Automated and runtime checks - -- [ ] Run `tests/run` (Node tests plus qmllint over the whole shell). -- [ ] Deploy through the Quickshell Ansible tag with `qs_live_begin` / - `qs_live_end`; inspect that service invocation's journal for QML load - failures, syntax errors, binding loops, invalid sizing, and oversized buffers. diff --git a/docs/t3-git-actions-manual-verification.md b/docs/t3-git-actions-manual-verification.md deleted file mode 100644 index 6761d07c..00000000 --- a/docs/t3-git-actions-manual-verification.md +++ /dev/null @@ -1,94 +0,0 @@ -# T3 git actions manual verification - -Use a connected T3 Code server whose token has `orchestration:operate`, plus -threads whose project checkouts can be put into each git state below. - -## Dedicated panel layout and navigation - -- [ ] Confirm T3 opens as its own 520px attached panel under the T3 widget, - separate from the six status tabs and bounded by the output on a narrow - screen. -- [ ] Test both short and long inboxes: a short inbox hugs its visible rows and - footer, while a long inbox stops at the usable-height cap. Search remains - fixed whenever threads exist, the grouped list alone scrolls, and the - connection/status footer stays at the bottom. -- [ ] Open short and long conversations. The contextual header stays fixed, - the transcript takes the remaining height, and approvals, ready plans, task - progress, and the composer remain docked at the bottom. -- [ ] Exercise New Thread, Project, model, access, and Run settings. Menus stay - within the panel; right-side menus open inward; New - Thread scrolls to reserved popup room without growing a detached tail. At - 320px reasoning lives in Run settings, model text elides before access text, - and the tune and send/stop actions remain reachable. -- [ ] Check ready plans, approvals, task progress, signed-out, disconnected, - and read-only states at normal and output-constrained widths. Drafts, lifecycle - actions, authentication, and Git behavior must remain unchanged. -- [ ] Verify pointer and keyboard focus, then press Escape repeatedly: close - the active connection/picker/menu layer first, return from Thread or New - Thread to Inbox second, and close the drawer through the host last. -- [ ] Open with `cybexos-runtime ipc popouts toggle t3code`, then hover - between T3 and status-drawer glyphs. T3 keeps its own source and navigation - while the existing hover transition and held-state behavior remain intact. - -## Visibility (options must only appear when they apply) - -- [ ] Open a thread whose checkout has uncommitted changes: the ellipsis menu - shows only `Commit & Push` (plus `View PR ↗` if a PR exists for the branch). -- [ ] Open a thread whose checkout is clean but ahead of upstream: the ellipsis - menu shows `Push` and not `Commit & Push`. -- [ ] Open a thread whose checkout is clean and up to date with no PR: no Git - status, action, card, or tile is visible. -- [ ] Open a thread whose branch has an open PR: `View PR ↗` appears in the - ellipsis menu; it opens the PR in the browser and closes the drawer. -- [ ] Open a thread whose project folder is not a git repository: no Git UI is - shown. -- [ ] With a read-only pairing (token without `orchestration:operate`), confirm - `Commit & Push`/`Push` are hidden while `View PR ↗` still works. - -## Commit & Push (defaults to the current branch — main) - -- [ ] Trigger `Commit & Push` on a dirty checkout on `main`: the menu closes and - the compact line below the header follows the live phase (`Generating commit - message…`, then the push phase); generation longer than 15 s must NOT time - out (budget is 120 s, sliding). -- [ ] On success the compact line shows `Committed · — pushed - to main` for about six seconds, then disappears. Reopen the menu and confirm - `Commit & Push` is gone. Verify on the server that the commit landed on - `main` (no feature branch was created). -- [ ] Trigger `Push` on a clean-but-ahead checkout: the success line appears, - then `Push` hides after the status refresh. -- [ ] Repo with a pre-commit hook: the compact line shows `Running …` - while it runs; a failing hook leaves its message visible in red. -- [ ] Navigate to the inbox mid-action and back: the action completes (it is - not interrupted) and the refreshed status reflects the result. -- [ ] Disconnect (stop the server or drop the network) mid-action: the compact - line shows `Disconnected before confirmation`, the pending state clears, and - the server-side push still completes; reopening the thread shows clean status. - -## Status refresh and errors - -- [ ] The status refreshes when a turn finishes with a ready checkpoint and - after every git action; reopening a thread within 10 s reuses the last fetch. -- [ ] Point a project at a deleted folder: the compact error line shows the - fetch error with a `Retry` action that re-requests the status. - -## Activity and changed files - -- [ ] Confirm routine latest-activity summaries no longer produce a card or - tile; messages and the existing Working row remain unchanged. -- [ ] Trigger a session/activity failure and confirm only a compact red inline - error appears in the timeline. -- [ ] Confirm a ready checkpoint with no changed files produces no UI. -- [ ] Confirm a checkpoint with changes produces one collapsed - `N changed file(s) · +A −D` row. Expand it, verify the diff loads lazily, then - exercise collapse, copy, retry, truncated-preview, and full-client actions. -- [ ] Switch threads or receive a newer checkpoint and confirm the expanded - diff state resets. - -## Automated and runtime checks - -- [ ] Run `tests/run` (Node tests plus qmllint over the whole shell). -- [ ] Deploy through the Quickshell Ansible tag, wrapping the entire live check - with `qs_live_begin` / `qs_live_end`. Inspect that service invocation's - journal for QML load failures, syntax errors, and binding loops; open the - drawer with `cybexos-runtime ipc popouts toggle t3code`. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 00000000..42809116 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,82 @@ +# Independent updates + +The machine owns `/etc/nixos/flake.nix` and `flake.lock`: + +```nix +inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; +inputs.nerdos.url = "github:DigitalPals/NerdOS/EXACT_AUTHENTICATED_REVISION"; +inputs.nerdos.inputs.nixpkgs.follows = "nixpkgs"; +``` + +Add personal inputs explicitly and make their Nixpkgs follow the same root. +The updater rejects duplicate Nixpkgs universes and unrelated lock changes. +Upstream binaries use `cache.nixos.org`; no extra cache key is implicitly trusted. +The 26.05 package set needs a narrowly scoped Hyprland 0.56.2/hyprutils 0.14 +compatibility overlay for the existing Lua desktop and native titlebars, plus a +scoped build-time Wayland protocol pin and the required Aquamarine source fix. +It uses Nixpkgs recipes and the single machine package universe. `nix/desktop-compatibility.nix` +records exact source identities; remove it after stable catches up and the +runtime qualifies. Other upstream inputs continue to advance independently. + +`nerd update check` checks upstream and NerdOS independently. An unreachable +source is unknown/unavailable, never reported current. Nixpkgs checks identify +available revisions; preview builds expose the actual package closure changes. +The Update drawer has separate Upstream, NerdOS and Both preview actions, +installed/available versions, release notes, durable progress, errors, built +changes, restart requirements and recovery status. + +An upstream discovery failure keeps `upstream.state` at `unreachable` and +explains that the Nixpkgs package-source status is unknown. A confirmed GitHub +API rate-limit response or HTTP 429 suggests retrying later; HTTP 403 without +rate-limit evidence is reported as access denied. Recognized server, DNS, TLS, +connection and timeout errors have separate guidance. Other Nix failures leave +the cause unknown and suggest checking network access and Nix configuration. +Malformed metadata also leaves the status unknown. NerdOS discovery still runs +after an upstream failure, so each source keeps its own result. + +Discovery creates no deployment or transaction log. Its public error includes +this read-only diagnostic command, which can be run locally without elevation: + +```sh +nix flake metadata --refresh --json github:NixOS/nixpkgs/nixos-26.05 +``` + +Checks classify command diagnostics internally and publish only static guidance; +they do not log the raw discovery output. The command above may show private +URLs or configuration details: inspect its output locally and redact secrets +before sharing it. Checks do not retry automatically or require an account or +authentication token. + +The separate **Device firmware…** action opens a terminal, refreshes fwupd's +signed metadata, shows the selected devices and versions, and asks before +installation. The launcher also provides **Review device firmware…**; the CLI +is `nerdos-firmware-update interactive`. fwupd supplies authentication, +signature checks and device progress. Keep external power connected and follow +device-specific instructions. Firmware changes are outside the Nix transaction +and are not reverted by a generation rollback. Reboots remain explicit. + +`nerd update stage upstream|nerdos|both|local` authenticates selected release +metadata before evaluation, snapshots the entire personal configuration and +updates only the chosen inputs. NerdOS-only updates include its source-declared +vendor dependency graph while preserving root upstream/personal inputs. They +never select code from a moving branch. Failed combined previews offer the +explicit `stage upstream` retry retaining installed NerdOS; that combination +must build and pass candidate checks too. + +`nerd update status` returns the built diff, transaction and review token. +`nerd update apply ID TOKEN` runs under a supervised systemd worker and rejects +stale review tokens or intervening personal edits. No widget/module is removed +to make evaluation or runtime checks pass. Actual build progress is phase-based; +private transaction logs hold build diagnostics. After a deliberate reboot and +authenticated desktop login, `nerd update confirm` validates system services, +Hyprland, exactly one managed Quickshell and its current journal. + +A failed deployment is quarantined by complete system identity. Inspect the +cause before using `nerd update retry-failed ID` to explicitly allow another +preview of it. Retry permission covers prior failures of that exact system and +is consumed only after a successful preview. Recovery is separate from authenticated normal updates; a channel +cannot use a lower semantic version as a normal update. + +Advancing 26.05 is routine. Changing the root stable branch is a deliberate major +upgrade and currently requires new compatibility qualification. Never change +`system.stateVersion` as part of a routine update. diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 00000000..b7c3c187 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,111 @@ +# Validation status and release blockers + +The NixOS implementation is unreleased and incomplete. There is no qualified +production release, authenticated public NixOS channel or completed physical +hardware/security qualification. This record describes development work on +2026-10-22; results on earlier source snapshots do not qualify later edits. + +## Evidence boundaries + +| Area | Actually exercised | Remaining work | +| --- | --- | --- | +| Source lifecycle and trust | Python command-boundary fixtures; real isolated Ed25519 signing/verification; input ownership, failed evaluation/build and durable recovery cases | Real signed-delivery/updater matrix on installed machines | +| Desktop source | JavaScript source suites, QML lint, ownership/theme/package tests and C++ notification-image tests passed on development snapshots | Repeat required checks on the final source identity | +| Public plugins | Actual Quickshell engine under isolated headless Sway: API 1 replacement, N/N+1/rollback and all six supported Omarchy kinds | Installed Hyprland session, real player/audio/input and third-party dependency coverage | +| System boot | Real isolated NixOS VM boot; installed password/sudo and SDDM login; Hyprland/titlebars and Quickshell rendering; sole managed shell/current journal health; API 1 personal widget using a machine-added executable; candidate namespace/compositor/QML validation | Repeat on the final source identity; suspend, peripherals and physical authentication | +| Fresh ISO installation | Complete unencrypted and encrypted UEFI ISO installations, LUKS unlock, password login and encryption-gated automatic login in disposable QEMU on development snapshots; exact installed source identity checked | Both cases must pass on the final source identity; actual testing corrected filesystem-type, public mount-ancestor permission and Quickshell IPC path bugs | +| Updates and recovery | Actual local stage/build/apply/trial boot/confirmation; injected activation failure and coherent rollback; real initrd root failure and fallback to the previous EFI default; personal hashes preserved; managed-shell crash safe mode and explicit recovery, on development snapshots | Final same-source runs including upstream-only, NerdOS-only, combined, unavailable/malicious delivery, failed evaluation/build, interrupted staging/application and explicit rollback | +| NerdOS Assistant | Service suite against scripted Claude Code/Codex protocol fakes and a fake speech-dispatcher; actual Quickshell engine and service under isolated headless Sway with a scripted agent (composer, working pill, command and apply approvals, Undo, widget hand-off, logo-only character, box placement); QML lint; system and product closures built; feature combinations evaluated | Installed Hyprland session: Super+I tap and hold, focus grab, multi-display drag, Voxtype push-to-talk, speech-dispatcher replies, grim screen context, polkit apply; real Claude Code and Codex sessions, sign-in errors and resume. The lifecycle VM booted this source and passed its desktop check, then its offline software preview failed: the staged build needs a stdenv the VM does not register, with or without the assistant | +| Hardware | Pinned Intel camera HAL/GStreamer and PSYS packages built; private-ABI parser self-test passed | Actual kernel/private ABI and processed camera frames, both XPS SKUs, NVIDIA, fingerprint/FIDO2, Secure Boot and peripheral/power matrix | +| Release delivery | Publisher-side signature, asset, qualification, sequence and downgrade regression checks | Protected workflow execution, owner keys, immutable publication, public asset verification, Pages promotion and durable custom cache | + +Fixtures simulate command boundaries. They are regression coverage and cannot +establish a successful installation, an applied update or recovery from a real +failed boot. A build proves that a derivation builds; it does not prove that the +resulting compositor, camera, authentication or desktop works. + +Development diagnostics are not substitutes for the final qualification report. +The handoff records the exact source NAR identity, image checksum, completed +tests and failures; inspect that evidence before using a particular image. + +## Implementation and operational blockers + +- Signed installation media, safe Secure Boot enrollment and out-of-tree module + signing remain unfinished. This includes enforcing kernel module trust and + authenticating the external installation payload; signing a boot executable + alone is insufficient. Secure Boot installation refuses before erasure; + disabling firmware verification is not an accepted workaround. +- The desktop requires Hyprland 0.56.2 and native titlebars. The stable Nixpkgs + compositor is older; the narrow compatibility overlay and its dependent native + libraries and titlebars build. Real native configuration verification accepts + the product configuration and rejects an invalid declaration. It also exposed + an upstream verifier/event-loop interaction, now handled without scheduling + runtime timers during parsing. Installed startup/rendering and runtime health + passed on a development snapshot; repeat against the final source identity. +- Mandatory access control coverage remains incomplete. AppArmor enablement + and the exact Chromium namespace exception do not reproduce the prior + enforcing policy. Qualify enforced native service/application confinement + and negative access tests, alongside packet-level firewall tests. +- Production publisher trust is deliberately empty. An owner must provision + real public keys, publish an authenticated exact revision and configure + protected GitHub environments and immutable Releases. Do not substitute a + development key or a moving branch. +- T3 Code's native wrapped AppImage builds but remains runtime-unqualified. + Cisco IKEv1/IPsec uses the maintained Nixpkgs Libreswan plugin rather than the + archived, vulnerable vpnc plugin. Real gateway authentication/routing/DNS and + all declared product/hardware features still require qualification. +- Type 1 systemd-boot entries and Type 2 UKI/Lanzaboote entries have native boot + selection support with source regression coverage. Actual UKI trial boots, + signing, failed-boot recovery and rollback remain unqualified. +- No application-data migration engine ships. Reviewed transaction-root pruning + and weekly cleanup enabled by default are implemented; real bootloader + interruption and timer qualification remain outstanding (see the + [development checks](measurements/software-defaults-cleanup-2026-10-23.md)). + Persistent state requires backups; retained Nix generations do not + roll back documents, VPN credentials, plugin data or service databases. +- No durable public NerdOS binary cache is configured. Public cache hosting, + explicit cache trust and redistribution review remain release work. +- PXE delivery uses the Beast (`10.10.0.7`), independently of the development + host. A copied ISO is not advertised as PXE-ready until checksum verification, + refresh, running service and image-list checks succeed there. + +1Password CLI/Polkit integration, connected-account services, the Hermes bridge, encrypted-backup timer, +external-monitor lid handling, Voxtype and the required offline dictation model +have native declarations. Their installed interactions still require testing. + +The native Linux ChatGPT derivation builds with strict ELF dependency checks; +its version command and bundled Codex/Node commands ran in the isolated build +container. This does not qualify interactive sign-in, Chromium sandbox startup, +remote control or application use on NixOS. The official upstream distributions +do not include NixOS in their formal preview support list. + +The desktop package also checks its actual wrapped Python and GI dependencies +under an empty environment: network/account/calendar/firmware libraries, schemas, +GIO TLS, Pillow, D-Bus and WebSockets. This caught and corrected plain-Python +shadowing and missing typelibs before installation qualification. + +## Qualification procedure + +Run `nix develop -c tests/run`, `nix develop -c tests/qml-lint` and +`nix develop -c python3 -B tests/user-plugins.py` in an isolated environment, +then evaluate/build the relevant flake checks and ISO. Run desktop engines +sequentially and obey [the Quickshell safeguards](../AGENTS.md). + +`scripts/qualify-installations` boots the built ISO and exercises the actual +fresh installer, installed boot/login and local staged updater in disposable +UEFI VMs. Its evidence is intentionally labelled +`isolated-installation-evidence`; it cannot satisfy complete release +qualification. Exercise both `--case plain` and `--case encrypted`, retain source +revision/NAR and image hash, and report a failure as a failure. + +Extend real installed-system testing through all independent update scopes, +unavailable and malicious delivery, failed evaluation/build, interrupted +application, trial boot, explicit confirmation and coherent rollback. Keep +sentinels for personal packages, VPNs, services, widget code/placement/preferences, +plugin state and service data, and check them after updates and rollback. +Hardware/security checks require representative physical machines. + +Only a complete same-revision `installation-update-and-physical-qualification` +report containing every check required by `release/manage.py` passes the release +gate. See [release operations](releasing.md) for trust and publication. Do not +promote the development evidence in this document to a production-readiness claim. diff --git a/docs/xps-2026-hardware.md b/docs/xps-2026-hardware.md deleted file mode 100644 index 46f1a7f5..00000000 --- a/docs/xps-2026-hardware.md +++ /dev/null @@ -1,346 +0,0 @@ -# Dell XPS 2026 / Panther Lake hardware support - -The `xps-2026` role is intentionally narrow. It runs only when DMI reports -Dell, XPS 14/16 or DA14260/DA16260, SKU `0DB9` or `0DBA`, and a Panther Lake -Core Ultra CPU. Camera and touchpad work have additional ACPI, PCI, and I2C -gates. It does not apply these settings to other Dell laptops. - -Fedora's stock kernel remains the only kernel. This role does not build or -install a kernel, `linux-ptl`, an i915/xe/DRM patch, `fred=on`, or a global -Wi-Fi 7/EHT workaround. - -## Internal OLED backlight - -The LG 2880x1800 OLED panel with EDID product `30e4` accepts brightness writes -through the Xe driver's default interface without changing its luminance; only -the separate zero/off operation has an effect. The same XPS chassis can contain -an IPS panel, so the role reads the internal eDP EDID and limits the workaround -to that exact OLED product. - -For the affected panel, every installed Fedora boot entry receives -`xe.enable_dpcd_backlight=1` through `grubby`. This makes Xe probe the standard -VESA DPCD path, which the panel implements correctly. The kernel log's generic -suggestion of `i915.enable_dpcd_backlight=3` is not used: Panther Lake runs the -`xe` module here, and forcing the Intel HDR path leaves this panel frozen. -`grubby --update-kernel=ALL` also updates `/etc/default/grub` and -`/etc/kernel/cmdline`, so later Fedora kernel entries inherit the argument. - -The change takes effect only when Xe next loads. After applying the role, -reboot normally and verify: - -```bash -grep -o 'xe.enable_dpcd_backlight=[^ ]*' /proc/cmdline -sudo cat /sys/module/xe/parameters/enable_dpcd_backlight -brightnessctl set 40% -``` - -The first two commands should report `xe.enable_dpcd_backlight=1` and `1`; the -last should visibly change panel luminance. - -## Internal-panel black flashes - -On the XPS 14 DA14260 (SKU `0DB9`) with LGD product `0x07c6`, Fedora kernel -`7.2.7-200.fc44` exhibited repeated full-screen black flashes at 120 Hz. -The panel reported a PSR link CRC error, PSR2 selective fetch was enabled, -and the boot journal recorded `Selective fetch area calculation failed in -pipe A`. Disabling PSR live stopped the flashes without changing brightness, -resolution or refresh rate. This is separate from an absent Quickshell -wallpaper when the desktop session target has not started. - -The shared hardware role limits `xe.enable_psr=0` to this SKU and the full -EDID manufacturer/product bytes `30e4c607`. Both checkout provisioning and -ISO hardware setup use this task. `grubby --update-kernel=ALL` applies it to -installed kernels and the defaults for future kernels. Disabling PSR costs -some idle display power; it retains 120 Hz. The `display` tag applies only -this quirk, after the role's read-only hardware detection: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags display --check --diff -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags display -``` - -The boot argument takes effect on the next normal boot. On a running system, -`/sys/kernel/debug/dri/0/i915_edp_psr_status` reports the active PSR mode -(the debugfs name is retained by the Xe driver). During diagnosis, writing -`1` to `i915_edp_psr_debug` disables PSR for the current boot; `0` restores -the driver's default. Verify the DRM device before using either path. - -Keep `xps_2026_psr_disabled_panels: []` in the saved Ansible configuration to -opt out of further enforcement when evaluating a driver fix, then remove -`xe.enable_psr` with `grubby --update-kernel=ALL --remove-args=xe.enable_psr`. -No other panel or machine receives the workaround. - -## Internal speakers - -SKU `0DB9` (plus Quattro-listed XPS 16 SKU `0DBA`) gets Omarchy Quattro's current -13-biquad-per-channel profile and stereo lookahead limiter. A separate user -PipeWire process hosts the graph, so a broken optional profile cannot prevent -the normal PipeWire daemon from starting. Its output is pinned to the exact -`sof_sdw` internal-speaker sink. Headphones, Bluetooth, HDMI/DP, USB, and -network sinks bypass it. At login the manager waits for four consecutive -observations of the physical speaker, avoiding WirePlumber's initial device -enumeration churn without masking a genuinely absent sink. - -The XPS 14 profile was measured on `0DB9`; `0DBA` is included on the same basis -as Quattro and has not been independently measured here. Fedora provides the -filter-chain LV2 loader and `lsp-plugins-lv2` limiter. Desktop volume keys and -the Quickshell audio UI control the physical speaker gain while the virtual -filter master stays at unity. - -Useful checks: - -```bash -systemctl --user status xps-speaker-tuning -/usr/local/libexec/xps-speaker-tuning check -pactl list sinks short -journalctl --user -u xps-speaker-tuning -b -``` - -## Optional fingerprint reader - -Fingerprint support itself is no longer XPS-specific: every installation gets -Fedora's `fprintd`/`fprintd-pam` and `authselect`'s `with-fingerprint`, and -Settings > Security manages enrolment (see [Fingerprints and security -keys](security.md)). Without a reader or an enrolled finger, every prompt asks -for the password exactly as before. - -What remains in this role is the hardware gate. The detector trusts explicit -fingerprint/biometric product descriptors and uses a conservative, unbound-USB -vendor fallback. It explicitly rejects Synaptics `06cb:0701` (SVP7500), which -is the IPU7 camera's USBIO bridge on the tested XPS 14 rather than a fingerprint -reader. The tested SKU `0DB9` exposes that bridge but no fingerprint device to -current `libfprint`, so Settings > Security reports that no reader was found. - -When a precisely matched XPS build has a possible reader, the role installs the -`xps-fingerprint-check` diagnostic and asks fprintd over D-Bus whether the -current libfprint release supports the device; if not, it reports that -password authentication is unchanged. It never edits PAM files and enrols -nothing. Existing installations keep their `with-fingerprint` state; the -shared policy only adds the feature where it was absent, once. - -Enrol in Settings > Security, or interactively: - -```bash -fprintd-enroll -fprintd-list "$USER" -fprintd-verify -authselect current --raw -/usr/local/libexec/xps-fingerprint-check "$USER" -``` - -SDDM login stays password-only, so the login keyring is unlocked as before. -The lock screen uses hyprlock's own fprintd check beside a fingerprint-free PAM -service, and sudo and Polkit use Fedora's `system-auth` with password fallback. -Fedora has no stock `authselect` feature for inserting a lid-state command into -PAM, so the role does not copy Omarchy's custom clamshell helper; with the lid -closed, the reader times out and the password is asked. - -## OVTI08F4 / IPU7 camera - -The camera role additionally requires the `OVTI08F4` ACPI device and Intel PCI -ID `8086:b05d`. Fedora 44 supplies the stock IPU7 base/ISYS modules, IPU bridge, -OV08X40 sensor driver, and `intel/ipu/ipu7ptl_fw.bin`. Those remain owned by the -normal Fedora kernel and `intel-vsc-firmware` packages. Verification rejects an -out-of-tree replacement for either `intel_ipu7` or `intel_ipu7_isys`. - -Missing hardware companions use DKMS: - -- Intel IPU7 PSYS, compiled in PSYS-only mode against Fedora's stock core ABI. -- Intel CVS only on kernels which do not supply it. Fedora's native CVS is - retained when present; its runtime PM and media bridge manage camera - ownership. The legacy CVS DKMS build refuses to shadow a native module. - -RPM Fusion's `akmod-v4l2loopback` supplies the third out-of-tree module. Intel's -HAL, redistributable IPU75XA libraries, and `icamerasrc` are built into the -local `xps-ipu7-camera-stack` RPM. `v4l2-relayd` publishes their processed NV12 -stream as `/dev/video50`; WirePlumber hides raw Bayer ISYS nodes and the -duplicate generic libcamera endpoint, then exposes only **Hardware ISP -Camera** to PipeWire. Brave is launched with `PipeWireCamera` enabled. - -The source inputs are immutable archives with SHA-256 checksums in -[`roles/xps-2026/defaults/main.yml`](../roles/xps-2026/defaults/main.yml): - -| Component | Pinned commit | Why it is needed | -| --- | --- | --- | -| [intel/ipu7-drivers](https://github.com/intel/ipu7-drivers) | `a88b19096a738d0708742a78d6540d6d4a3021ff` | PSYS companion absent from Fedora's stock module set | -| [intel/vision-drivers](https://github.com/intel/vision-drivers) | `a8d772f261bc90376944956b7bfd49b325ffa2f2` | Panther Lake CVS ownership driver | -| [intel/ipu7-camera-bins](https://github.com/intel/ipu7-camera-bins) | `403c67db6b279dd02752f11db6a34552f31a3ac5` | Intel's redistributable IPU75XA userspace interface | -| [intel/ipu7-camera-hal](https://github.com/intel/ipu7-camera-hal) | `b1f6ebef12111fb5da0133b144d69dd9b001836c` | Hardware ISP HAL and sensor configuration | -| [intel/icamerasrc](https://github.com/intel/icamerasrc) | `4fb31db76b618aae72184c59314b839dedb42689` | GStreamer source consumed by `v4l2-relayd` | - -The PSYS patch is deliberately limited to the companion module: it removes an -out-of-tree debugfs structure member that is not present in Linux 7.1's stock -IPU7 core and prevents DKMS from compiling or installing base/ISYS. Before each -DKMS build and camera start, `xps-ipu7-abi-check` reads Fedora's in-tree module -BTF with `pahole` and compares the private `ipu7_device`, `ipu7_bus_device`, and -`ipu7_psys_pdata` layouts that PSYS dereferences. This structural guard is not -tied to an exact kernel version. If Fedora changes that internal ABI, it emits -`IPU7_STOCK_ABI_CHANGED` and refuses the optional camera build/start while -leaving the stock modules and normal kernel update path untouched. -The guard also covers callback, shared queue, and firmware boot structures. -The reviewed Fedora 7.2 layout accounts for the public `auxiliary_device` -growing by eight bytes: the PSYS build uses the target kernel headers and the -private structure offsets are checked as a complete signature. Unrecognized -layouts still fail closed. It refuses PSYS DKMS if Fedora starts shipping -PSYS itself. Native CVS does not require replacement or an extra reboot when -installing PSYS for the first time. - -With native CVS the graph is `OV08X40 -> Intel CVS -> IPU7 CSI2`. A narrow HAL -patch resolves the sensor's actual I2C address across this bridge and programs -its sink/source formats and links. Older direct sensor-to-CSI configurations -remain supported. Merely registering the sensor does not prove frame capture; -validate processed frames after installation. - -The complete build-input hash is embedded in the RPM release, its installed -manifest, and both DKMS package versions. Consequently, changing a source pin, -patch, ABI guard, spec, or build recipe while retaining the human bundle -version still creates fresh PSYS/CVS registrations and forces real module -builds. Before replacing a healthy stack, the builder retains one checksum- -verified copy of the exact installed RPM. Immediately before any DNF operation -that would change NEVRA, the role re-verifies that snapshot against the healthy -installed identity. This is a hard precondition: an interrupted rerun with a new -cached RPM and an old installed RPM may use the already durable snapshot, but it -cannot proceed when that snapshot is absent or mismatched. - -The role then writes and syncs `/var/lib/xps-hardware/ipu7/transaction.in-progress` -before removing the relay trigger and stopping/disabling camera exposure. Both -the init unit and relay refuse to run while that marker exists. A successful new -stack is validated against its exact manifest before the committed identity is -written and synced; only then is the in-progress marker removed. If older IPU -modules are already loaded, the target remains uncommitted, hidden, and disabled -through one normal reboot. Rerunning the camera tag after that reboot proves the -new on-disk modules are now the boot boundary, then commits and exposes them. A -failed userspace, akmods, or DKMS refresh reinstalls the snapshot, force-builds -both prior DKMS modules, and performs the same exact validation before clearing -the marker. The init helper also compares the installed identity with -`committed-identity`, so interruption, marker corruption, or an unprovable -rollback leaves the relay hidden and camera service stopped rather than exposing -a mixed stack. - -On a later Ansible invocation, the role recovers the prior identity, target -identity, target NEVRA, and exact prior NEVRA from the checksummed marker and -rollback snapshot; it does not infer them from whether systemd remains enabled. -The builder refuses changed input hashes while that transaction is unresolved -and never rotates its recorded rollback RPM. This permits an exact rollback -after interruption at the exposure, DNF, or DKMS boundary without letting a -missing snapshot reopen package installation. Historic target NEVRAs are -validated against the role-owned package/release shape and their recorded -manifest prefix, not the repository's current bundle version, so a legitimate -bundle bump cannot make an interrupted prior transaction unrecoverable. - -The local RPM is also validated before packaging. Every staged `libcamhal`, HAL -plugin, `libgsticamerasrc`, private icamerasrc interface, and included IPU75XA -binary library is inspected with `readelf` and `ldd`. Missing dependencies fail -the build, and private dependencies must resolve inside the new stage rather -than through an older installed camera RPM. The validator also requires the -OV08X40 AIQB, graph binary, sensor JSON, and common HAL configuration under -`/etc/camera/ipu75xa`. Runtime checks repeat the closure validation. Camera -source, ABI, dependency, or build failures remain non-fatal to the rest of -Ansible and are recorded in `/var/lib/xps-hardware/ipu7/build-failed.log` or -`/var/lib/xps-hardware/ipu7/abi-failed.log` or -`/var/lib/xps-hardware/ipu7/dkms-failed.log`. The ABI preflight runs before the -role prunes or upgrades any previously installed camera DKMS bundle. - -### Secure Boot and kernel updates - -DKMS and RPM Fusion akmods share `/etc/pki/akmods`. On a machine with an -NVIDIA GPU, the [NVIDIA role](nvidia.md) queues this key's enrollment for you; -one enrollment covers both. Otherwise, with Secure Boot enabled, enroll that -public key once: - -```bash -sudo mokutil --import /etc/pki/akmods/certs/public_key.der -``` - -Choose a temporary password, reboot, and use the firmware MOK manager to enroll -the key. Then rerun the role from the active release: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags xps-2026 -``` - -The role does not disable Secure Boot. A DKMS update made while old camera -modules are loaded is not force-unloaded: the camera and relay stay disabled -behind the transaction marker. Reboot normally, then rerun the same command; -that post-reboot pass validates, commits, and starts the new signed modules. - -`kernel-devel-matched` keeps builds tied to Fedora's installed kernel. Diagnose -the complete path with: - -```bash -dkms status -modinfo -n intel_ipu7 intel_ipu7_isys intel_ipu7_psys intel_cvs -systemctl status xps-ipu7-camera xps-ipu7-camera-init v4l2-relayd@ipu7 -journalctl -k -b | grep -Ei 'ipu7|intel_cvs|ov08x40' -/usr/local/libexec/xps-ipu7-abi-check "$(uname -r)" -/usr/local/libexec/xps-ipu7-userspace-check -/usr/local/libexec/xps-ipu7-camera-check -``` - -The normal check does not turn on the camera. To request a short three-frame -smoke test explicitly: - -```bash -XPS_CAMERA_FRAME_TEST=1 /usr/local/libexec/xps-ipu7-camera-check -``` - -The frame check skips the relay's initial splash buffers and verifies three -complete, changing images in memory; it saves no images. An identical/covered -scene can make this proof inconclusive. The relay disables its producer's -last-sample retention so GStreamer mmap consumers copy and immediately requeue -loopback buffers across the splash-to-camera transition. - -The Fedora package's unused generic `icamerasrc` generator trigger is removed -on this hardware; the role's `ipu7` relay is the only camera relay instance. -The suspend hook stops only the userspace relay before sleep and restarts it -through a managed, restartable oneshot service after resume. A pending resume -job is cancelled before another sleep transition, so rapid suspend cycles and -suspend-then-hibernate cannot collide on a transient unit name. The hook avoids -fragile module unload/reprobe operations. - -## Haptic touchpad - -The existing Fedora controller power rules are retained. The hardware-specific -daemon now pairs the matching 06CB input and hidraw devices, applies both the -surface/physical-button feature and a configurable intensity, retries across -device disappearance/re-enumeration, and reapplies after physical clicks. It -rereads its configuration on SIGHUP (`systemctl reload xps-haptic-touchpad`) -or a restart; a converge that changes the configuration restarts it. It reads -HID features back when the transport supports that operation; this XPS -firmware may expose them as write-only. - -Set `xps_2026_haptic_intensity` to `low`, `mid`, or `high` in inventory. These -map to the controller's values 10, 50, and 100. Check it with: - -```bash -systemctl status xps-haptic-touchpad -sudo /usr/local/libexec/xps-haptic-touchpad --check -journalctl -u xps-haptic-touchpad -b -``` - -## Media, firmware, and wireless regulation - -The role explicitly installs SOF/audio, Intel GPU/VSC/Wi-Fi firmware, the Intel -media driver, oneVPL's `libvpl` dispatcher and `intel-vpl-gpu-rt`, and -`wireless-regdb`. It does not add `libvpl-devel` solely for runtime support; -source-build dependencies for the hardware-gated camera RPM are installed only -when that camera is detected. - -Fedora's `setregdomain` (resolved through `PATH`, currently packaged under -`/usr/bin`) derives the regulatory country from the configured -`Europe/Amsterdam` timezone; no Arch-style override is installed. -`./verify` requires the effective global and self-managed phy domains to be -`NL`. Wi-Fi 7/EHT remains enabled. - -```bash -command -v setregdomain -iw reg get -``` - -## Intentional stock-kernel gap - -Omarchy Quattro's Panther Lake kernel and its three display patches are not -ported. Until equivalent code reaches Fedora's normal kernel, Panel Replay and -VRR behavior may lag Quattro. Closing that display-only gap would require the -custom kernel work explicitly excluded by this repository; speaker, camera, -touchpad, media firmware, and regulatory support do not require it. diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..e7f6e1e3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,175 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1754269165, + "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", + "owner": "ipetkov", + "repo": "crane", + "rev": "444e81206df3f7d92780680e45858e31d2f07a08", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1754091436, + "narHash": "sha256-XKqDMN1/Qj1DKivQvscI4vmHfDfvYR2pfuFOJiCeewM=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "67df8c627c2c39c41dbec76a1f201929929ab0bd", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "lanzaboote", + "pre-commit-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "lanzaboote": { + "inputs": { + "crane": "crane", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": [ + "nixpkgs" + ], + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1762205063, + "narHash": "sha256-If6vQ+KvtKs3ARBO9G3l+4wFSCYtRBrwX1z+I+B61wQ=", + "owner": "nix-community", + "repo": "lanzaboote", + "rev": "88b8a563ff5704f4e8d8e5118fb911fa2110ca05", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "v0.4.3", + "repo": "lanzaboote", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1792615934, + "narHash": "sha256-MlvRfa1O5Cqc2/a+gG+l9TSuowEoCKCmrJ1d9oAVm0I=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks-nix": { + "inputs": { + "flake-compat": [ + "lanzaboote", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1750779888, + "narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "lanzaboote": "lanzaboote", + "nixpkgs": "nixpkgs" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1761791894, + "narHash": "sha256-myRIDh+PxaREz+z9LzbqBJF+SnTFJwkthKDX9zMyddY=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "59c45eb69d9222a4362673141e00ff77842cd219", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..982cc199 --- /dev/null +++ b/flake.nix @@ -0,0 +1,149 @@ +{ + description = "NerdOS: a personal, independently updatable NixOS desktop"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + lanzaboote = { + url = "github:nix-community/lanzaboote/v0.4.3"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + outputs = inputs@{ self, nixpkgs, lanzaboote, ... }: + let + systems = [ "x86_64-linux" ]; + each = nixpkgs.lib.genAttrs systems; + # Carry the tested input sources in the live store. A fresh machine starts + # from this lock, without resolving the moving stable branch during install. + inputNodes = builtins.genericClosure { + startSet = map (value: { key = value.outPath; inherit value; }) + (builtins.attrValues (builtins.removeAttrs inputs [ "self" ])); + operator = node: map (value: { key = value.outPath; inherit value; }) + (builtins.attrValues (node.value.inputs or {})); + }; + inputSources = map (node: node.key) inputNodes; + inputNarHashes = map (node: node.value.narHash) inputNodes; + compatibility = import ./nix/desktop-compatibility.nix; + # The default desktop the installation ISO carries as an offline payload. + installerPayload = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ module ./nix/modules/installer-payload.nix ]; + }; + module = { options, lib, ... }: { + imports = [ ./nix/modules lanzaboote.nixosModules.lanzaboote ]; + # NixOS VM tests can supply an already-configured, read-only pkgs. + # Normal installations must receive the same exported compatibility overlay. + nixpkgs.overlays = lib.mkIf (options.nixpkgs.overlays.type.name != "unique") [ compatibility ]; + nerdos.release = { + version = nixpkgs.lib.mkDefault (nixpkgs.lib.trim (builtins.readFile ./VERSION)); + revision = nixpkgs.lib.mkDefault (self.rev or "uncommitted"); + narHash = nixpkgs.lib.mkDefault (self.narHash or "unavailable"); + }; + }; + in { + overlays.default = compatibility; + nixosModules = { default = module; nerdos = module; installer = ./nix/modules/installer.nix; }; + packages = each (system: let pkgs = import nixpkgs { inherit system; overlays = [ compatibility ]; config.allowUnfree = true; }; in rec { + desktop = pkgs.callPackage ./nix/packages/desktop.nix { source = self; }; + updater = pkgs.callPackage ./nix/packages/updater.nix { source = self; }; + installer-ui = pkgs.callPackage ./nix/packages/installer.nix { source = self; }; + plymouth = pkgs.callPackage ./nix/packages/plymouth.nix { source = self; }; + xps-camera = (pkgs.callPackage ./nix/packages/ipu7.nix { kernel = pkgs.linuxPackages_latest.kernel; }).sensor; + xps-psys = (pkgs.callPackage ./nix/packages/ipu7.nix { kernel = pkgs.linuxPackages_latest.kernel; }).psys; + t3-code = pkgs.callPackage ./nix/packages/t3-code.nix {}; + titlebars = pkgs.callPackage ./nix/packages/titlebars.nix {}; + iso = self.nixosConfigurations.installer.config.system.build.isoImage; + default = desktop; + }); + nixosConfigurations.installer = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ module ./nix/modules/installer.nix { + nerdos.installer.payload = installerPayload; + system.extraDependencies = inputSources; + environment.etc."nerdos/installer-inputs.json".text = builtins.toJSON + (map builtins.unsafeDiscardStringContext inputSources); + } ]; + }; + checks = each (system: let pkgs = import nixpkgs { inherit system; overlays = [ compatibility ]; }; in { + parity = import ./nix/tests/parity.nix { + inherit nixpkgs pkgs module system inputSources inputNarHashes; + installer = self.nixosConfigurations.installer.config; + }; + lifecycle = pkgs.runCommand "nerdos-lifecycle-tests" { + nativeBuildInputs = [ + (pkgs.python3.withPackages (p: [ p.websockets p.pygobject3 p.pillow p.dbus-python ])) + pkgs.openssl pkgs.git pkgs.dbus pkgs.restic pkgs.quickshell pkgs.systemd + pkgs.xdg-terminal-exec pkgs.desktop-file-utils + ]; + GI_TYPELIB_PATH = pkgs.lib.makeSearchPath "lib/girepository-1.0" [ pkgs.networkmanager pkgs.glib ]; + NERDOS_DBUS_SESSION_CONFIG = "${pkgs.dbus}/share/dbus-1/session.conf"; + NERDOS_LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.2"; + } '' + cp -r ${self} source; chmod -R u+w source + # Tests execute the same portable helper scripts as the packages. + # /usr/bin/env is not available inside a normal Nix build sandbox. + patchShebangs source + cd source + export PYTHONDONTWRITEBYTECODE=1 + python3 -m unittest discover -s tests/nixos -v + python3 -B tests/nixos/hermes-bridge.py + python3 -B tests/nixos/hermes-remote-runtime.py + python3 -B tests/nixos/hermes-webui-auth.py + python3 -B tests/nixos/default-agent.py + python3 -B tests/nixos/camera-frame.py + python3 -B tests/system-settings.py + touch $out + ''; + system = (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix ]; + }).config.system.build.toplevel; + # Qualify the complete default application selection too. The small + # VM/installer choices must not hide unsupported proprietary packages. + product = (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix ({ lib, ... }: { + nerdos.features = { + proprietary = lib.mkForce true; + steam = lib.mkForce true; + docker = lib.mkForce true; + }; + }) ]; + }).config.system.build.toplevel; + nvidia-evaluation = pkgs.writeText "nerdos-nvidia-application-independence" (builtins.unsafeDiscardStringContext ( + (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix { + nerdos.hardware.nvidia = true; + } ]; + }).config.system.build.toplevel.drvPath)); + # Build the complete matched hardware profile: generic product builds + # cannot detect camera-module, LPMd or hardware-unit packaging failures. + # This remains a source/build check, never physical qualification. + xps-system = (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix { + nerdos.hardware.xps = { enable = true; sku = "0DB9"; camera = true; }; + } ]; + }).config.system.build.toplevel; + vm = import ./nix/tests/lifecycle.nix { + inherit pkgs module inputSources; + candidate = self.checks.${system}.system; + }; + }); + devShells = each (system: let pkgs = import nixpkgs { inherit system; overlays = [ compatibility ]; }; in { + default = pkgs.mkShell { + packages = with pkgs; [ openssl nixfmt git nodejs quickshell qt6.qtdeclarative restic systemd + qt6.qttools cmake ninja pkg-config qt6.qtbase sway qemu OVMF dbus util-linux lua5_4 jq procps hyprland xorriso mtools squashfsTools xdg-terminal-exec desktop-file-utils + (python3.withPackages (p: [ p.pexpect p.pillow p.pygobject3 p.dbus-python p.websockets p.pyside6 ])) ]; + NERDOS_OVMF = "${pkgs.OVMF.fd}/FV"; + GI_TYPELIB_PATH = pkgs.lib.makeSearchPath "lib/girepository-1.0" [ pkgs.networkmanager pkgs.glib ]; + NERDOS_DBUS_SESSION_CONFIG = "${pkgs.dbus}/share/dbus-1/session.conf"; + NERDOS_SWAY_BINARY = "${pkgs.sway-unwrapped}/bin/sway"; + NERDOS_QML_IMPORT_PATH = "${pkgs.qt6.qtdeclarative}/lib/qt-6/qml:${pkgs.qt6.qtmultimedia}/lib/qt-6/qml:${pkgs.qt6.qtwebsockets}/lib/qt-6/qml:${pkgs.qt6.qt5compat}/lib/qt-6/qml:${pkgs.quickshell}/lib/qt-6/qml"; + NERDOS_QT_PLUGIN_PATH = "${pkgs.qt6.qtsvg}/lib/qt-6/plugins"; + QT_QPA_PLATFORM = "offscreen"; + QT_QUICK_BACKEND = "software"; + }; + }); + templates.default = { path = ./templates/machine; description = "Machine-owned NerdOS configuration"; }; + }; +} diff --git a/roles/xps-2026/files/camera/71-xps-ipu7-hide-isys.rules b/hardware/xps/camera/71-xps-ipu7-hide-isys.rules similarity index 100% rename from roles/xps-2026/files/camera/71-xps-ipu7-hide-isys.rules rename to hardware/xps/camera/71-xps-ipu7-hide-isys.rules diff --git a/roles/xps-2026/files/camera/90-xps-ipu7-psys.rules b/hardware/xps/camera/90-xps-ipu7-psys.rules similarity index 100% rename from roles/xps-2026/files/camera/90-xps-ipu7-psys.rules rename to hardware/xps/camera/90-xps-ipu7-psys.rules diff --git a/roles/xps-2026/files/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch b/hardware/xps/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch similarity index 100% rename from roles/xps-2026/files/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch rename to hardware/xps/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch diff --git a/roles/xps-2026/files/camera/patches/0004-ipu7-psys-register-bus.patch b/hardware/xps/camera/patches/0004-ipu7-psys-register-bus.patch similarity index 100% rename from roles/xps-2026/files/camera/patches/0004-ipu7-psys-register-bus.patch rename to hardware/xps/camera/patches/0004-ipu7-psys-register-bus.patch diff --git a/hardware/xps/camera/patches/0005-ipu7-psys-stock-core-layout.patch b/hardware/xps/camera/patches/0005-ipu7-psys-stock-core-layout.patch new file mode 100644 index 00000000..1b2f4c00 --- /dev/null +++ b/hardware/xps/camera/patches/0005-ipu7-psys-stock-core-layout.patch @@ -0,0 +1,79 @@ +From 9a37ac0b0dd9f7ab751e4456cdb728ee8f03a7ed Mon Sep 17 00:00:00 2001 +From: CybexOS +Date: Wed, 19 Aug 2026 12:15:00 +0200 +Subject: [PATCH] media: intel/ipu7: build PSYS for the stock 7.1 core ABI + +Linux 7.1 ships the IPU7 base and ISYS drivers in-tree. Their ipu7_device +does not contain the optional debugfs directory pointer added by Intel's +later out-of-tree debugfs patch. Retaining that member shifts hw_ver and +ipu7_bus_ready_to_probe relative to the stock core's allocation, causing +PSYS to defer forever. + +For kernels which already provide base and ISYS, compile only PSYS, match +the in-tree structure layout, and give PSYS an independent debugfs root. +NixOS's base and ISYS modules remain unmodified and are not installed by +this native module package. +--- +diff --git a/drivers/media/pci/intel/ipu7/Makefile b/drivers/media/pci/intel/ipu7/Makefile +index 0750ef9..95a6c32 100644 +--- a/drivers/media/pci/intel/ipu7/Makefile ++++ b/drivers/media/pci/intel/ipu7/Makefile +@@ -10,6 +10,7 @@ endif + endif + endif + ++ifndef IPU7_PSYS_ONLY + intel-ipu7-objs += ipu7.o \ + ipu7-bus.o \ + ipu7-dma.o \ +@@ -32,6 +33,7 @@ ifdef CONFIG_VIDEO_INTEL_IPU7_MGC + intel-ipu7-isys-objs += ipu7-isys-tpg.o + endif + obj-$(CONFIG_VIDEO_INTEL_IPU7) += intel-ipu7-isys.o ++endif + + obj-$(CONFIG_VIDEO_INTEL_IPU7) += psys/ + +diff --git a/drivers/media/pci/intel/ipu7/ipu7.h b/drivers/media/pci/intel/ipu7/ipu7.h +index f780a7e..3b76d0d 100644 +--- a/drivers/media/pci/intel/ipu7/ipu7.h ++++ b/drivers/media/pci/intel/ipu7/ipu7.h +@@ -81,9 +81,6 @@ struct ipu7_device { + + void __iomem *base; + void __iomem *pb_base; +-#ifdef CONFIG_DEBUG_FS +- struct dentry *ipu7_dir; +-#endif + u8 hw_ver; + bool ipc_reinit; + bool secure_mode; +diff --git a/drivers/media/pci/intel/ipu7/psys/ipu-psys.c b/drivers/media/pci/intel/ipu7/psys/ipu-psys.c +index d175e4e..0671a22 100644 +--- a/drivers/media/pci/intel/ipu7/psys/ipu-psys.c ++++ b/drivers/media/pci/intel/ipu7/psys/ipu-psys.c +@@ -1310,7 +1310,7 @@ static int ipu7_psys_init_debugfs(struct ipu7_psys *psys) + struct dentry *file; + struct dentry *dir; + +- dir = debugfs_create_dir("psys", psys->adev->isp->ipu7_dir); ++ dir = debugfs_create_dir("intel-ipu7-psys", NULL); + if (IS_ERR(dir)) + return -ENOMEM; + +@@ -1456,11 +1456,9 @@ static void ipu7_psys_remove(struct auxiliary_device *auxdev) + { + struct ipu7_psys *psys = dev_get_drvdata(&auxdev->dev); + struct device *dev = &auxdev->dev; +-#ifdef CONFIG_DEBUG_FS +- struct ipu7_device *isp = psys->adev->isp; + +- if (isp->ipu7_dir) +- debugfs_remove_recursive(psys->debugfsdir); ++#ifdef CONFIG_DEBUG_FS ++ debugfs_remove_recursive(psys->debugfsdir); + #endif + + mutex_lock(&ipu7_psys_mutex); +-- +2.50.1 diff --git a/roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch b/hardware/xps/camera/patches/0006-camera-hal-native-cvs-bridge.patch similarity index 97% rename from roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch rename to hardware/xps/camera/patches/0006-camera-hal-native-cvs-bridge.patch index 181be49f..28d7b8dc 100644 --- a/roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch +++ b/hardware/xps/camera/patches/0006-camera-hal-native-cvs-bridge.patch @@ -1,7 +1,7 @@ From: CybexOS Subject: [PATCH] camera-hal: follow the native CVS bridge on OV08X40 -Fedora's native CVS exposes sensor -> CVS -> IPU7 CSI2. Resolve the actual +NixOS's native CVS exposes sensor -> CVS -> IPU7 CSI2. Resolve the actual sensor I2C address through that bridge and configure its two pads and links. Retain the direct sensor topology used with the legacy CVS driver. diff --git a/hardware/xps/camera/xps-ipu7-abi-check b/hardware/xps/camera/xps-ipu7-abi-check new file mode 100755 index 00000000..88635b15 --- /dev/null +++ b/hardware/xps/camera/xps-ipu7-abi-check @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Refuse PSYS use when the selected NixOS kernel's in-tree IPU7 private ABI has changed.""" + +from __future__ import annotations + +import gzip +import lzma +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +class AbiChanged(RuntimeError): + pass + + +# PSYS dereferences these private structures allocated/owned by the in-tree +# base driver. This signature was recorded from the previously reviewed upstream IPU7 BTF; +# checking offsets (rather than a kernel version) permits harmless kernel +# updates while refusing a layout that this compatibility patch has not seen. +EXPECTED = { + "ipu7_device": ( + 304, + ( + ("pdev", 0), + ("devices", 8), + ("isys", 24), + ("psys", 32), + ("buttress", 40), + ("cpd_fw", 256), + ("cpd_fw_name", 264), + ("fw_code_region", 272), + ("base", 280), + ("pb_base", 288), + ("hw_ver", 296), + ("ipc_reinit", 297), + ("secure_mode", 298), + ("ipu7_bus_ready_to_probe", 299), + ), + ), + "ipu7_bus_device": ( + 992, + ( + ("auxdev", 0), + ("auxdrv", 856), + ("auxdrv_data", 864), + ("list", 872), + ("subsys", 888), + ("pdata", 896), + ("mmu", 904), + ("isp", 912), + ("ctrl", 920), + ("dma_mask", 928), + ("fw_sgt", 936), + ("fw_entry", 952), + ("syscom", 960), + ("boot_config", 968), + ("boot_config_dma_addr", 976), + ("boot_config_size", 984), + ), + ), + "ipu7_psys_pdata": ( + 16, + (("base", 0), ("ipdata", 8)), + ), + "ipu7_auxdrv_data": ( + 24, (("isr", 0), ("isr_threaded", 8), ("wake_isr_thread", 16)), + ), + "ipu7_syscom_context": ( + 48, (("num_input_queues", 0), ("num_output_queues", 2), + ("queue_configs", 8), ("queue_indices", 16), + ("queue_mem_dma_addr", 24), ("queue_mem", 32), ("queue_mem_size", 40)), + ), + "syscom_queue_config": ( + 16, (("token_array_mem", 0), ("queue_size", 8), + ("token_size_in_bytes", 12), ("max_capacity", 14)), + ), + "ia_gofo_boot_config": ( + 276, (("length", 0), ("config_version", 4), ("client_version_support", 8), + ("pkg_dir", 24), ("subsys_config", 28), ("uc_tile_frequency", 32), + ("checksum", 36), ("uc_tile_frequency_units", 38), ("padding", 39), + ("reserved", 40), ("syscom_context_config", 272)), + ), + "syscom_config_s": ( + 4, (("max_output_queues", 0), ("max_input_queues", 2)), + ), + "syscom_queue_params_config": ( + 8, (("token_array_mem", 0), ("token_size_in_bytes", 4), ("max_capacity", 6)), + ), +} + +# Upstream Linux 7.2 grows the public auxiliary_device by eight bytes. PSYS uses the +# matching kernel headers, so its following fields move with that embedded +# object. This second complete signature was reviewed against stock 7.2.7 BTF; +# continue rejecting any other private layout rather than accepting arbitrary +# shifts or relaxing individual offset checks. +REVIEWED_ALTERNATES = { + "ipu7_bus_device": ( + 1000, + ( + ("auxdev", 0), ("auxdrv", 864), ("auxdrv_data", 872), + ("list", 880), ("subsys", 896), ("pdata", 904), ("mmu", 912), + ("isp", 920), ("ctrl", 928), ("dma_mask", 936), ("fw_sgt", 944), + ("fw_entry", 960), ("syscom", 968), ("boot_config", 976), + ("boot_config_dma_addr", 984), ("boot_config_size", 992), + ), + ), +} + + +MEMBER_RE = re.compile( + r"^\s*.*?\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[[^]]+\])?" + r"\s*(?:__attribute__\(\([^;]+\)\))?;" + r"\s*/\*\s*(\d+)\s+\d+\s*\*/\s*$" +) +CALLBACK_RE = re.compile( + r"^\s*.*?\(\*([A-Za-z_][A-Za-z0-9_]*)\)\([^;]*\);" + r"\s*/\*\s*(\d+)\s+\d+\s*\*/\s*$" +) +SIZE_RE = re.compile(r"/\*\s*size:\s*(\d+),.*members:\s*(\d+)\s*\*/") + + +def run(argv: list[str], *, text: bool = True) -> subprocess.CompletedProcess: + return subprocess.run(argv, check=False, capture_output=True, text=text) + + +def command_output(argv: list[str]) -> str: + result = run(argv) + if result.returncode: + detail = (result.stderr or result.stdout).strip() + raise AbiChanged(f"command failed ({' '.join(argv)}): {detail}") + return result.stdout.strip() + + +def parse_layout(output: str) -> tuple[int, tuple[tuple[str, int], ...]]: + members: list[tuple[str, int]] = [] + size = None + declared_members = None + for line in output.splitlines(): + if match := CALLBACK_RE.match(line) or MEMBER_RE.match(line): + members.append((match.group(1), int(match.group(2)))) + if match := SIZE_RE.search(line): + size = int(match.group(1)) + declared_members = int(match.group(2)) + if size is None or declared_members is None or len(members) != declared_members: + raise AbiChanged("could not parse a complete structure layout from module BTF") + return size, tuple(members) + + +def unpack_module(source: Path, destination: Path) -> None: + if source.suffix == ".xz": + with lzma.open(source, "rb") as incoming, destination.open("wb") as outgoing: + shutil.copyfileobj(incoming, outgoing) + elif source.suffix == ".gz": + with gzip.open(source, "rb") as incoming, destination.open("wb") as outgoing: + shutil.copyfileobj(incoming, outgoing) + elif source.suffix == ".zst": + with destination.open("wb") as outgoing: + result = subprocess.run( + ["zstd", "--quiet", "--decompress", "--stdout", str(source)], + check=False, + stdout=outgoing, + stderr=subprocess.PIPE, + ) + if result.returncode: + detail = result.stderr.decode().strip() + raise AbiChanged(f"cannot decompress {source}: {detail}") + elif source.suffix == ".ko": + shutil.copyfile(source, destination) + else: + raise AbiChanged(f"unsupported stock-module compression: {source}") + + +def stock_module(kernel: str, name: str) -> Path: + prefix = ["modinfo", "-b", "/run/booted-system/kernel-modules", "-k", kernel] + path = Path(command_output([*prefix, "-n", name])) + if command_output([*prefix, "-F", "intree", name]) != "Y" or "/kernel/" not in str(path): + raise AbiChanged(f"{name} is not the selected in-tree module: {path}") + if not str(path.resolve()).startswith("/nix/store/"): + raise AbiChanged("Kernel module is outside the immutable selected closure") + return path + + +def layout_reviewed(structure: str, actual: tuple) -> bool: + return actual == EXPECTED[structure] or actual == REVIEWED_ALTERNATES.get(structure) + + +def check_kernel(kernel: str) -> None: + if kernel != command_output(["uname", "-r"]): + raise AbiChanged("Runtime BTF can only validate the booted kernel") + if not shutil.which("pahole"): + raise AbiChanged("The native dwarves package is missing") + base_module = stock_module(kernel, "intel_ipu7") + stock_module(kernel, "intel_ipu7_isys") + stock_module(kernel, "intel_cvs") + modules = Path(f"/run/booted-system/kernel-modules/lib/modules/{kernel}/kernel") + if any(modules.rglob("*ipu7*psys.ko*")): + raise AbiChanged("Upstream now supplies PSYS; remove the external companion before proceeding") + base = Path("/sys/kernel/btf/vmlinux") + if not base.is_file(): + raise AbiChanged("Booted kernel does not expose BTF; private ABI is unproved") + with tempfile.TemporaryDirectory(prefix="xps-ipu7-abi.") as work: + module = Path(work) / "intel-ipu7.ko" + unpack_module(base_module, module) + for structure, expected in EXPECTED.items(): + result = run(["pahole", f"--btf_base={base}", "-F", "btf", "-C", structure, str(module)]) + if result.returncode or not layout_reviewed(structure, parse_layout(result.stdout)): + raise AbiChanged(f"Unreviewed private layout: {structure}; expected {expected}") + + +def self_test() -> None: + fixture = """ +struct sample { + void * base; /* 0 8 */ + const void * ipdata; /* 8 8 */ + /* size: 16, cachelines: 1, members: 2 */ +}; +""" + assert parse_layout(fixture) == (16, (("base", 0), ("ipdata", 8))) + try: + parse_layout(fixture.replace("members: 2", "members: 3")) + except AbiChanged: + pass + else: + raise AssertionError("incomplete layout was accepted") + assert layout_reviewed("ipu7_bus_device", EXPECTED["ipu7_bus_device"]) + assert layout_reviewed("ipu7_bus_device", REVIEWED_ALTERNATES["ipu7_bus_device"]) + size, fields = REVIEWED_ALTERNATES["ipu7_bus_device"] + assert not layout_reviewed("ipu7_bus_device", (size + 8, fields)) + assert not layout_reviewed("ipu7_bus_device", (size, fields[:-1])) + callback_fixture = """ +struct callbacks { + void (*handler)(struct device *); /* 0 8 */ + void * base __attribute__((__aligned__(8))); /* 8 8 */ + /* size: 16, cachelines: 1, members: 2 */ +}; +""" + assert parse_layout(callback_fixture) == (16, (("handler", 0), ("base", 8))) + print("IPU7 ABI parser self-test passed") + + +def main() -> int: + if len(sys.argv) == 2 and sys.argv[1] == "--self-test": + self_test() + return 0 + args = sys.argv[1:] + if len(args) > 1 or (args and args[0].startswith("--")): + print("usage: xps-ipu7-abi-check [kernel]", file=sys.stderr) + return 2 + kernel = args[0] if args else command_output(["uname", "-r"]) + try: + check_kernel(kernel) + except AbiChanged as error: + print( + "IPU7_STOCK_ABI_CHANGED: the selected NixOS kernel's stock IPU7 ABI no longer matches " + f"the reviewed PSYS compatibility layer for {kernel}: {error}. " + "The camera stack will not start; the selected NixOS kernel's stock " + "intel_ipu7 and intel_ipu7_isys modules remain untouched.", + file=sys.stderr, + ) + return 78 + print(f"IPU7_STOCK_ABI_OK kernel={kernel}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roles/xps-2026/files/camera/xps-ipu7-camera-frame-check b/hardware/xps/camera/xps-ipu7-camera-frame-check similarity index 82% rename from roles/xps-2026/files/camera/xps-ipu7-camera-frame-check rename to hardware/xps/camera/xps-ipu7-camera-frame-check index d8b65b75..d0373480 100644 --- a/roles/xps-2026/files/camera/xps-ipu7-camera-frame-check +++ b/hardware/xps/camera/xps-ipu7-camera-frame-check @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Verify fresh relay frames in memory without saving camera images.""" +import argparse import hashlib import os import re @@ -9,9 +10,12 @@ import sys def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--device", required=True, help="Processed relay output, for example /dev/video50") + device = parser.parse_args().device environment = dict(os.environ, LC_ALL="C") details = subprocess.run( - ["v4l2-ctl", "--device=/dev/video50", "--get-fmt-video"], + ["v4l2-ctl", f"--device={device}", "--get-fmt-video"], capture_output=True, text=True, check=True, timeout=5, env=environment, ).stdout match = re.search(r"Size Image\s*:\s*(\d+)", details) @@ -22,7 +26,7 @@ def main(): # warm-up interval and require different complete images, not merely three # successful dequeues. stdout is an anonymous pipe; pixels never reach disk. captured = subprocess.run( - ["v4l2-ctl", "--device=/dev/video50", "--stream-mmap", + ["v4l2-ctl", f"--device={device}", "--stream-mmap", "--stream-skip=30", "--stream-count=3", "--stream-to=-"], capture_output=True, check=True, timeout=15, env=environment, ).stdout diff --git a/hardware/xps/camera/xps-ipu7-camera-init b/hardware/xps/camera/xps-ipu7-camera-init new file mode 100644 index 00000000..bcc274e9 --- /dev/null +++ b/hardware/xps/camera/xps-ipu7-camera-init @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Loaded only by the matched NixOS XPS camera unit. Core/ISYS/CVS modules are +# owned by the selected kernel and must never be unloaded by this initializer. +set -euo pipefail + +die() { echo "xps-ipu7-camera-init: $*" >&2; exit 1; } +[[ $(cat /sys/class/dmi/id/sys_vendor) == 'Dell Inc.' ]] || die 'not a Dell system' +case "$(cat /sys/class/dmi/id/product_sku)" in + 0DB9|0DBA) ;; + *) die 'DMI SKU is not a supported XPS 2026 model' ;; +esac +[[ -e /sys/bus/acpi/devices/OVTI08F4:00 || -e /sys/bus/acpi/devices/OVTI08F4:01 ]] \ + || die 'OVTI08F4 ACPI camera is absent' +matched=false +for device in /sys/bus/pci/devices/*; do + if [[ $(cat "$device/vendor") == 0x8086 && $(cat "$device/device") == 0xb05d ]]; then + matched=true + break + fi +done +"$matched" || die 'Panther Lake IPU7 PCI function (8086:b05d) is absent' +kernel_log=$(journalctl -k -b --no-pager) || die 'cannot inspect this boot for camera kernel faults' +if grep -Eq 'RIP: .*ipu7|BUG:.*ipu7' <<< "$kernel_log"; then + die 'this boot recorded an IPU7 kernel fault; reboot before retrying the camera' +fi +if grep -q '^ov08x40 ' /proc/modules && ! grep -q '^intel_cvs ' /proc/modules; then + modprobe -r ov08x40 || die 'sensor is busy; reboot to apply CVS-first ordering' +fi +modprobe intel_cvs +sleep 2 +modprobe ov08x40 +modprobe intel_ipu7_psys +udevadm settle --timeout=10 +for _ in {1..30}; do + [[ -e /dev/ipu7-psys0 || -e /dev/ipu-psys0 ]] && exit 0 + sleep 0.2 +done +die 'IPU7 PSYS device did not appear' diff --git a/hardware/xps/xps-fingerprint-check b/hardware/xps/xps-fingerprint-check new file mode 100755 index 00000000..77c5aead --- /dev/null +++ b/hardware/xps/xps-fingerprint-check @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +fprintd-list "${1:-$USER}" +grep -q pam_unix /etc/pam.d/sudo +grep -q pam_fprintd /etc/pam.d/sudo diff --git a/roles/xps-2026/files/xps-fingerprint-detect b/hardware/xps/xps-fingerprint-detect similarity index 100% rename from roles/xps-2026/files/xps-fingerprint-detect rename to hardware/xps/xps-fingerprint-detect diff --git a/roles/xps-2026/files/xps-haptic-touchpad b/hardware/xps/xps-haptic-touchpad similarity index 98% rename from roles/xps-2026/files/xps-haptic-touchpad rename to hardware/xps/xps-haptic-touchpad index bf7b4df2..ec1ce584 100644 --- a/roles/xps-2026/files/xps-haptic-touchpad +++ b/hardware/xps/xps-haptic-touchpad @@ -15,7 +15,7 @@ import time VENDOR = "06CB" -CONFIG_PATH = "/etc/xps-haptic-touchpad.conf" +CONFIG_PATH = "/etc/nerdos/xps-haptic.conf" BUTTON_SWITCH_REPORT = 0x06 HAPTIC_INTENSITY_REPORT = 0x37 BUTTON_SWITCHES = 0x03 # Surface switch and physical button switch. @@ -273,7 +273,7 @@ def monitor_pair(event, hidraw, phys, wakeup_fd): ) while not stop_requested: - # Config changes arrive as SIGHUP (Ansible restarts the unit when + # Config changes arrive as SIGHUP (the NixOS module restarts the unit when # it templates a new file), so there is nothing to time out for. events = poller.poll(-1) if reload_requested: diff --git a/roles/xps-2026/files/xps-speaker-filter-chain.conf b/hardware/xps/xps-speaker-filter-chain.conf similarity index 100% rename from roles/xps-2026/files/xps-speaker-filter-chain.conf rename to hardware/xps/xps-speaker-filter-chain.conf diff --git a/roles/xps-2026/files/xps-speaker-host.conf b/hardware/xps/xps-speaker-host.conf similarity index 100% rename from roles/xps-2026/files/xps-speaker-host.conf rename to hardware/xps/xps-speaker-host.conf diff --git a/roles/xps-2026/files/xps-speaker-tuning b/hardware/xps/xps-speaker-tuning similarity index 94% rename from roles/xps-2026/files/xps-speaker-tuning rename to hardware/xps/xps-speaker-tuning index dda2f61c..6e35692b 100644 --- a/roles/xps-2026/files/xps-speaker-tuning +++ b/hardware/xps/xps-speaker-tuning @@ -4,8 +4,8 @@ set -uo pipefail readonly virtual_sink=xps_speaker_tuning readonly output_node=xps_speaker_tuning_output readonly speaker_pattern='^alsa_output.*sof_sdw.*HiFi__Speaker__sink$' -readonly profile=/usr/local/share/xps-2026/audio/filter-chain.conf -readonly host=/usr/local/share/xps-2026/audio/host.conf +readonly profile=/etc/nerdos/xps-audio/filter-chain.conf +readonly host=/etc/nerdos/xps-audio/host.conf readonly speaker_wait_attempts=60 readonly speaker_stable_samples=4 readonly speaker_wait_interval=0.25 @@ -84,9 +84,8 @@ move_apps_to() { } plugin_present() { - [[ -r /usr/lib64/spa-0.2/filter-graph/libspa-filter-graph-plugin-lv2.so ]] && - grep -Rqs 'http://lsp-plug.in/plugins/lv2/limiter_stereo' \ - /usr/lib/lv2 /usr/lib64/lv2 2>/dev/null + [[ -n ${NERDOS_LSP_LV2:-} && -d $NERDOS_LSP_LV2 ]] && + grep -Rqs 'http://lsp-plug.in/plugins/lv2/limiter_stereo' "$NERDOS_LSP_LV2" } prepare_runtime_config() { @@ -210,7 +209,7 @@ case "$action" in } runtime=$(prepare_runtime_config "$speaker") || exit 1 export PIPEWIRE_CONFIG_DIR="$runtime" - exec /usr/bin/pipewire -c xps-speaker-tuning.conf + exec pipewire -c xps-speaker-tuning.conf ;; activate) activate ;; deactivate) deactivate ;; diff --git a/image/AUDIT-2026-09-23.md b/image/AUDIT-2026-09-23.md deleted file mode 100644 index 532e3a93..00000000 --- a/image/AUDIT-2026-09-23.md +++ /dev/null @@ -1,175 +0,0 @@ -# ISO and installer audit — 2026-09-23 - -Audited checkout: `3c585cd`, followed by the local fixes described below. -Host: a Fedora 44 workstation. This is a source/configuration audit with automated -tests, not validation of a newly built or booted ISO. - -The subsequent [implementation record](IMPLEMENTATION-2026-09-23.md) tracks -which recommendations were implemented in the isolated worktree. Findings -below describe the audited baseline; current commands are in [README.md](README.md). - -## Result and verification - -The project has a complete VM-based ISO builder, offline application payload, -Hyprland/Quickshell live session, welcome application, and Anaconda Web UI. -The earlier full-application ISO passed offline UEFI installation and an -installed-disk boot; [VALIDATION.md](VALIDATION.md) identifies that exact -September 6 image. Those results do not qualify current source. - -Checks performed in this audit: - -- `./tests/run`: passed, including 895 JavaScript tests and repository source, - integration, helper, and policy checks. The real-engine Quickshell lifecycle - smoke test was explicitly skipped because a managed desktop was active. -- `image/tests`: all 11 existing tests passed; all 13 passed after adding the - repository-policy regressions below. Qt welcome loading used an isolated - PySide6 6.11.2 environment with `QT_QPA_PLATFORM=offscreen`, not the exact - Fedora image package environment. -- Fedora 44 Kickstart parsing passed for the expanded compose configuration - (139 native selections, before pinned application RPM additions, and three - Flatpaks) and the interactive installation cleanup script. -- All six packaged Hyprland templates, three Distrobox launcher templates, - and the Hermes service rendered with the packager's actual Jinja context. -- Static analysis passed again after the code changes; `git diff --check` - passed. - -An actual `image/build` invocation stopped at its prerequisite check because -this host lacks `cloud-localds`. No builder VM or disk was created. The -live-test harness also assumes `/usr/share/OVMF/OVMF_{CODE,VARS}_4M.fd`, which -this Fedora host does not provide; its firmware uses other paths/formats. -The local `/data/pxe/iso` tree and previous image-output directories are absent. -The PXE host's name did not resolve; an SSH attempt at its address stopped -at the unknown-host-key check. Existing PXE image contents were not inspected. - -Fresh compose, live boot, encrypted installation, installed boot, physical -hardware, Secure Boot, and current iVentoy behavior remain unverified. - -## Small fixes made during the audit - -1. [applications](applications) requested `brave-origin.repo`, but the reviewed - policy is `roles/apps/files/brave-browser.repo`. Python silently ignored - the missing file, omitting the Brave repository while the package contract - selected `brave-origin`. Corrected the filename and made missing policy - files fail explicitly. Added tests for the actual Brave repository contract - and missing-file behavior. -2. [Live image CI](../.github/workflows/live-image.yml) did not trigger on - desktop, base-role, or helper/asset changes that affect the image. Expanded - its path filters to cover these inputs and `VERSION`. - -No installed desktop configuration was changed. No ISO was published, so no -iVentoy refresh was required. Temporary test dependencies and the task-created -Python bytecode were removed; no VM, disk, screenshot, or build log is retained. - -## Comparison with the current desktop - -| Area | Finding | -| --- | --- | -| Quickshell | All 352 source files match the deployed runtime byte-for-byte; the image packager embeds that source. This establishes source parity, not the contents of an existing ISO. | -| Hyprland | Core files and rendered defaults match the deployed runtime, aside from irrelevant whitespace. The active `releases/bootstrap` source is older than the runtime and is not a reliable parity baseline. | -| Appearance | Theme, fonts, bar geometry, spacing, borders, and module layout largely match. The current Bluetooth widget preference is not seeded. | -| Wallpaper | The current mountain wallpaper and bundled wallpaper collection are absent from the build allowlist. No default wallpaper is selected in the image. Wallpaper-derived colors can therefore differ too. | -| Omarchy plugin | Personal plugin packages, including the installed `digitalpals.model-usage` widget, are not copied. Omarchy compatibility code remains part of the shell. | -| Personal monitor rules | The personal AORUS mode and single-window width override are not exported. Generic display defaults remain appropriate for a distributable ISO. | -| Boot theme | The workstation selects the custom `cybex` Plymouth theme; the image includes generic Plymouth and omits the boot role. | -| Installed login | The current workstation enables autologin. The image post-install hook explicitly writes a GDM configuration without it. | -| Encryption | Anaconda offers encryption, but the image does not enable it by default or share its passphrase with the account password. | -| System policy | The image invokes the apps role and manually assembles desktop integration. It does not apply all base, firewall, hardware, and dotfile policies from the workstation installer. | - -Evidence: [source allowlist](build), [payload assembly](package), -[application preparation](applications.yml), [wallpaper role](../roles/dotfiles/tasks/wallpapers.yml), -[boot role](../roles/boot/tasks/main.yml), [compose Kickstart](live.ks), -[Anaconda defaults](live-rootfs/etc/anaconda/conf.d/20-cybexos.conf), and -[installed-system cleanup](live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks). - -Two smaller integration gaps also need attention: the T3 launcher still looks -for its OAuth callback helper under `~/.config/quickshell`, while image -initialization only creates the current runtime link; Super+P references a -private `portal-launcher` helper that the image does not include. Resolve or -gate these through the shared desktop contract. - -## Proposed minimal installation - -Omarchy v4.0.4's [shared setup form](https://github.com/omacom/omarchy/blob/v4.0.4/install/provisioning/setup-form.sh) -collects keyboard, account/password, optional Git identity, hostname, and -timezone. Its [ISO configurator](https://github.com/omacom/omarchy-iso/blob/7cfb7111a06873d61c45d37034577d4ba08d3f4f/configs/airootfs/root/configurator) -then asks about the disk and confirms changes, with encryption enabled by -default. The [installation implementation](https://github.com/omacom/omarchy-iso/blob/7cfb7111a06873d61c45d37034577d4ba08d3f4f/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py) -enables autologin on encrypted normal installations. The pinned ISO source -is the current development branch inspected during this audit; it may be -newer than the released ISO. Older Omarchy 3 manual limitations should not -be assumed to describe this source. - -Recommended CybexOS flow: - -| Screen | Required choices | Defaults / secondary choices | -| --- | --- | --- | -| Your setup | Username, password and confirmation, keyboard layout | Language, timezone, and hostname prefilled where possible, editable under More options; keyboard test field available. | -| Install location | Target disk, shown by model and capacity | Automatic LUKS2-encrypted Btrfs root/home; boot partitions managed by Anaconda; autologin enabled. Advanced storage remains available. | -| Review and install | Confirm the exact disk changes | Show encryption, account, keyboard, and erase summary, then installation progress and reboot. | - -Collect the password once, use it for LUKS and the account, and leave root -locked. Keep the password out of persistent logs and generated artifacts; -use the installer's supported secret handling and store the account hash. -After reboot the intended flow is **Cybex disk-unlock prompt → desktop**. -Move optional Git identity and application choices into later personalization. -When encryption is disabled in Advanced, default back to a graphical login. - -Keep Anaconda's storage backend and validation. Fedora 44's Web UI tag 68 -supports branding, CSS, and hiding selected pages, but its -[customization documentation](https://github.com/rhinstaller/anaconda-webui/blob/68/docs/customization.rst) -says Web UI add-ons are not implemented. The existing -[encryption](https://github.com/rhinstaller/anaconda-webui/blob/68/src/components/storage/storage-configuration/DiskEncryption.jsx) -and [account](https://github.com/rhinstaller/anaconda-webui/blob/68/src/components/users/Accounts.jsx) -components use separate password state. Implementing a combined screen -requires a maintained Web UI change or a custom frontend using Anaconda's -interfaces. Begin with a small Web UI change and measure its maintenance cost. - -Equal initial passwords do not automatically stay synchronized. Omarchy has a -[separate disk-password command](https://github.com/omacom/omarchy/blob/v4.0.4/bin/omarchy-drive-password). -A later CybexOS password-settings screen should coordinate both changes or -clearly identify which password it changes. Also test GNOME Keyring access -under autologin: disk unlock alone does not establish the normal login PAM -password handoff, so applications may otherwise prompt again. - -## Prioritized improvements - -1. **Make one shared desktop/image contract.** Package the same wallpaper, - appearance defaults, helpers, system policies, and boot theme from both - install paths. Keep personal plugins and machine-specific overrides out - of that contract. Add fresh-account parity assertions. -2. **Complete the branded boot path.** Reuse the current Cybex artwork and - colors for a short boot menu, Plymouth animation and encryption entry, - welcome screen, and installer. Keep readable error output and a recovery - menu. A consistent restrained animation will contribute more than adding - extra splash screens. -3. **Remove the blank first-login wait.** The session launcher synchronously - copies the whole offline seed before Hyprland starts; the existing README - documents about a minute on live media. Prepare installed accounts before - reboot; reduce live-user copying and expose progress for unavoidable work. - Share immutable toolchain assets where their update/ownership behavior - permits it. Preserve per-user writable state and offline functionality. -4. **Implement the three-screen installer and encrypted autologin.** Test - international keyboards at both password entry and early boot, installer - failure/retry, screen lock, sudo, and application credential storage. -5. **Improve build feedback and recovery.** Add disk/RAM/CPU preflight and - Fedora dependency guidance; use unique build IDs; show named phases, - timings, and actionable failure summaries. Record source revision, local - modifications, configuration, tool versions, and payload versions. - Cache signature/checksum-verified downloads independently of disposable - VMs, and benchmark zstd against the current xz ISO compression before - choosing a new default. Verify copied output checksums before success. -6. **Automate release qualification and PXE publishing.** Detect Fedora and - Debian OVMF paths; replace fixed boot delays with bounded readiness checks. - Exercise live boot → encrypted installation → installed boot → desktop - parity, with outbound networking disabled. Publish a uniquely named ISO - and checksum to `/data/pxe/iso` only after verification, then perform the - required iVentoy refresh/status/list checks. Keep physical GPU and Secure - Boot coverage as explicit additional tests. -7. **Provide desktop RPM updates.** The image RPM version is currently fixed - and a signed desktop repository is not yet established. Application updates - alone cannot deliver the next CybexOS desktop release. - -Start with parity and a newly qualified build, then implement the boot/installer -experience. Keep the full offline application image as the primary profile -while measuring size and startup time; a smaller optional development profile -can be considered afterward if the measurements justify another artifact. diff --git a/image/Containerfile.tests b/image/Containerfile.tests deleted file mode 100644 index 67c4251c..00000000 --- a/image/Containerfile.tests +++ /dev/null @@ -1,15 +0,0 @@ -# CI passes inventory fedora_release (image/fedora_target.py); this default -# serves a plain local build and is kept equal to it by test_fedora_release.py. -ARG FEDORA_RELEASE=44 -FROM fedora:${FEDORA_RELEASE} -RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 gvfs \ - ShellCheck pykickstart qt6-qtdeclarative-devel desktop-file-utils \ - nodejs24 gnupg2 git ripgrep luajit ansible-core rpm-build rpm-sign createrepo_c \ - && dnf clean all -# CI mounts the host-owned checkout read-only while this test image runs as -# root. Trust only that explicit mount for source archive enumeration. -RUN git config --system --add safe.directory /source -ENV QT_QPA_PLATFORM=offscreen -WORKDIR /source -ENV PYTHONDONTWRITEBYTECODE=1 -CMD ["python3", "image/check-source"] diff --git a/image/IMPLEMENTATION-2026-09-23.md b/image/IMPLEMENTATION-2026-09-23.md deleted file mode 100644 index cd56f1f6..00000000 --- a/image/IMPLEMENTATION-2026-09-23.md +++ /dev/null @@ -1,91 +0,0 @@ -# ISO experience implementation — 2026-09-23 - -Worktree: `CybexOS-wt/iso-experience-20260923`, beside the main checkout - -Branch: `feat/iso-experience-20260923` - -Base: `3c585cd` - -This implements the source changes from the [ISO audit](AUDIT-2026-09-23.md). -The implementation stayed in this worktree while another agent used `main`. -At the user's request, no ISO was created or tested. No build VM, installer, -PXE operation, signed release or host desktop deployment was run. - -## Delivered - -| Audit suggestion | Implementation | -| --- | --- | -| Share desktop defaults | `assets/desktop-contract.json` supplies wallpaper, helper and seeding contracts. Workstation and image share wallpaper assets, Bluetooth defaults, boot artwork, sysctl and firewall sources. Private plugins/monitor rules/credentials are excluded. T3's runtime callback path and optional private portal binding are corrected. | -| Brand boot and installer | GRUB/Syslinux appearance, existing Cybex Plymouth animation/unlock artwork, welcome and Cockpit installer share the dark/warm palette. Boot/media-check/recovery commands remain available. Target initramfs is regenerated after live configuration removal. | -| Improve first-login time | Essential desktop files precede Hyprland; large offline applications seed in the background with progress/retry. Installed users seed before reboot. Atomic copies preserve existing personal data and defer editor/tool activation until dependencies exist. | -| Minimal encrypted installation | Three pages: setup, disk, review. Automatic encrypted Btrfs is default; one initial account/LUKS password, locked root, encrypted-root verification before autologin, normal login when encryption is disabled. Keyboard is applied before password entry. Anaconda handles validation and disk writes; advanced storage retains the stock UI. | -| Build feedback/recovery | Resource/dependency preflight, Fedora-compatible seed tools, unique build IDs, phases/timings, revision/configuration provenance, verified download cache, xz/zstd switch, checksummed atomic artifact delivery and interruption cleanup. Brave repository omission fixed. | -| Qualification/PXE tools | Explicit opt-in live/install harnesses, portable OVMF discovery, isolated disk identity, offline guest networking, live and installed checks, shared Quickshell lifecycle helper, default artifact cleanup. PXE publication preserves existing files and verifies checksum, refresh, image list, PXE and service status. | -| Desktop RPM updates | Source/build-derived version, migration epoch, fingerprint-pinned public update channel, local signed-repository preparation, independent signature verification and rejection of packages containing a different/disabled channel. Actual URL, signing key and hosting remain operator inputs. | - -See [README.md](README.md) for commands and [INSTALLER.md](INSTALLER.md) for -the Fedora 44 API contract, state handling and target finalization. - -## Review findings fixed - -- Correct `brave-browser.repo` policy selection and explicit missing-file failure. -- Wait for Anaconda startup; use its supplied browser URL/port and correct - Fedora 44 password-hashing API. -- Serialize confirmation and installation, use a durable worker, avoid - status/worker state races, invalidate old plans, and reject changed disks. -- Restore the shared firewall zone after Anaconda's default SSH exception; - regenerate installed initramfs after removing live-only settings. -- Keep boot-keyboard conversion and live keyboard application ahead of - password entry. Changing the layout clears password fields and review state. -- Defer LazyVim/toolchain activation during background copying, and keep - incomplete files from becoming live defaults. -- Accept prerelease RPM filenames, reject symlinked checksum manifests, - and atomically refuse even a concurrently created empty output directory. -- Verify actual RPM signatures rather than accepting digest-only success; - check embedded update-channel digests before signing a desktop release. -- Require checksum sidecars for VM input, use the actual shared Quickshell - audit helper, and register signal cleanup in imported entry points. - -## Source-only verification - -- `image/check-source`: **13 existing image/Qt tests + 61 Python image-tooling, - parity, installer and release tests + 4 installer state tests passed**. -- Headless browser fixture: real frontend with fake Cockpit transport; three - pages, keyboard changes, encryption default, explicit erase confirmation, - password clearing, progress/reload recovery and 390px layout passed. -- `tests/run`: repository syntax/lint, **895 JavaScript tests**, QML helpers, - configuration integration, policy, application/helper and existing recovery - fixtures. The live Quickshell lifecycle test is skipped when a managed - desktop is active; no second shell instance was launched. -- Fedora 44 Kickstart parsing: expanded compose (139 native application - selections plus three Flatpaks) and target post-install scripts passed. -- Independent source review checked Anaconda 44.30/Web UI 68 interfaces and - installation sequence. iVentoy 1.0.41 response shapes were inspected - statically from its official release; no iVentoy service was contacted. -- Whitespace checks passed. Optional `rpmspec --parse` could not run because - this host lacks `rpmspec`; no package tools were installed on the host. - -Qt fixtures used an isolated PySide6 6.11.2 environment, not a composed Fedora -image. The browser test used installed Chromium/Brave with temporary Playwright -Core 1.63.0. Task-created dependencies, screenshots and scratch files are -removed after verification; no ISO, VM disk or large diagnostic output is kept. - -## What these checks do not establish - -A newly built ISO still needs real Fedora/Cockpit authorization, actual DBus -tasks, BIOS/UEFI boot, offline installation, encrypted/unencrypted target boot, -autologin, screen lock, sudo, non-US/dead-key input and keyring behavior verified. -Physical GPU, Secure Boot, suspend/resume and network-zone behavior need their -own hardware coverage. No performance numbers or successful current-ISO boot -claim can be inferred from the fixtures. - -The qualification harness remains unqualified too: initial SSH access and LUKS -unlock use bounded graphical key injection without prompt recognition. A real -VM run is needed to establish timing reliability. Its failures cannot count as -a successful qualification report. - -The signed repository tool is implemented but no production channel exists -without the operator's key and URL. Default builds keep desktop updates disabled. -The shared password is initial only; later account and disk password changes -are independent. The keyring remains protected and may request unlock after -automatic login. xz stays default until an authorized zstd size/startup comparison. diff --git a/image/INSTALL-AUDIT-2026-09-25.md b/image/INSTALL-AUDIT-2026-09-25.md deleted file mode 100644 index 387ee843..00000000 --- a/image/INSTALL-AUDIT-2026-09-25.md +++ /dev/null @@ -1,336 +0,0 @@ -# Installed XPS audit — 2026-09-25 - -The application payload was installed, but the ISO does not reproduce the -workstation's complete baseline, account policy, or hardware configuration. -There is also a session dependency cycle that prevents three desktop services -from starting. These are image/configuration defects, rather than evidence of -an interrupted application seed. - -This records the initial read-only investigation followed by the authorized -repair and validation. The findings below describe the original installation; -the applied-repair section records the resulting state. - -## Build and checks - -- Computer: Dell XPS 14 DA14260, SKU 0DB9, Fedora 44, kernel - `7.2.7-200.fc44.x86_64`. -- Original installed build: `20260925T140839Z-5f33cb32`, clean source revision - `a2d31c2c13b5ab92491a8650991c519a223d10bb`. -- Read-only SSH inspection of `john@10.10.0.7` matched that build to - `/data/pxe/iso/CybexOS-Live-44-20260925T140839Z-5f33cb32.iso` and the - builder's provenance. That original ISO remains unchanged. -- All **166 explicit RPM selections** in the installed `applications.json` - are present, along with all **three declared Flatpak applications**. -- Offline application seeding reports `ready`. No broken symlinks were found - in the user's `.local/bin`, `.npm-global/bin`, or `.cargo/bin`. -- Claude, OpenCode, Cargo, Node, Bun, awww, wayfreeze and voxtype version - commands succeed. This does not qualify every application's interactive use. -- Quickshell is active with its MainPID as the sole `qs` process. Hyprland - reports no configuration errors. Its current journal confirms the missing - PowerProfiles service and contains existing portal/property warnings. -- System and user `--failed` lists are empty; that check misses the startup - jobs systemd discarded to resolve the dependency cycle below. - -## Reported problems - -| Problem | Evidence and cause | Required correction | -| --- | --- | --- | -| Nano missing after installation | Neither the required application list nor the image kickstart selects `nano`. The local RPM was installed at 17:22 CEST by a later `dnf install nano`, confirmed in the journal and DNF log. | Add `nano` to the shared required package contract so both installation paths include it. | -| Updates widget error | The original Quickshell warning at 17:01 CEST is `Cache-only enabled but no cache` for the Hyprland COPR. The widget only runs `dnf --quiet --cacheonly check-update`; the image runs `dnf clean all`. The makecache timer starts after 10 minutes plus up to 60 minutes of randomized delay, and its service requires AC power. | Bootstrap verified metadata on the first online boot and make the widget handle an uninitialized cache explicitly. | -| Signing-key prompts and incomplete update checks | The manual root DNF run encountered missing repository metadata keys, then verified signatures after acceptance. The unprivileged check still logs missing keys and skips `openai-chatgpt` and `tailscale-stable`, even while returning exit status 0. | Initialize trust in the context used by update checks, using the reviewed pinned keys. Treat skipped enabled repositories as an incomplete check. Keep signature verification enabled. | -| LocalSend cannot communicate normally | Flatpak 1.18.2 is installed and starts; it binds TCP and UDP port 53317. The active network interfaces use the `cybexos` zone. Its shipped definition has target `DROP` and no allowed ports because `features.local_network_services` defaults to false. A connection from the PXE host to the laptop's listening port timed out. | Include the intended LocalSend TCP/UDP rules in the active and permanent policy. Test discovery and a transfer in both directions. | -| Codex/Claude do not default to YOLO | The user's login shell is `/bin/bash`. The Codex alias exists and resolves correctly in interactive Fish, but Bash does not source it. No Claude alias exists in the shared Fish configuration. The image seeds Fish files but never sets the installed account's shell. | Apply the intended Fish login-shell policy during installed-account setup and add the Claude alias. Verify the aliases in a newly opened terminal. | -| Passwordless sudo missing | `sudo -n true` requires a password. The repository default is explicitly `passwordless_wheel: false`; its sudoers task belongs to the base role, which the image application playbook does not run. | Record the requested enabled policy and apply a validated, mode-0440 sudoers entry during image installation. Updating only the Ansible default would not fix the image path. | -| Tailscale click does not open login | The UI first opens a setup view; `Continue in browser` calls `signIn()`. When unprivileged Tailscale needs permission, it retries through `pkexec`. The Polkit agent was never started because of the session cycle. Tailscale is now logged in following the user's manual `sudo tailscale up`. | Repair the Polkit agent startup. If the desired initial click must open the browser directly, route that action to `signIn()` rather than only `showSetup()`. Verify with a fresh unauthenticated test account/device. | - -The original Tailscale interaction was not replayed by logging out the user's -working connection. The missing agent and extra setup step are confirmed; -the exact button sequence used in the failed attempt is unknown. - -LocalSend was launched briefly with `--hidden` and the task-owned Flatpak -instance was stopped afterward. No files were transferred. The inbound timeout -is consistent with the shipped host firewall; routed-network filtering could -also contribute. The permanent `/etc/firewalld` configuration and live ruleset -could not be fully read without authentication. A loopback HTTPS probe did -not return a successful response, so complete protocol/transfer functionality -has not been established. - -[LocalSend's upstream requirements](https://github.com/localsend/localsend/blob/main/README.md#setup) -specify incoming TCP and UDP 53317. -[DNF5 documents](https://dnf5.readthedocs.io/en/latest/dnf5.conf.5.html#repo-gpgcheck) -that repository metadata keys are separate from package keys and are stored -separately for each repository. Importing an RPM key alone is insufficient. - -The requested alias arguments are supported by the installed CLIs and their -official references: Codex -[`--dangerously-bypass-approvals-and-sandbox`](https://learn.chatgpt.com/docs/developer-commands?surface=cli) -and Claude -[`--dangerously-skip-permissions`](https://code.claude.com/docs/en/cli-reference). - -## Additional defects to fix first - -### 1. Session startup: authentication, automatic locking, dictation - -The current boot journal explicitly records an ordering cycle for each of -`hyprpolkitagent.service`, `hypridle.service` and `voxtype.service`, followed by -deletion of its start job. All three are inactive, with no corresponding process. - -`image/rootfs/usr/lib/systemd/user/hyprland-session.target` wants these units -and orders itself before `graphical-session.target`. Their vendor units order -themselves after `graphical-session.target`. Systemd's target dependency -ordering closes the cycle. The image's drop-ins do not reconcile that ordering. -The workstation uses its own service templates instead. - -Repair the image session ordering and verify the installed vendor units and -drop-ins together. A successful login and a running menubar do not verify that -authorization prompts, automatic idle locking, or dictation are operational. -The inactive idle daemon makes this the first repair priority. - -### 2. Missing baseline packages and services - -`image/applications` reads application packages and selected desktop/Docker -tasks. It omits the `Install Fedora baseline packages` task. Compared with that -task, this computer is missing: - -| Missing package | Affected feature | -| --- | --- | -| `tuned-ppd` | Desktop power-profile backend; Quickshell explicitly reports PowerProfiles unavailable. Neither this nor `power-profiles-daemon` is installed. | -| `cups` | Printing service; the printer settings application is installed without its intended local print server. | -| `avahi` | Local service discovery. | -| `bolt` | Thunderbolt authorization management. | -| `gvfs-fuse` | Filesystem access to GVfs mounts. | -| `acl` | ACL administration tools. | -| `bash-completion` | Bash command completion. | -| `powertop` | Power diagnostics. | - -The separately selected workstation desktop package `fuzzel` is also absent. -The firmware refresh timer is disabled, while the base role enables it. -Baseline services must be installed and activated together, rather than merely -adding their client applications to the manifest. - -### 3. Update delivery and omitted integration files - -- `1password` and `openai-chatgpt` are each defined twice: once by their vendor - files and once in `cybex-applications.repo`. DNF logs duplicate-ID errors. - `image/compose` writes the combined file after vendor packages have created - their own files. Reconcile the definitions and preserve the reviewed policy - across vendor RPM scriptlets; use DNF overrides where appropriate. -- `cybexos-firmware-update` is omitted from `image/package`'s helper list. - The installed update runner expects it beside itself and records firmware - status 127 when it is absent. `fwupdmgr` itself is installed. -- `/usr/share/cybexos/update-channel.json` contains `enabled: false`. - Fedora/vendor updates can work, but there is no enabled repository delivering - new CybexOS desktop RPMs. A signed desktop update channel remains a separate - release requirement. -- The image omits the workstation's LocalSend Nautilus extension and three - share launcher desktop files. The basic LocalSend command is installed. - Its file/clipboard share helpers also need actual Flatpak transfer testing; - command existence alone does not establish that those integrations work. - -### 4. The XPS hardware role is not applied - -The image package renders desktop configuration with `cybexos_xps_2026=False` -and does not run the detected-hardware role for the installed computer. -This machine has the matching XPS SKU, Panther Lake IPU7 PCI function and a -Synaptics `06cb:0701` SVP7500 camera USBIO bridge. This is not a fingerprint -reader; the hardware role explicitly excludes it from fingerprint detection. - -Compared with the repository's XPS package tasks, missing packages include: - -- `alsa-sof-firmware`, `cirrus-audio-firmware`, `intel-audio-firmware` and - `intel-vsc-firmware`; -- `libva-intel-media-driver` and `intel-vpl-gpu-rt`; -- `pipewire-utils`, `pipewire-module-filter-chain-lv2` and `lsp-plugins-lv2`; -- Fingerprint packages are conditional on detecting a real supported reader; - their absence on this laptop is not an established installation defect. - -The intended camera setup, audio processing, touchpad/backlight configuration -and thermal/power configuration are therefore not established by this ISO. -The initial `/dev/video*`/sysfs inventory exposed only the external Studio -Display camera, not the laptop's internal camera. No supported fingerprint -reader was detected; the existing role probes support before enabling -fingerprint authentication. - -Hardware repair needs the role's model gates and physical testing. Do not -blindly install an IPU camera stack or apply laptop-specific tuning to every ISO -installation. - -## Repair and qualification order - -1. Correct the session dependency cycle; verify Polkit, idle locking and - dictation after a fresh login, with exactly one service-owned Quickshell. -2. Bring the image's baseline packages/services into the shared contract, - including nano and the firmware-update helper. -3. Reconcile repository definitions, bootstrap trusted metadata, and require - complete update-check coverage for every enabled repository. -4. Apply the requested installed-account defaults: Fish, both CLI aliases and - passwordless sudo. Test from a new terminal and with `sudo -n true`. -5. Correct LocalSend firewall/integration defaults and exercise bidirectional - transfers. Exercise Tailscale browser login from an unauthenticated state. -6. Apply and validate the detected XPS hardware configuration; qualify internal - camera, audio, power profiles and fingerprint support independently. -7. Run first-boot tests with empty DNF caches and no accepted user-side keys - before building/publishing another testing ISO. Add service and behavior - checks; repeating the current manifest-presence test will miss these defects. - -The initial audit was read-only apart from this document and a temporary -LocalSend launch, which was stopped. The authorized repair below followed it. - -## Applied repair and physical-machine validation - -The repair uses shared package, account, service, firewall, Fish and hardware -sources. Anaconda runs the offline account configuration; a first-boot service -runs model-gated hardware setup. Firmware and ALSA UCM profiles are included -in the ISO before the first physical boot. The latter were also missing: -without `alsa-ucm`, the newly detected internal sound card exposed only a -generic stereo fallback instead of its speaker, headphone and microphone paths. - -Validated on the installed laptop: - -- The package-contract preview reports no missing packages. Nano was already - manually installed; it is now also in the source package contract. -- `john` has `/usr/bin/fish`; an interactive Fish session defines both requested - YOLO aliases. `sudo -k -n true` succeeds using the validated wheel policy. -- Polkit, hypridle, voxtype, TuneD PPD, thermal management and the haptic service - run. Exactly one Quickshell is owned by its service; its current invocation - passes the repository's live-shell journal check. -- Root and user DNF checks succeed with unavailable-repository failures enabled. - There are no duplicate repository IDs. Repository reconciliation is idempotent. - A separate empty-cache user check also succeeds: DNF automatically imports - the bundled Codex, Tailscale and 1Password metadata keys without prompting. -- LocalSend's TCP/UDP 53317 ports are open. A protocol-compatible HTTPS request - with a disposable client certificate from `10.10.0.7` successfully retrieves - the laptop's device information. Plain curl without a client certificate is - rejected by LocalSend's mutual TLS; that is not a firewall failure. The - file wrapper now uses Flatpak document forwarding and the installed version's - supported file/text arguments. John subsequently confirmed that a real - LocalSend transfer with another device works. -- Tailscale remains connected to the user's existing account. Login routing, - duplicate-click prevention and browser handoff pass the behavioral fixtures; - the active account was not logged out for testing. -- Loading the newly installed audio firmware exposed the internal sound card. - With ALSA UCM installed, the internal speaker/headphone/microphone nodes appear. - The speaker-tuning check verifies its 26 biquads, limiter and physical output. - John subsequently confirmed audible output through the internal speakers. - Internal microphone capture has not been physically verified. -- A repeat baseline/account configuration finishes with `changed=0`, `failed=0`. - -The camera setup exposed an additional Fedora-specific bug: its generic relay -configuration is a dangling symlink into `/run`. Backup and restoration now -preserve the symlink rather than dereferencing it, with a regression fixture. -The subsequent hardware check reaches the existing ABI guard and refuses the -optional camera bundle on `7.2.7-200.fc44.x86_64`: Fedora now ships `intel_cvs` -in-tree. No conflicting DKMS module was installed. The diagnostic remains at -`/var/lib/xps-hardware/ipu7/abi-failed.log`; `cybex doctor` reports it. Automatic -retry avoids rebuilding the same incompatible kernel/payload combination. -This needs a separate camera compatibility change, not a reinstall. - -The signed CybexOS desktop RPM update channel is still unconfigured. This is -separate from the now-working Fedora/vendor/Flatpak update checks. - -Repair backups are retained under `/var/lib/cybexos/backups/`: -`install-repair-20260925T160403Z` (212 KiB) and -`install-repair-20260925T160524Z` (24 KiB). They preserve replaced system files -and account/Fish configuration for review or rollback. - -## Rebuilt delivery and regression verification - -Build `20260925T162842Z-f71bbab0` completed successfully from a task-specific -worktree. Its live audit verifies all 209 explicit RPM selections, all three -Flatpaks and the bundled user toolchains. The laptop was upgraded to its matching desktop RPM, -`1:0.0.0~dev-1.20260925162842.gfe1179ea114a.dirty.fc44.x86_64`. -Repeating shared configuration after the RPM upgrade reports `changed=0`, -`failed=0`. The post-upgrade package audit reports no missing packages, and the -live Quickshell check confirms one service-owned process with no matching QML -errors in the current invocation. - -The ISO is published on `john@10.10.0.7` at -`/data/pxe/iso/CybexOS-Live-44-20260925T162842Z-f71bbab0.iso` -(6,927,169,536 bytes), with a companion `.iso.sha256` file. SHA-256: -`412509d7013d636e9a62ddae7a46a233f4434de60cc679aff244ec75ff199b63`. -The publisher verified the copied checksum, successful iVentoy refresh, image -listing, running PXE status and active `iventoy.service`. Existing ISOs remain. - -The full repository test suite passed all 17 stages, including 1,146 JavaScript -tests across 122 files. Source checks, shared Ansible policy fixtures and -static analysis also passed. A disposable Fedora 44 container applied the -account/firewall policy successfully; its second application made no changes. -The final image source check passes 20 image fixtures, 102 additional Python -fixtures and four installer JavaScript fixtures. - -The installation VM runner initially failed before boot because it supplied a -disk serial number as a block-backend option. The corrected runner attaches -the serial number to `virtio-blk-pci`, preserving the disposable-disk identity -check. QEMU documents the separate -[drive and device arguments](https://github.com/qemu/qemu/blob/master/docs/qdev-device-use.txt). -The original 21-byte serial was also truncated by Virtio's -[20-byte identifier limit](https://github.com/torvalds/linux/blob/master/include/uapi/linux/virtio_blk.h). -The runner and installer guard now share a shorter constant, with a protocol -length regression check; the guard continues to require an exact match. -This host-side runner change does not alter the built ISO payload. - -The bootstrap now waits for the first-run desktop before sending terminal -shortcuts or commands, uses a separate workspace, and closes failed terminal -attempts. It still requires a freshly computed shell-output marker before -typing private fixture input. This prevents boot-menu editing and unreadable -stacks of tiled terminals during automated qualification; regression fixtures -cover both conditions that gate private input. - -The first completed encrypted-installation run booted without the ISO and -passed the new account/service checks, then exposed a test-only permissions -mistake: Fedora restricts traversal of `/etc/firewalld`. The installed audit -now queries both runtime and permanent LocalSend ports through -`sudo -n firewall-cmd`, instead of reading that directory as an ordinary user. - -Live qualification also exposed a process-audit bug: `qs ipc -p ...` clients -were classified as developer menubars because they contain `-p`. The shared -test helper now waits for IPC clients to finish naturally and only uses the -existing termination path for actual developer instances. A regression fixture -verifies that IPC clients receive no termination signal. - -The final UEFI qualification **passed** with guest outbound networking blocked. -It verified the live applications, a fresh encrypted installation, boot without -the ISO, Btrfs encryption, SELinux enforcing, desktop settings and removal of -live-only access. The installed account passed Fish, both YOLO aliases, -passwordless sudo, session services, enabled baseline timers and both runtime -and permanent LocalSend firewall checks. - -All 21 named qualification stages passed, including cold reboot with an -encrypted keyring, logout/compositor-crash/display-manager-restart recovery, -missing boot-password-cache fallback and a deliberately failed first autologin. -These are VM results, not physical GPU, Secure Boot or camera certification. - -## Retained artifacts and cleanup - -On `john@10.10.0.7`: - -| Path | Size | Reason retained | -| --- | --- | --- | -| `/data/pxe/iso/CybexOS-Live-44-20260925T162842Z-f71bbab0.iso` | 6,927,169,536 bytes (6.45 GiB) | Completed and qualified testing ISO, visible in iVentoy. | -| Same ISO path with `.sha256` appended | 112 bytes | Verified ISO checksum. | -| `~/.local/share/cybexos/images/install-policy-20260925-2200/deliverables/cybexos-desktop-0.0.0~dev-1.20260925162842.gfe1179ea114a.dirty.fc44.x86_64.rpm` | 1,720,229,107 bytes (1.60 GiB) | Matching repair package already installed on the laptop; retained for review/reuse. | -| Same `deliverables/` directory: `applications.json`, `packages.txt`, `build-provenance.json`, `qualification.json`, `SHA256SUMS` | Under 70 KiB combined | Package selections, exact RPM inventory, provenance, passed qualification report and checksums. | - -The task's failed/superseded VM runs, all disposable disks, credentials, -screenshots, duplicate build ISO, intermediate RPMs, temporary logs and build -worktree were removed. Only `deliverables/` remains in the task staging root. -The original installation ISO and unrelated images were preserved. Small -laptop rollback backups and the camera diagnostic described above remain -intentionally. Repository changes remain uncommitted in both checkouts. -Final cleanup checks found no task-owned VM processes or mounts. The remote -staging root uses 1.6 GiB for the retained deliverables; local temporary repair -files, package staging and task bytecode caches are gone. All retained package -checksums pass, and iVentoy still reports the expected ISO and running PXE. - -## Recommendation - -Keep and repair the current installation. Its original application payload -was complete for the old manifest; the problems came from missing shared -configuration and several independent image defaults. Reinstalling the old -image would repeat those defects, and reinstalling the new image would not -resolve the camera's current kernel incompatibility. - -Reboot once after this repair to load the firmware, hardware environment and -new login-shell defaults cleanly. John has confirmed a real LocalSend transfer -and internal speaker output. Internal microphone capture remains unverified; -service and device-node checks alone do not establish its physical operation. diff --git a/image/INSTALLER.md b/image/INSTALLER.md deleted file mode 100644 index 0a2b8f09..00000000 --- a/image/INSTALLER.md +++ /dev/null @@ -1,644 +0,0 @@ -# CybexOS installation experience - -The live image opens a small CybexOS page in Anaconda's existing local Cockpit -service. It uses Fedora's Anaconda storage and installation backend. It does not -format devices itself, generate an unattended live Kickstart, or replace -Anaconda's partition validation. - -The complete installation has **not been qualified by an ISO boot**. The SDDM -implementation has source, fixture and disposable desktop-RPM build checks; -ISO creation and publication were deferred at the user's request. No real -installation or host login-manager switch was performed. Do not label an image -qualified until the opt-in installation tests pass. - -## Boot entries - -The ISO menu (UEFI GRUB and BIOS isolinux) offers each generated live entry -twice. **Install CybexOS** is first and the default; it adds `cybexos.install` -to the kernel command line. **Try CybexOS live** is the unchanged live desktop. -The basic graphics troubleshooting entry has the same pair. - -With `cybexos.install`, `cybexos-live-setup` creates `/run/cybexos-install-mode` -and the session launcher starts `cybexos-install-session.target` instead of -`hyprland-session.target`. That target starts only the Polkit agent and the -welcome window in `--install-mode`, which opens the installer immediately. -There is no menubar, dictation, idle daemon, Hermes bridge, input method or -background offline-application seeding. The window cannot be closed; it can -reopen the installer, restart, power off, or start the full live desktop. -Both entries boot the same image, so this saves startup time and memory, not -download size or installation time: Anaconda installs by copying the live -root filesystem, which must therefore contain every installed application. - -## User flow - -Usernames are lowercase. Capital letters are lowercased while typing, so -"John" creates the account `john`; the backend still rejects anything else. - -1. **Your setup:** select a keyboard, test it, and enter username/password twice. - Language, timezone, hostname and **Prepare this computer for a new owner** - are under **More options**. The timezone is a dropdown of Anaconda's valid - timezones, preselected from Anaconda's geolocation (Fedora's GeoIP service) - unless the user chose one. -2. **Install location:** choose a disk. The default is automatic Btrfs with - LUKS2 encryption. When the disk already holds another system, the step - offers **Install alongside** (the default) or **Erase disk**; see - [Installing alongside another system](#installing-alongside-another-system). - The advanced section can disable encryption or open the stock Anaconda Web - UI for several disks, reusing existing partitions or a custom layout. -3. **Review and install:** show the target model/path, account, encryption, - startup behavior, boot keyboard, language/timezone, exactly what is erased, - resized, created and kept, actual Anaconda disk actions, and its validation - warnings. An explicit checkbox is required: erase the disk, or (alongside) - resize the named partition. Progress thereafter comes from Anaconda's real - task signals. - -An attached seed volume replaces these questions with an -[unattended installation](#unattended-installation) and its countdown. - -The password is applied to the LUKS slot and hashed separately for the created -administrator account. Root is locked. The post-install helper verifies that -the target root filesystem has an encrypted block-device ancestor before -requesting SDDM autologin. The shared login helper rechecks root encryption at -boot and permits automatic login only on the first SDDM start in that boot. -Unencrypted and stock Advanced installations retain the -normal login screen. EFI and boot partitions follow Anaconda's platform rules; -“encrypted installation” does not mean the firmware boot partition is encrypted. - -The app keyring remains encrypted. SDDM's autologin PAM stack uses systemd's -`pam_systemd_loadkey` and GNOME Keyring's normal PAM integration to unlock it -with the briefly cached boot passphrase when the passwords match. The cache -retention is not extended. If the cache is absent or expired, or the keyring -password differs, an application requests the keyring password normally. The -installer never writes the passphrase to a file or creates a passwordless -keyring. Disk, account and keyring passwords are not kept synchronized after -installation; changing one later can require a separate keyring unlock. - -The RPM packages the same login preparation helper, PAM template and SDDM -service drop-in as workstation deployment. Its post-transaction step installs -the PAM template with an initial backup; the SDDM package retains ownership of -`/etc/pam.d/sddm-autologin`. The helper prepares `/etc/sddm.conf` before SDDM -starts. There is no permanently enabled autologin stanza in the image. - -## Installing alongside another system - -When the selected disk has partitions whose file system suggests another -operating system (NTFS or BitLocker for Windows; ext4, XFS, Btrfs, LUKS or LVM -for Linux; APFS or HFS+ for macOS), the location step offers **Install -alongside** it. The backend's `alongside` command asks Anaconda, never the -browser, how much space is available: - -- **Enough free space** (at least the larger of 64 GiB and Anaconda's payload - requirement): CybexOS uses it. Nothing is resized. -- **Not enough free space:** only the **largest partition Anaconda can shrink** - is offered. A slider chooses the space for CybexOS, from the minimum up to - what the partition can give while keeping its file system minimum plus - 5 GiB of working space. Anything Anaconda cannot shrink (for example - BitLocker) is never offered; choose **Erase disk** or the full installer. - -The sequence mirrors anaconda-webui 68's reclaim dialog: the disk is selected, -`InitializationMode` is `CLEAR_PARTITIONS_NONE` with no drives to clear and no -new disk label, an `AUTOMATIC` partitioning is created, its resizable device -tree (`GetDeviceTree()`, interface `DeviceTree.Resizable`) receives one -`ShrinkDevice(partition, size)` call, and then the normal request, passphrase, -configure, apply and validate steps run. NTFS resizing uses `ntfsresize` -from `ntfsprogs`, a mandatory package of the image's `@anaconda-tools` group. - -Before review, the backend refuses any plan that touches an existing device -other than the shrunk partition: a `destroy` action, a resize of anything -else, or a format or label created on an existing partition or the disk. The -existing EFI system partition is reused and shared; Anaconda's automatic -partitioning assigns it `/boot/efi` without formatting it. The review states -the resized partition with its old and new size, the space CybexOS receives -and the partitions kept unchanged, and the confirmation names the partition. -The installation confirmation is `alongside_confirmed`, not `erase_confirmed`. - -**Boot menu.** Fedora 44's `grub2-tools` (2.12) requires `os-prober`, and its -`/etc/grub.d/30_os-prober` runs unless `GRUB_DISABLE_OS_PROBER=true`. -Anaconda 44.30 does not set that variable on x86 and runs `grub2-mkconfig` in -the target, so the generated `grub.cfg` lists Windows Boot Manager (UEFI) and -other Linux installations. BLS kernel updates do not regenerate `grub.cfg`; -the os-prober entries remain. The target hook clears `menu_auto_hide` in the -installed grubenv so the menu is visible at every start, and records in -`/etc/cybexos/installation.json` whether `grub.cfg` contains an os-prober -entry (`otherSystemsInBootMenu`). The firmware boot menu remains a fallback. -Another Fedora-based system on the same EFI system partition shares -`EFI/fedora`; CybexOS's boot loader then manages both. - -## Unattended installation - -A volume labelled `CYBEXOS-SEED` (or cloud-init's `CIDATA`/`cidata`) with a -`cybexos-seed.yml` at its top installs without questions. The live welcome -opens the installer when such a volume is attached (it checks for 30 seconds -after login). The installer page asks the backend's `seed` command, which -mounts the volume read-only (`ro,nosuid,nodev,noexec`), reads only the YAML -and the files it names (regular files, no symlinks, size-limited), unmounts it -and validates everything strictly. Every problem is listed on screen; nothing -is planned until the seed is valid. A volume without `cybexos-seed.yml` is not -a seed. - -A valid seed is planned with Anaconda exactly like an interactive installation -(`plan` with the seed's digest; a changed seed is refused). The page then shows -the matched disk's identity, the account, storage and access, and a visible -countdown (`countdown`, 10–600 seconds) with **Cancel installation**. The -backend also refuses to start before the countdown has ended, so a page that -skips it cannot shorten it. Cancelling resets Anaconda's plan; nothing was -written, and a reloaded page does not offer the same seed again until it -changes or the live system restarts. The outcome is that of an interactive installation: the same -Anaconda storage, account, keyboard, language, timezone and hostname calls, -target hooks, `/etc/cybexos/config.yml`, login policy and factory point. - -```yaml -# cybexos-seed.yml at the top of a volume labelled CYBEXOS-SEED. -version: 1 -disk: - match: - serial: S6B0NL0W123456 # `lsblk -o NAME,MODEL,SERIAL,SIZE` shows it - erase: true # required: the matched disk is erased -encryption: - enabled: true - passphrase_file: disk-passphrase # a file beside this one; see below -account: - username: alice - full_name: Alice Example - # mkpasswd -m yescrypt (or openssl passwd -6). Never a plain password. - password_hash: "$y$j9T$SKre8868v2XV1NXxxycxM/$klea7p1t8995H5ER2f8JnqFUUOtu1t24e8W3qRBPIU3" - passwordless_sudo: false -system: - keyboard: us # an Anaconda layout, e.g. "de (nodeadkeys)" - locale: en_US.UTF-8 - timezone: Europe/Amsterdam - hostname: alice-laptop -ssh: - authorized_keys: - - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOnlyReplaceWithYourOwnPublicKey0 alice@desktop - server: false # true enables sshd; the firewall still applies -tailscale: - auth_key_file: tailscale-auth-key # joins once at first start, then deleted -config: # /etc/cybexos/config.yml choices - manage_personal_dotfiles: true - features: - steam: false - local_network_services: false -countdown: 30 -``` - -| Setting | Rules | -| --- | --- | -| `version` | Must be `1`. Unknown settings anywhere are errors. | -| `disk.match` | One or more of `serial`, `wwn` (exact; WWN case-insensitive), `model` (case- and space-insensitive), `path` (a `/dev` path; `/dev/disk/by-id/…` links are resolved), `min_size`/`max_size` (`500GB`, `465GiB`). All given keys must match. A size alone is refused. Exactly one disk must match; zero or several matches are errors listing the available disks. The seed volume's own disk and the live medium are never candidates. | -| `disk.erase` | Must be `true`. Unattended installation always erases the matched disk; install alongside interactively. | -| `encryption` | `enabled` defaults to `true` and then requires `passphrase_file`. | -| `account` | Required unless `oem: true`, which forbids it. `username` follows the interactive rules, `full_name` is optional (no colons), `password_hash` must be a yescrypt (`$y$`) or SHA-512 (`$6$`) crypt hash; a `password` key is an error. `passwordless_sudo` defaults to `false`. | -| `oem` | `true` installs for a new owner; see below. | -| `system` | `keyboard`, `locale` and `timezone` must be in Anaconda's lists; defaults `us`, `en_US.UTF-8`, `UTC`. `hostname` defaults to `cybexos`. | -| `ssh` | Up to 32 OpenSSH public keys for the account's `~/.ssh/authorized_keys`, and `server` (default `false`) to enable `sshd`. The CybexOS firewall zone still drops SSH except on interfaces it trusts (Tailscale when that feature is on). Not allowed with `oem: true`. | -| `tailscale.auth_key_file` | A file holding a `tskey-…` auth key. Requires the Tailscale feature. | -| `config` | Only `manage_personal_dotfiles`, `passwordless_local_polkit`, `docker_sudoless` and `features.*` booleans, validated by the shared `installation_policy.choices`. Absent keys keep the ISO defaults. Automatic login, identity and passwordless sudo are decided by the installer as in an interactive installation. | -| `countdown` | Seconds before the disk is erased, 10–600, default 30. | - -Check a prepared seed folder before writing it to a volume (everything except -the disk match, which needs the target machine): - -```bash -python3 image/live-rootfs/usr/libexec/cybexos-installer-seed check /path/to/seed-folder -``` - -**Secrets and their trade-off.** The account takes only a password hash. LUKS, -however, needs the passphrase itself, so a fully unattended encrypted -installation must read it from the seed volume (`passphrase_file`). Anyone -who can read the volume can unlock the installed disk until the passphrase is -changed (`sudo cryptsetup luksChangeKey` on the LUKS partition, or the -interactive Settings for the account password). Keep the volume offline and -physically controlled, write it shortly before use, remove it after -installation and wipe it. The passphrase is never copied to the target, logged -or written to `/run`; it is passed to Anaconda's `SetPassphrase` in memory. -The account and disk start with different secrets unless you use the same -one; with different secrets, automatic login still works but the keyring asks -for its password once. The Tailscale key is staged in root-only, memory-backed -`/run/cybexos-installer/staged`, copied by the target hook to a root-only -`/var/lib/cybexos/first-boot/` file, moved to `/run` at the first start before -the factory recovery point is taken, used once with -`tailscale up --auth-key=file:…` (never on a command line) and deleted whether -or not it worked. On an unencrypted disk the deleted bytes can remain on the -device until overwritten; use a pre-approved, short-lived or ephemeral key. - -## New-owner (OEM) installations - -**Prepare this computer for a new owner** (or `oem: true` in a seed) installs -without an account. Anaconda creates no user; root stays locked. The target -hook verifies that no desktop account exists, writes a disabled login policy -and `/etc/cybexos/oem.json` (`{"version": 1, "passwordless_wheel": …}`), and -keeps any seed `config` choices in `/etc/cybexos/install-choices.json`. - -At the first start, `cybexos-new-owner.service` runs on the first console -before the login screen (`Before=display-manager.service`). It shows a -graphical setup in [cage](https://github.com/cage-kiosk/cage) (the RPM -requires the `cage` package) with four steps, one primary action each: - -1. **Welcome:** language (the installed UTF-8 locales, the installer's list, - labelled in their own language; the setup's own text is Dutch for `nl_*` - and English otherwise) and keyboard layout (xkeyboard-config's layouts and - variants), each a searchable list, with a field to try the keyboard; -2. **Your account:** full name, username (suggested from the first name and - editable), password and confirmation with the console's rules; on an - encrypted disk it explains that the password also unlocks the disk and asks - for the temporary passphrase used at this start; -3. **Time zone:** a searchable list, preselected with the installation's time - zone; -4. **Finishing:** the provisioning stages while they run, then **Ready** and a - restart (automatic after 15 seconds). - -Everything works from the keyboard alone, with a visible focus ring, and -sizes scale with the screen. - -**Privilege separation.** The root service never runs the window. It starts -`cage -d -- /usr/bin/python3 -I /usr/libexec/cybexos-new-owner-gui` with -`systemd-run --pipe --wait` as the transient unit -`cybexos-new-owner-gui.service` with `DynamicUser=yes` (a fresh unprivileged -user: read-only system, private `/tmp`, no new privileges, Unix and netlink -sockets only), the `video`, `input` and `render` groups and a private 0700 -runtime directory. There is no login session yet, so libseat's `noop` backend -opens the screen and input devices; the root service switches the first -console to graphics mode with its keyboard off for the duration (so nothing -typed reaches the console's input buffer) and restores it afterwards. The -window gets every list in one `init` message on standard input and returns -JSON lines on standard output: `ready`, `active` (first key press), -`keyboard`, `submit` and `restart`. The root service accepts only those -types with exactly their fields as strings of bounded length and lines up to -32 KiB; anything else ends the window. Every answer is checked again as root -(the same rules module the window imports), and the password travels only on -that pipe: never on a command line, in the environment, a file or a log. - -**Keyboard.** cage reads `XKB_DEFAULT_LAYOUT`/`XKB_DEFAULT_VARIANT` when it -starts. Choosing a layout applies it to the system with `localectl -set-x11-keymap` (localed converts it to the console keymap) and restarts cage -with it, back on the first step with the test field focused, so the password -is always typed with the chosen layout. On an encrypted disk a layout without -a console keymap is refused, as in the installer. Before the re-key, the -running kernel's initramfs keymap (`lsinitrd`) is compared with -`/etc/vconsole.conf` and `dracut --regenerate-all --force` runs when they -differ, so the unlock prompt reads the new password with the same layout. - -**Fallback.** The text-mode questions on the same console (keyboard, language, -account, password, time zone, then a confirmation) take over when -`cybexos.new-owner=console` is on the kernel command line; when cage, -systemd-run, the window, its QML or PySide6 is missing; when there is no -`/dev/dri/card*` or `/dev/input/event*`; when the console is not a virtual -terminal; when the window does not draw within 60 seconds (after one more -attempt); when no key press reaches it within five minutes; when it breaks -the protocol; or when it closes or crashes before the account exists. - -Either way the service then: - -1. creates the account in `wheel` and sets its password with `chpasswd`; -2. runs `cybexos-configure-installed --offline --user NAME` and - `cybexos-seed-installed-users`, the same fresh-account provisioning as - Anaconda's in-target hook, so `config.yml` is recorded from the saved - choices through the shared installation policy; -3. applies the saved sudo choice; -4. on an encrypted disk, refreshes the initramfs keymap when needed and - re-keys every LUKS device of the root file system (see below), then enables - automatic login only on a verified encrypted root, exactly as the - installer does; -5. updates `installation.json`, removes `oem.json` and restarts. - -Any failure removes the half-created account and restores the saved choices; -the window says what failed and offers to try again, and the console starts -again. Before creating the account the service records it in -`/etc/cybexos/new-owner-pending.json` (no secrets); a crash or power loss in -the middle is undone at the next start, and the unit runs again on every -start until `oem.json` is gone. - -**Encryption.** An encrypted OEM installation uses a temporary passphrase -chosen in the installer (or the seed's `passphrase_file`). The person handing -over the computer gives it to the new owner, who types it at the first unlock -and once more in the setup. The setup adds the new password as a passphrase -(`cryptsetup luksAddKey`, both secrets through stdin and a pipe, never argv), -removes every other key slot (`luksKillSlot`) and every LUKS2 token, and -verifies that the temporary passphrase no longer opens the disk. Re-running -after an interruption is safe. The restart then unlocks with the owner's -password, logs in automatically and lets the keyring unlock with the same -password. Changing a passphrase does not change the LUKS volume key: someone -who saved a copy of the header with the old key slot, or the volume key while -the disk was unlocked, could still decrypt it. A new owner who does not trust -the previous holder should run `sudo cryptsetup reencrypt` on each LUKS device -(online LUKS2 re-encryption; slow, keep the computer on mains power). - -## Factory reset - -Every guided installation leaves `/var/lib/cybexos/factory-point.pending`. -`cybexos-first-boot.service` runs at the first start, before any login, and -takes one **factory recovery point** with -`cybexos-system-snapshot create --factory`: the normal read-only root snapshot -and `/boot` archive, marked by `metadata/ID.factory`. It is outside the -five-point rotation and never pruned, and only one can exist. - -`cybex factory-reset` (ISO installations; a checkout installation explains -that it is unavailable) runs `/usr/libexec/cybexos-factory-reset reset` with -sudo, so it needs administrator authentication. It refuses in a recovery boot, -with a restore waiting for a restart, with an unfinished update transaction, -or without a factory point. It shows what is erased, suggests -`cybex backup now`, and requires typing the computer name or `erase`. On an -encrypted disk it asks for the current passphrase and a temporary passphrase -for the next owner, and re-keys the disk to that passphrase alone (as above). -It then restores the factory point with the normal restore and records the -reset inside the restored root. **Settings → Recovery points → Factory -reset…** explains the same and opens it in a terminal. - -At the next start, `cybexos-first-boot` completes the reset before any login: -it deletes every other recovery point, every set-aside root and the update -checkpoints (they contain the previous owner's system and files), removes every -desktop account and everything in `/home` (nested Btrfs subvolumes included; -other mounts are refused), SSH host keys and the sudo rule, converts the -installation's `config.yml` choices into `install-choices.json`, writes -`oem.json`, and the new-owner setup follows. The system is at the factory -point's package versions; the new owner updates as usual. Hostname and machine -ID are kept. -## Installation details and logs - -An **Installation details** panel at the bottom of every page follows one -allow-listed log, refreshed every two seconds while open: - -| Choice | Files | -| --- | --- | -| Installation steps | `/run/cybexos-installer/worker.log`: timestamped Anaconda steps and the error that stopped them | -| Anaconda | `/tmp/anaconda.log` | -| Commands | `/tmp/program.log` | -| Storage | `/tmp/storage.log` | -| Software | `/tmp/packaging.log` | -| Post-installation scripts | `/mnt/sysroot/var/log/anaconda/cybexos-cleanup.log` (chroot `%post`) and `/var/log/anaconda/cybexos-target-policy.log` (`--nochroot`) | - -The page shows at most the last 96 KiB of each. A failed installation names the -step it stopped in and opens the log most likely to explain it: the -post-installation scripts for "Running post-installation scripts", otherwise -the step log. Public status and saved state use only fixed step labels. The -log panel records Anaconda's free-form task and error text, which can include -paths and personal data; review exported logs before sharing them. Anaconda's -`/tmp/sensitive-info.log` is never read. - -**Save logs** writes `cybexos-install-logs-DATE-TIME.tar.gz`, with all of these -logs, the secret-free state file and this boot's journal, to a writable -filesystem (FAT, exFAT, ext2/3/4, Btrfs or XFS) on a removable drive. The live -medium, the selected target disk and fixed disks are never offered. An -unmounted drive is mounted `nosuid,nodev,noexec` only for the copy. - -## Keyboard behavior - -Before password entry is enabled, the backend validates the selected layout -against Anaconda's available layouts, obtains its virtual-console equivalent, -and applies the XKB layout/variant to the live user's single Hyprland session. -Changing layout clears the password fields and invalidates an earlier review. -The test input is for ordinary characters, not the password. - -The adapter accepts ordinary `layout` and `layout (variant)` identifiers. -Layouts it cannot map to a boot keymap fail before password entry and can use -the full installer. Anaconda writes the installed keyboard configuration; the -desktop launcher reads locale1 on login. Physical non-US keyboards, dead keys, -non-ASCII passwords, and accessibility input require later actual boot tests. -Early-boot Bluetooth input support is not promised. - -## Integration and source contract - -The interface was checked against **Anaconda 44.30** (tag `anaconda-44.30`, -commit `2353f519077c7fc9c8d6fe7712adc141cad2b649`) and **anaconda-webui 68**. -These are the Fedora 44 versions used as the source reference. - -- [Anaconda's live launcher](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/data/liveinst/liveinst) - owns backend startup, authorization, required modules, and the live payload. - It explicitly rejects Kickstart-based live installations. -- [Web UI customization](https://github.com/rhinstaller/anaconda-webui/blob/68/docs/customization.rst) - documents the `webui_web_engine` hook. Our browser wrapper accepts only the - loopback Anaconda URL, preserves its port, and substitutes the CybexOS page. -- [Anaconda's web launcher](https://github.com/rhinstaller/anaconda-webui/blob/68/webui-desktop) - starts its Cockpit service and passes the local page URL to that browser. -- [Storage API sequence](https://github.com/rhinstaller/anaconda-webui/blob/68/src/apis/storage_partitioning.js): - configure automatic partitioning, apply the in-memory layout, run its - validation task, then review. CybexOS sets both selected disks and - `DrivesToClear` to the one chosen disk. It obtains candidate disks from - `GetUsableDisks` and excludes protected/non-disk devices. -- [Storage scan interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/storage_interface.py) - provides `ScanDevicesWithTask` for an explicit rescan. A rescan invalidates - the prior review token. Existing partition details come from read-only - `lsblk`; Anaconda remains the authority for disk selection. -- [Disk initialization interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/disk_initialization/initialization_interface.py) - defines the clearing scope. [PartitioningRequest](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/structures/partitioning.py) - supplies Btrfs scheme `1`, LUKS2, and encryption policy. -- [Task interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/task/task_interface.py) - defines `Start`, `Stopped`, `Finish`, `GetResult`, and progress. `Finish` is - checked after stopping; stopping alone does not count as success. -- [Upstream installation progress](https://github.com/rhinstaller/anaconda-webui/blob/68/src/components/installation/InstallationProgress.jsx) - starts the same Boss `InstallWithTasks` task. The CybexOS worker runs under - systemd so a browser or SSH disconnect does not terminate its monitor. -- [UserData](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/structures/user.py) - provides the account record; [localization](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/localization/localization_interface.py) - provides layout selection and virtual-console conversion. - -There is no claim of a stable third-party installer-plugin API. A Fedora or -Anaconda version update requires reviewing this adapter and re-running the -installation qualification, alongside normal source tests. - -## Backend protocol - -`/usr/libexec/cybexos-installer-backend COMMAND` reads one JSON object on stdin -and emits newline-delimited JSON. Input is capped at 16 KiB. Requests require -root, `/run/cybexos-live`, and `rd.live.image` on the running kernel command -line. Operations that need Anaconda also require its `backend_ready` marker. -Fixture tests import the controller/adapter; they do not bypass these runtime -guards or invoke a real installation. - -| Command | Input | Result | -| --- | --- | --- | -| `inventory` | `{}` | Available disks with model, capacity, serial, WWN and existing partitions; layouts, locales, timezones and payload space requirement | -| `rescan` | `{}` | Invalidates any review, asks Anaconda to scan again, and returns a fresh inventory | -| `diagnostics` | `{}` | Allowlisted installer phase, progress, worker service state and backend readiness; no raw logs or personal data | -| `geolocate` | `{}` | Timezone from Anaconda's geolocation task, or empty; changes no selection | -| `keyboard` | `{"keyboard":"us"}` | Applied live keyboard and boot keymap | -| `seed` | `{}` | `{"found": false}`, or a display-safe seed summary with the matched disk, or every validation error | -| `cancel-seed` | `{"seed":"DIGEST"}` | Stops an unattended installation and resets the plan; that seed is not offered again in this live session | -| `alongside` | `{"disk":"vda"}` | Free space, or the one shrinkable partition with its floor and the CybexOS size range | -| `plan` | Account fields below, or `{"seed":"DIGEST"}` | Review token, disk identity, account policy, layout (`erase`/`alongside` with resize, space and kept partitions), disk actions, warnings; a seed plan adds its countdown | -| `install` | Token, disk, explicit erase or alongside confirmation | Starts the independent worker and returns `phase: installing` | -| `status` | No input required | Secret-free state and actual task progress | -| `reset` / `advanced` | `{}` | Clears the uncommitted plan and account state | -| `reboot` | `{}` | Reboots only when installation state is complete | -| `log` | `{"source":"steps"}` | The end of one allow-listed log (see above) | -| `log-destinations` | `{}` | Writable filesystems on removable drives | -| `export-logs` | `{"device":"sdb1"}` | Saves the log archive to one listed destination | - -`plan` fields are `username`, `password`, `confirm`, `keyboard`, `locale`, -`timezone`, `hostname`, `disk`, optional boolean `encrypted` (default true), -optional boolean `passwordless_wheel` (default false), optional boolean `oem` -(default false; no `username`, and `password` is the temporary disk -passphrase), optional `layout` (`erase` or `alongside`) and, for `alongside`, -an optional `shrink` of `{"device": ID, "size": BYTES}` that must name the -partition `alongside` offered. -`disk` is an Anaconda disk name such as `vda`, not an arbitrary path. -`install` input is `{"token":"…","confirmed_disk":"vda","erase_confirmed":true}`, -or `"alongside_confirmed":true` for an alongside plan. A seed plan is refused -before its countdown has ended. -The final record is `{"event":"result","ok":true,"data":{…}}`, or -`{"event":"result","ok":false,"error":"safe display text"}`. -`worker` is an internal systemd entry point and does not accept user settings. - -The review token expires after 30 minutes. Before installation, the controller -rechecks disk identity and existing partitions, selected disks, applied partitioning, the exact set of -pending actions, and storage validation. The set is compared without order: -`GetActions()` re-sorts blivet's list on every call, and its topological sort -reverses independent actions each time. A rejected confirmation returns to disk -selection. It never retries a started install. A failed -worker or interrupted task requires diagnosis, and the state continues to -block a replacement storage transaction. Status reads never overwrite worker -progress or completion. The startup grace prevents a queued service from being -mistaken for a lost monitor. - -## Credentials, state, and target finalization - -The UI passes the password through Cockpit stdin; it is never a command-line -argument. The helper uses Anaconda's own password-hashing function, provides the -plaintext only to its LUKS method, and drops its request copies. Python and -JavaScript do not guarantee immediate memory erasure. No disk key file or -plaintext password is written to the live image or target. A seed's account -arrives as a hash only; its disk passphrase is read from the seed volume into -memory for `SetPassphrase`. The only credential the installer moves to the -target is a seed's one-time Tailscale key (see above). - -`/run/cybexos-installer/state.json` is root-only, contains no password/hash, and -is replaced atomically. It holds disk identity, user-visible choices, the review -token before commit, task path, and progress. The browser uses a disposable -runtime profile with password saving, form history, and crash-session restore -disabled. Exceptions returned to the UI exclude raw DBus parameters. The welcome -launcher requests `--nosave=all_ks` to avoid saving generated account metadata. -Status and exported diagnostics expose only allowlisted phase, progress and -service fields. Anaconda's free-form task messages and journals are never -copied into the browser export because they may contain personal data. A failed -installation stays blocked pending diagnosis; the page never retries disk writes. - -The existing post-install hook removes the live account, temporary permissions, -live installer page/helpers/browser configuration, and live services. It calls -`cybexos-seed-installed-users` in the target before declaring success. A final -initramfs regeneration happens after live-only dracut configuration is removed. -The shipped CybexOS firewall zone is restored after Anaconda's firewall task, -which otherwise adds an SSH exception by default. A final -non-chroot post hook invokes `cybexos-installer-target`, which checks the actual -`/mnt/sysroot` mount, root encryption ancestry, created user, and locked root -before writing `/etc/cybexos/login.json`. This versioned nonsecret policy names -the installed user, requested autologin and `live: false`. -`/etc/cybexos/installation.json` records installation and keyring policy metadata. -The target hook removes any wheel sudo rule inherited from the live image. -Fresh offline configuration also defaults `passwordless_wheel` to false, so a -stock Advanced installation requires an administrator password. The guided -installer changes that policy and installs a mode-0440 sudoers rule only after -the user explicitly selects passwordless sudo and confirms the review. - -Before that, the first post hook (`cybexos-installer-target --stage`, ordered -before the in-target hook) writes a seed's explicit choices to -`/etc/cybexos/install-choices.json`; `cybexos-config` applies them through the -shared `installation_policy.apply_choices` when it records `config.yml` for a -fresh account and then removes the file. The final hook also handles the other -guided paths: for a new-owner installation it requires that no desktop account -exists and writes `oem.json` and a disabled login policy; for a seed it adds the -authorized keys (owned by the account, `0700`/`0600`), enables `sshd` only when -asked, and moves the staged Tailscale key; for an alongside installation it -clears `menu_auto_hide` and records whether os-prober added another system. -Every guided installation gets `/var/lib/cybexos/factory-point.pending`. - -The temporary live account has a separate policy with `live: true` and a -root-owned `/run/cybexos-live-session` marker. The login helper accepts this -exception only with the live kernel command-line flag. Target cleanup removes -the marker and prepared SDDM configuration and writes a disabled installed -policy before the final target helper runs. Stock Advanced installations keep -that disabled policy. Boot-local autologin state lives in -`/run/cybexos-login/autologin-used`; it cannot authorize a later boot. - -## Tests and remaining qualification - -`image/test_installer_seed.py` validates the documented seed example and every -seed rule, disk matching (zero/several matches, the seed's own disk), read-only -discovery with symlink refusal and unmounting, secret separation, the -backend-enforced countdown and the target hook's keys, SSH server, one-time -Tailscale key, OEM and alongside records. `image/test_installer.py` checks the -alongside, OEM and seed storage/account sequences against strict anaconda-44.30 -proxies (`CLEAR_PARTITIONS_NONE`, one `ShrinkDevice` on the partitioning's tree -before configuration, no account and a locked root for OEM, a seed hash used -as-is) and the controller's refusal of plans that touch other partitions. -`image/test_new_owner.py` covers LUKS re-keying against a fake LUKS2 header, -the new-owner questions and provisioning order with rollback and crash -recovery, the initramfs keymap refresh, the graphical setup's root side (the -window protocol with malformed, unknown and oversized messages, the -unprivileged `systemd-run` command, console handover, fallback selection, a -scripted end-to-end conversation with keyboard restart, wrong passphrase and -failed provisioning, and that no secret reaches a log or command line), the -factory reset guard rails and next-start cleanup, and the first-start -factory point. `image/test_new_owner_window.py` loads the window offscreen and -drives its pages, validation, live Dutch/English switching, translation -completeness, finishing states, and checks that every message it sends passes -the root service's parser. -`image/test_installation_parity.py` compares seed-style explicit choices on -the ISO and checkout paths. The browser fixture (`image/browser-smoke.cjs`) -drives the alongside slider and review, new-owner mode, and the seed -countdown, cancel and error screens with a mocked backend. - -None of these establishes a real installation. The following still need ISO -and VM (and some real hardware) qualification before they can be called -working: Anaconda 44.30 accepting an installation without any user; -`GetDeviceTree`/`ShrinkDevice` and free-space automatic partitioning on real -NTFS (including `ntfsresize` and a Windows that boots afterwards), ext4 and -already-full disks; the ESP being reused unformatted; os-prober entries in the -generated `grub.cfg` on UEFI and BIOS and the visible menu; seed discovery for -vfat, ISO9660 `cidata` and ext4 volumes and the welcome auto-start; the -countdown, cancel and unattended end-to-end result, including SSH and one-time -Tailscale enrollment at the first start; the graphical new-owner setup on tty1 -before SDDM (Plymouth hand-off, cage with the `noop` seat backend as a -`DynamicUser` on real Intel, AMD and NVIDIA graphics, input devices, the -console mode handover, cage restarting with a new layout, HiDPI scaling) and -its console fallback (localed keyboard, `chpasswd`, initramfs keymap refresh, -LUKS re-key on a real LUKS2 header, restart into autologin and keyring); -taking the factory point -at the first start; and a complete factory reset of an encrypted installation -through to the new owner's first login. -Every image build runs the chroot `%post` of `90-cybexos.ks` against the -composed root filesystem before packaging (`image/post_install_check.py`). It -uses a throwaway overlay with an Anaconda-like administrator account, the live -`/run` layout Anaconda binds into the target, and no network, then applies the -target helper and checks the resulting policy. A failure stops the build and -prints the script's output. This is how the 2026-09-26 failure (a missing -`/var/lib/systemd/linger` directory aborting every installation after the boot -loader step) would have been caught. - -`image/test_installer.py` covers input/locale validation, disk identity changes, -confirmation/replay, plan failures and retries, durable worker outcomes, monitor -loss, strict Anaconda proxy members and ordering, password separation, keyboard -commands, target autologin conditions, rejection of mixed encrypted/plaintext -backing devices, live-policy cleanup, executable permissions, and browser URL -validation. Node fixtures cover UI state, confirmation, navigation locks, and -failure recovery. Image tests load the welcome QML offscreen in both modes and -drive its wallpaper row against a scripted shell. The -headless browser fixture exercises the three pages with a mocked backend. -`image/test_qualification.py` checks URL and disk identity guards without -starting a VM. - -The opt-in `image/qualify` harness drives the guest's actual Cockpit page -through a local SSH tunnel and host Playwright. It discovers the guest URL from -the live browser command line, selects only the disk with the fixed disposable -serial, and verifies that a second attached disk's image hash is unchanged. -`--scenario` selects encrypted/plain storage and US/NL (or DE) keyboard and -locale. A `--candidate-rpm` must have a newer installed RPM version; the -optional `--recovery-check` creates a point before upgrade, boots its GRUB -entry, restores it and verifies the baseline RPM returned. The report at -`OUTPUT/qualification.json` records the exact ISO and candidate RPM SHA-256, -scenario, checks and final status. Credentials are passed to the browser driver -on stdin and excluded from its output. Source tests do not establish a -successful VM installation. - -Still required before an ISO can be called working: execution and observation -of actual Cockpit loading and -authorization, Fedora DBus behavior, UEFI/BIOS boot, encrypted and unencrypted -installation, keyboard input at LUKS unlock, autologin, post-script ordering, -offline payload completeness, driver/hardware behavior, and clean installed -services. The SDDM migration additionally needs real boot-cache/keyring handoff, -logout/relogin, compositor-crash and SDDM restart checks. The guest qualification -harness also injects an early first-autologin launcher failure, verifies that -the boot attempt remains consumed, and tests password-login recovery after a -manager restart. It restores the original launcher in cleanup. These checks -must run on isolated test disks with explicit opt-in; their presence in the -harness and passing source fixtures do not establish a boot result. diff --git a/image/QUALIFICATION-2026-09-26-LIFECYCLE.md b/image/QUALIFICATION-2026-09-26-LIFECYCLE.md deleted file mode 100644 index b66a7288..00000000 --- a/image/QUALIFICATION-2026-09-26-LIFECYCLE.md +++ /dev/null @@ -1,222 +0,0 @@ -# ISO lifecycle qualification, 26 September 2026 - -Status: the corrected replacement ISO passed all five UEFI QEMU scenarios -(88 checks), is checksum-verified, and is listed in iVentoy. This report does -not qualify a public release. - -## Candidate and evidence - -The candidate was built from clean source -`a991a97d099936298edfa7ad4c1747a3d0261f74` (`source_dirty=false`). Its source -archive SHA-256 is -`a0f0462f828f637c68c5dcb8233a78e5f32541223aef1b76f60cdc0d040bb1cc`. -The [build manifest](qualification-results/2026-09-26/build.json) binds the -source and artifacts. Each final report's `source_revision` records its -qualification harness revision. -The later `bd04954` change only fixes qualification-tool socket paths; it does -not change the installed ISO runtime. - -Artifacts retained on `john@10.10.0.7` for private installation and upgrade review: - -| Artifact | Path | Bytes | -| --- | --- | ---: | -| Private testing ISO | `/data/pxe/iso/CybexOS-Live-44-20260926T150946Z-99900b99.iso` | 7,633,059,840 | -| Matching unsigned testing RPM | `/data/cybexos-candidate-rpm-20260926-a991a97/cybexos-desktop-0.0.0~dev-1.20260926150946.ga991a97d0999.fc44.x86_64.rpm` | 1,720,330,844 | - -Each has an adjacent SHA-256 sidecar (112 bytes for the ISO; 139 bytes for -the RPM). Six compact JSON files in `image/qualification-results/2026-09-26/` -retain 8,929 bytes of build provenance and final qualification evidence. Digests: - -```text -ISO 7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635 -RPM e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b -``` - -Build and served-copy checksums passed. iVentoy refresh returned success, -the new filename was listed, PXE reported running, and the service was active. - -| Final UEFI scenario | Status | -| --- | --- | -| Unencrypted US | Passed: [12 checks](qualification-results/2026-09-26/plain-us.json), harness `bd04954` | -| Encrypted Dutch | Passed: [25 checks](qualification-results/2026-09-26/encrypted-nl.json), harness `bd04954` | -| Encrypted US | Passed: [25 checks](qualification-results/2026-09-26/encrypted-us.json), harness `a991a97` | -| Unencrypted Dutch | Passed: [12 checks](qualification-results/2026-09-26/plain-nl.json), harness `bd04954` | -| Older-ISO RPM upgrade and GRUB recovery | Passed: [14 checks](qualification-results/2026-09-26/upgrade-recovery.json), harness `bd04954` | - -Every scenario uses disposable serial-identified installation and guard disks, -with outbound guest networking blocked. The guard disk must remain unchanged. -A pass requires installed boot without the ISO, desktop/application checks, -selected locale/timezone/keyboard, enforcing SELinux, and removal of live-only -privileges. Fresh installs must require a sudo password. Fresh encrypted scenarios -also exercise login/keyring recovery. Upgrade/recovery checks must preserve -user Kitty edits, shell preferences and a home-directory marker. - -The prior-image baseline is -`/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. -It deliberately uses an installer-only session target. Its explicit legacy -qualification path starts the full desktop only after checking that target -and its marker; fresh candidates retain strict normal-startup requirements. - -Physical PXE client boot, Secure Boot, and this laptop's post-upgrade hardware -behavior are outside the QEMU qualification scope. - -## Reference workstation - -The reference is a Dell XPS 14 DA14260 running Fedora 44 from the ISO/RPM path. -Its runtime is under `/usr/share/cybexos`, without a source-checkout `current` -symlink. It uses encrypted Btrfs and enforcing SELinux; Secure Boot is disabled. -The battery was plugged in and paused at its 75–80% preservation thresholds. - -These observations informed the RPM channel, account migrations, hardware -continuation, diagnostics, and charge-limit status changes. The workstation -was inspected but was not upgraded, reconfigured, or rebooted for these tests. - -## Other verification - -| Check | Result | -| --- | --- | -| Initial local repository suite (historical) | All 17 stages passed, including 1,157 JavaScript tests. The opt-in live Quickshell stage was skipped. Later GTK and harness corrections have focused regression checks and current hosted CI coverage. | -| GitHub checks | Both required Fedora source/image checks passed at runtime/harness head `bd04954`. Checks for the final documentation and evidence commit are attached to [PR #1](https://github.com/DigitalPals/CybexOS/pull/1/checks). | -| Real RPM signing integration | Disposable RPM signed using a signing-subkey-only keyring; independent RPM/repository signatures, metadata binding, and tamper rejection passed. Nothing was installed or published. | -| Generic Fedora 44 VM at `86f185f` | First convergence: 135 changes. Second convergence: zero changes (`ok 220`). Uninstall: 22 changes; adopted files restored and project state removed. | -| Ephemeral PXE runner service | Actual transient user-service startup and cleanup passed. No GitHub runner was registered. | -| Reference workstation shell | Final guard passed its start/end checks: active managed MainPID 41596 was the sole Quickshell process, with a clean current-invocation journal. | -| Harness regressions | Sequential Quickshell IPC clients, unknown/persistent extras, localized console prompts, browser preflight, and bounded redacted audit failures passed focused tests. | -| GTK provisioning at `a991a97` | 26 focused tests passed, including real Ansible offline skipping and inherited-descriptor regressions. The actual task also passed against installed gsettings/dconf in private HOME/XDG directories and a private dconf profile: first run applied dark defaults, second made no changes, and explicit light/custom-theme choices survived. Temporary files and private processes were removed; workstation settings were untouched. | - -The generic VM predates later archive, installer and harness corrections; -it does not substitute for final ISO qualification. Its 1.2 GiB staging was -removed. - -## Findings and qualification corrections - -- Source archives omitted the bundled agent skill. The archive and an - extracted-tree packaging regression now include it. -- Anaconda's common-locales shortlist omitted Dutch. The installer now - enumerates its full API inventory: 180 available locales across 85 languages. -- Offline provisioning assumed `/var/lib/systemd/linger` existed. It now - creates the root-owned directory before the account marker; a real Ansible - regression covers a missing parent and a second idempotent run. -- A controlled backend lock reproduced the visible initialization-busy error. - The browser driver retries only that initial state through the UI, within - the original deadline and before any disk action. Other failures stop. -- Console bootstrapping now distinguishes echoed commands from output, - confirms Bash before Bash-specific setup, and recognizes the observed Dutch - sudo OCR error only with the sudo prefix and disposable account name. -- Welcome can start another Quickshell IPC client while the audit waits for - an earlier one. The guard now rescans and inspects every observed extra PID, - with a bound and the same final sole-managed-PID requirement. The historical - extra PID from the failed US run could not be classified retrospectively. -- Missing browser dependencies now fail before a VM/output directory is - created. Installed-audit failures retain bounded, password-redacted details - without echoing whole Python heredocs over the useful traceback. -- The US installed audit reported `Installed timezone differs` even though - Anaconda had selected UTC correctly. This Fedora workstation uses hardlinks - for `UTC` and `Etc/UTC`; the audit now compares file identity rather than - resolved path names. A generated-code regression covers hardlink and symlink - aliases and rejects a different zone. -- Recovery verification now requires the exact requested snapshot ID, matching - the snapshot tool's `recoveryBoot` string. Its regression uses the actual - index producer. The user-preference fixture also handles an omitted bar - position as the desktop's effective `top` default. -- Recovery uses an in-memory root overlay, whose encryption ancestry cannot be - verified by normal login policy. The real older-ISO run upgraded successfully - and preserved user choices, then its harness incorrectly expected autologin - on recovery boot. Recovery qualification now unlocks the encrypted disk, - waits for the SDDM greeter, signs in with the fixture password, and requires a - working desktop before restore. Session readiness precedes VT discovery, - because SSH can become available before SDDM has created a login session. -- A later recovery run verified the exact snapshot and completed its restore - command, then SSH disconnected during poweroff. The harness failed before - waiting for QEMU to exit. Shutdown now accepts SSH exit 255 only after a - guest marker confirms successful preparation and QEMU exits with status 0 - within the existing deadline. Sync, temporary-access cleanup, authentication, - timeout and abnormal-exit failures remain fatal. -- An earlier encrypted-US run timed out waiting for SSH after its first cold - reboot. The retained logs did not establish a cause. Readiness failures now - record bounded, password-redacted SSH and screen diagnostics and remove the - temporary screenshot. A standalone retry passed all 25 checks, - including SSH readiness and keyring recovery after that cold reboot. -- A replacement test launch failed before boot because its QMP socket path - exceeded Linux's Unix-socket pathname limit; normal nested release-runner - paths could do the same. The harness now allocates a short private socket - directory, records its actual location in `vm.json`, and removes it after - confirmed guest exit. Real socket-bind regressions cover long output paths, - repeated boots, retained diagnostic logs, and startup failures. The ISO - payload is unaffected. - -Modified diagnostic guests and superseded images cannot count as final proof. -Their useful findings are recorded here instead of retaining large artifacts. - -A later baseline run established a runtime provisioning defect: the GTK-default -task used `dbus-run-session -- gsettings` inside Bash command substitution. -An activated `gvfsd-fuse` inherited the output pipe, keeping Bash and offline -Ansible provisioning blocked after the settings command returned. The guest's -pipe holders were verified before terminating that one daemon to continue -diagnosis. That modified guest is excluded from final qualification. The first -`a1713ff` candidate had passed four fresh scenarios but packaged the same -defective task; it was superseded by the candidate above, which passed a new -complete qualification matrix. The fix skips GTK bus initialization -offline, leaving appearance defaults to the first desktop session. Live -provisioning uses a bounded private bus and separate regular-file captures for -each call, so a surviving service cannot hold Ansible's pipes open or corrupt -the next settings read. The modified diagnostic guest subsequently completed -the RPM upgrade, exact recovery boot and restore, baseline-version check, and -user-choice preservation; it remains excluded from final qualification. - -## Release configuration and limits - -The signed repository destination, public key, protected branch checks, -immutable-release setting, Pages configuration, signing environment, and -baseline variable were independently verified through the GitHub API. No -repository runners are registered. Signing runs on a separate hosted Fedora -container; the PXE runner receives no private signing key. See -[the release guide](../docs/iso-releases.md) for on-demand runners and gates. - -No production desktop RPM or public repository metadata has been published. -Project code is MIT; bundled third-party software and artwork retain their own -terms. Their redistribution/provenance review remains a public-release gate. - -This private qualification build explicitly includes the pinned stable channel -with its repository enabled. Because Pages has no metadata yet, ordinary DNF -operations on an installation of this candidate can fail on that repository. -For private testing before publication, disable only this repository: - -```bash -sudo sed -i 's/^enabled=1$/enabled=0/' /etc/yum.repos.d/cybexos-desktop.repo -``` - -This preserves signature checks and the pinned key. After publication, verified -channel enrollment re-enables it. `cybex update-channel status` checks local -configuration, not remote availability. Ordinary builds without -`--update-channel` ship a disabled desktop channel. - -The protected primary signing keyring is retained locally at -`/home/john/.local/state/cybexos/release-signing/DigitalPals-CybexOS` -(76 KiB, owner-only directory). Only its signing subkey was uploaded to the -GitHub environment secret. The public fingerprint is -`16C60642B7278AECE3A933C354220839FDF7099E`. - -## Cleanup - -All task build/cache/dependency staging (29 GiB), disposable VM disks, logs, -screenshots, temporary harness worktrees, and QMP socket directories were -removed after the five scenarios finished. The superseded task ISOs -`CybexOS-Live-44-20260926T114433Z-a107c90a.iso` and -`CybexOS-Live-44-20260926T123305Z-901ef7db.iso`, their checksums, and old RPM -directories `/data/cybexos-candidate-rpm-20260926` and -`/data/cybexos-candidate-rpm-20260926-a1713ff` were removed. Superseded local -results and diagnostic traces were removed after recording their findings. - -The final retained artifacts and signing keyring are listed above. The original -baseline ISO/checksum, Alpine ISO, unrelated remote checkout, and pre-existing -remote image outputs were preserved. The local image-output directory is -absent. Final process, mount, temporary-directory and worktree checks found no -task leftovers; directory inventory and disk usage were verified. - -After removing the last superseded ISO, iVentoy refresh returned -`result: success`. PXE reported `running`, `iventoy.service` was `active`, and -the image tree contained exactly the qualified candidate, original baseline, -and Alpine. The removed image was absent. Candidate ISO, baseline ISO, and -matching RPM checksum verification all passed again. No service restart was -needed. diff --git a/image/README.md b/image/README.md deleted file mode 100644 index 8a1aa248..00000000 --- a/image/README.md +++ /dev/null @@ -1,532 +0,0 @@ -# CybexOS live image - -The image packages the shared Hyprland/Quickshell desktop and offline -application set. The proposed boot path is **Cybex firmware menu → Cybex -Plymouth → live desktop/welcome → three-screen installer**. After an encrypted -installation, it is **disk unlock → automatic login → desktop**. - -Current ISO installation, desktop RPM upgrade, and recovery results are in the -[September 26 qualification report](QUALIFICATION-2026-09-26-LIFECYCLE.md), -including exact image hashes, harness revisions, and remaining release limits. -The earlier encrypted-login results are recorded in the -[September 25 installation audit](INSTALL-AUDIT-2026-09-25.md). -Earlier implementation and audit notes are historical: the -[September 23 implementation](IMPLEMENTATION-2026-09-23.md) and -[September 23 audit](AUDIT-2026-09-23.md). - -## Installation experience - -| Screen | Required choices | Defaults | -| --- | --- | --- | -| Your setup | Username, password/confirmation, keyboard | Language, detected timezone (dropdown), hostname and "Prepare this computer for a new owner" under More options; live keyboard applied before password entry, with a test field | -| Install location | Disk, identified by model and capacity; on a disk with another system, Install alongside (default) or Erase disk | LUKS2-encrypted Btrfs, shared initial disk/account password, automatic login | -| Review and install | Explicit confirmation to erase the disk, or to resize the named partition | Anaconda's actual partition plan, what is resized/created/kept, progress, completion and reboot | - -The custom Cockpit page uses Fedora 44 Anaconda's DBus storage/account/task -interfaces. Anaconda performs validation, partitioning and installation. -The full Anaconda Web UI remains available for advanced storage. The simple -flow needs at least 64 GiB for CybexOS. It erases the selected disk or, on a -disk with Windows or another system, installs alongside it: in free space, or -by shrinking only the largest partition Anaconda can shrink (a size slider; -the other system keeps its minimum plus 5 GiB). The EFI system partition is -shared and GRUB's os-prober lists the other system. Encryption can be disabled -explicitly; that path uses a normal login screen. Root stays locked, and the -new user has administrator privileges. - -Three further paths reuse the same backend and target hooks; see -[INSTALLER.md](INSTALLER.md): - -- **Unattended:** a volume labelled `CYBEXOS-SEED` (or `CIDATA`) with - `cybexos-seed.yml` installs without questions after a visible countdown that - can be cancelled. The seed names the disk by serial/WWN/model/path, must say - `erase: true`, takes only a password hash for the account, and reads the - disk passphrase and an optional one-time Tailscale key from separate files. - Remove and wipe the seed volume after use. -- **New owner (OEM):** installs without an account. The first start shows a - short graphical setup in cage before the login screen (language and - keyboard, account and password, time zone, finishing); it runs as an - unprivileged transient user and only returns answers to the root service, - which checks and applies them. Without usable graphics, or with - `cybexos.new-owner=console` on the kernel command line, the same questions - are asked on the console. On an encrypted disk the new password replaces - the temporary passphrase in every key slot. -- **Factory reset:** the first start takes a pinned factory recovery point. - `cybex factory-reset` (or Settings → Recovery points → Factory reset…) - restores it after a typed confirmation and sudo, and the next start removes - every account, home folder and other recovery point before the new-owner - setup. - -Passwords are passed through the privileged local transport and Anaconda's -in-memory interfaces. The account is stored as a password hash. CybexOS's -confirmation/progress files contain neither the password nor its hash. -Installation runs in a durable systemd worker and progress survives a browser -reload. A failed or lost installation worker requires inspection/restart; -the UI does not automatically retry disk writes. - -The account and encryption passwords start equal; changing one later does -not change the other. GNOME Keyring remains encrypted and can unlock through -SDDM's PAM stack using the briefly cached boot passphrase. An absent/expired -cache or different keyring password falls back to the application's unlock -prompt. No passwordless keyring or longer-lived password cache is created. See -[INSTALLER.md](INSTALLER.md) for interfaces, keyboard constraints and upstream -version references. - -## Desktop and first boot - -Both deployment paths consume [the shared desktop contract](../assets/desktop-contract.json), -wallpaper collection, existing Cybex Plymouth artwork, helpers, firewall zone -and sysctl policy. Bluetooth visibility matches the workstation default. -The ISO is authoritative for both paths' installation defaults; the checkout -installer consumes the same [installation policy](installation_policy.py). -The [parity contract](../docs/installation-parity.md) describes its checks. -The image contains the repository desktop and default applications; it does -not export personal plugins (including the Omarchy plugin), credentials, -monitor overrides or private launchers from the build machine. The private -portal keybinding is enabled only when its helper exists. - -The firmware menu uses the same dark background and warm accent as Plymouth -and the installer. Media checks, basic graphics and recovery entries are -preserved. Installed accounts are seeded before reboot. Live login copies -only desktop essentials before starting Hyprland; a background service copies -the large offline application seed and reports progress in the welcome -window. Reflinks are used where supported. Partially copied files never -become active, personal files/symlinks are preserved, and interrupted seeding -can be resumed with `cybex prepare-apps`. Developer tool links and editor -configuration activate after their dependencies are copied. - -On an installed system the first login opens the welcome window: the Cybex -wordmark, a scrollable row of popular Wallhaven wallpapers that apply with one -click (the shell searches, downloads and applies them over -`cybexos-runtime ipc wallpaper …`), and a Settings button. Offline, the row -shows the wallpaper folder instead. Closing the window finishes the welcome; -`cybex welcome` opens it again. The live session keeps its install-or-explore -choice. The window finds its QML beside the program, so -`python3 image/rootfs/usr/bin/cybexos-welcome` runs it from a checkout. - -The default application payload includes the desktop/media tools, Steam, -Docker, Podman/Distrobox, Tailscale, Brave, 1Password, ChatGPT, LocalSend, -Spotify, Android SDK, Rust, Claude Code, OpenCode, Codex CLI and T3 Code. -The payload is prepared for offline installation and first use. Online -services still need their accounts/network access. T3 waits for its offline -seed rather than downloading a fallback during setup. - -The temporary live account has administrative access and autologin; live -locking is disabled. The destination cleanup removes the account, its -privileges and live-only services/installer files, sets the shared firewall -policy, and rebuilds initramfs after removing live configuration. Installed -autologin requires the requested policy and verified root encryption, is -rechecked at boot, and is attempted only on the first SDDM start in each boot. -Logout and later SDDM starts return to the login screen. - -## Shared installation policy and repair - -The image package includes the reviewed Ansible baseline, account, Fish and -XPS hardware tasks under `/usr/share/cybexos/provision`. Package selection -includes the shared baseline and hardware firmware lists. Anaconda applies -account settings, service enablement, the firewall and hardware detection in -its offline target. The installed account starts with Fish and password-required -sudo. The installer offers an explicit opt-in to passwordless sudo; existing -saved choices are preserved on later configuration runs. -Both Codex and Claude use the interactive Fish aliases in the shared config. - -The installer records its choices in `/etc/cybexos/config.yml`, in the same -schema as the checkout installer: the account, the identity Anaconda set, the -inventory's feature defaults, personal defaults enabled for the new account -and the autologin decision made after the encryption check. The identity stays -Anaconda's (`manage_system_identity: false`). `cybex configure` asks the policy -questions again (connected widgets, local-network ports, passwordless sudo and -Polkit, sudoless Docker, autologin, personal defaults, Docker and Tailscale), -saves the answers and reapplies them; `cybex configure --check` prints them. -Autologin is saved only when the root filesystem is verifiably encrypted. -`cybex repair` creates a missing file for older installations, without opting -their account into personal defaults. `cybex uninstall` is not available: the -desktop is the operating system of an ISO installation. - -Installed accounts receive the same user configuration as a checkout -deployment, from shared task files: the session environment -(`environment.d`), portal preferences, Git/SSH/XDG directory/Brave/Firefox -defaults, the agent skill links and the Voxtype GPU backend (the CPU build -remains where no GPU is usable). Kitty settings live in the managed -`~/.config/kitty/cybexos.conf` fragment that repair keeps current; the user's -`kitty.conf` only includes it. The shell unit carries the inventory's feature -defaults, and `/etc/systemd/user/quickshell.service.d/50-cybexos-features.conf` -applies the saved choice; deselected connected widgets also mask the Hermes -bridge. Users are lingering, Docker is socket-activated rather than started -at boot, and deselected Docker/Tailscale units are turned off. On detected XPS -hardware, the session starts the external-monitor watcher. - -The image composes without weak dependencies, so packages Fedora normally -pulls in that way are explicit shared selections: the GnuPG pinentry, -`fwupd-efi`, the libcamera PipeWire camera path, `nss-mdns` (with authselect's -`with-mdns4`), `hunspell-en`, `docker-buildx`, `ibus-setup` and common tools. - -Fingerprint and security-key sign-in is the same on every installation: the -kickstart selects authselect's `with-fingerprint`, and installation, repair and -upgrade reconciliation run the shared `cybexos-auth-policy` helper, which adds -the sudo, Polkit and lock-screen key steps ([details](../docs/security.md)). - -After boot, `cybexos-hardware-setup.timer` applies the detected hardware role -when network access is available. Failed setup remains visible in the service -journal. A known camera ABI mismatch is cached for that kernel and provisioning -payload, avoiding repeated builds; a new kernel or changed payload retries it. -`cybex doctor --json` reports installed-system diagnostics. Hardware setup -status lives in `/var/lib/cybexos/hardware-status.json` and is shown in the -welcome window; a pending camera setup resumes after rebooting into the same -kernel. Successful hardware setup is also keyed to the kernel. - -The same setup runs the shared NVIDIA role on machines with an NVIDIA GPU: it -installs RPM Fusion's driver, builds it for the installed kernel, queues -Secure Boot key enrollment with a one-time password shown in the welcome -window and a notification, and records `/var/lib/cybexos/nvidia-status.json`. -A pending restart or enrollment keeps the hardware state pending until the -next boot confirms the driver; a failure keeps `nouveau` running and offers -`cybex repair --hardware`. See [NVIDIA graphics](../docs/nvidia.md). - -ISO installations use RPM upgrades for desktop changes. The default image has -no enabled desktop RPM update channel; inspect channel state with -`cybex update-channel status --json`. Enrolling a channel requires its reviewed -public configuration and complete signing-key fingerprint. RPM upgrades -schedule versioned account and machine policy through -`cybexos-reconcile.service` and its timer. Inspect or retry that work with -`sudo /usr/libexec/cybexos-reconcile --status` or `--retry`. `cybex repair` -reapplies the policy bundled with the installed RPM; it does not deploy a -source checkout. - -For installations made before this integration, preview and apply the migration -from a reviewed checkout: - -```bash -./image/repair-installed --user "$USER" -sudo ./image/repair-installed --apply --user "$USER" -``` - -This installs missing packages, reconciles signed vendor repositories, applies -shared machine/account settings, repairs session ordering and updates the -affected desktop helpers, `cybex configure`, the shell unit, the agent skill and -the XPS monitor watcher. It backs up replaced files under -`/var/lib/cybexos/backups/install-repair-*`. Home data and independent user -overrides remain in place; the managed Fish configuration is updated. Open a -new terminal for Fish, and log out/in to load the detected hardware flag into -Hyprland. The desktop shell restarts once during repair. - -DNF checks initialize the user's missing metadata and signing-key cache while -keeping signature verification enabled. Failed repositories fail the check. -Vendor repository IDs are reconciled and DNF5 overrides preserve the reviewed -key policy across vendor RPM updates. LocalSend gets TCP/UDP 53317 independently -of optional Steam/network services, and selected files use the Flatpak document -portal. The session target avoids the implicit ordering cycle with vendor -services that start after `graphical-session.target`. - -The release tooling prepares immutable-tagged desktop RPM releases and signed -repository metadata for -[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). -The public channel configuration is `image/channels/stable.json`. The signing -environment and baseline ISO are configured; Pages remains empty until a -reviewed release passes its gates using an on-demand PXE runner. See the -[release instructions](../docs/releasing.md). The repository code is MIT -licensed; a separate third-party software and asset redistribution audit is -still required before public distribution. Until publication and channel -enrollment, Fedora, vendor and Flatpak updates work independently. - -## Source checks: no ISO or VM - -```bash -# Fedora dependencies: python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 -# pykickstart nodejs24 gnupg2 git ripgrep -PYTHONDONTWRITEBYTECODE=1 python3 image/check-source -./tests/run - -# Alternatively, the image fixture environment: -docker build -f image/Containerfile.tests -t cybexos-image-tests:44 image -docker run --rm -v "$PWD:/source:ro" cybexos-image-tests:44 -``` - -`image/check-source` exercises Qt welcome loading, user isolation, interrupted -seeding, branding, installer confirmation/worker/Anaconda adapter contracts, -artifact delivery/cache, firmware discovery, iVentoy responses and release -metadata. CI runs these fixtures without an image build or VM. - -`image/browser-smoke.cjs` optionally exercises the real HTML/CSS/JavaScript -against a fake Cockpit transport in a headless Chromium browser. It contacts -only its temporary loopback fixture server. Install `playwright-core@1.63.0` -in a disposable dependency directory and set `NODE_PATH` to its `node_modules`: - -```bash -CYBEXOS_BROWSER=/usr/bin/brave-origin node image/browser-smoke.cjs -``` - -It checks the minimal flow, encryption default, erase confirmation, password -clearing, progress/reload recovery, a narrow viewport, installing alongside -with a resize, new-owner mode, and the seed countdown, cancel and error screens. Screenshots are -only written when `CYBEXOS_UI_SCREENSHOTS` specifies a directory. - -## Builder - -Python 3.11+, PyYAML and writable `/dev/kvm` are required. Host packages: - -- Fedora: `qemu-kvm qemu-img xorriso openssh-clients python3-pyyaml edk2-ovmf`. -- Debian: `qemu-system-x86 qemu-utils cloud-image-utils openssh-client python3-yaml ovmf`. - -The VM smoke and installation tests also need Tesseract with English data: -`tesseract tesseract-langpack-eng` on Fedora, or `tesseract-ocr tesseract-ocr-eng` -on Debian. It recognizes the disk-unlock prompt and a terminal execution -marker before the harness types private fixture input. The image builder -itself does not require OCR. - -QEMU runs with `-no-user-config`, so host `/etc/qemu` configuration neither -shapes nor blocks the builder or test VMs. - -Allow approximately 180 GiB free staging space and 24 GiB available RAM for -the complete application build. OVMF is needed for later UEFI qualification. -`cloud-localds`, `xorriso`, `genisoimage` or `mkisofs` can create the builder's -cloud-init seed. Preflight checks resources and tools without creating files, -downloading or starting a VM: - -```bash -./image/build --preflight -``` - -When a build is authorized: - -```bash -./image/build -# Optional: --output /path/to/new-task-directory --memory 24576 --cpus 8 -# Optional: --compression zstd (xz remains the established default) -# Optional: --update-channel /path/to/public-update-channel.json -``` - -Default output is `~/.local/share/cybexos/images/BUILD-ID/`; each ID combines -UTC time and a random suffix. Output must be empty and outside `/data/pxe/iso`. -The builder: - -1. Verifies the pinned Fedora Cloud 44 base and starts a disposable KVM guest. -2. Transfers an explicit source allowlist, build provenance and, if supplied, - a validated public update key/channel. Personal runtime directories and - private signing keys are not inputs. -3. Installs applications, assembles the desktop RPM, composes and compresses - the image, with named phases, elapsed times and failure summaries. -4. Reuses only checksum-pinned cached upstream archives/fonts/COPR keys. - Moving npm, Flatpak and repository content is not treated as verified cache. -5. Verifies all copied checksums and atomically publishes `artifacts/`, with - a uniquely named ISO, desktop RPM, manifests and `SHA256SUMS`. -6. Stops the guest and removes temporary disks/keys on success, failure or - normal interruption. Logs and small provenance records stay with output. - -The persistent cache defaults to `~/.cache/cybexos/image`; `--cache` changes -it. `build.json` records revision, dirty state, source archive/configuration -hashes, host tools and timings; installed `/usr/share/cybexos/build.json` -identifies the source build. `applications.json`, `packages.txt` and -`package-manifest.json` record the application contract, RPM versions and -staged payload. The latter is not an extracted-RPM audit. -`rpm-inventory.tsv` and `flatpak-inventory.tsv` are raw inventories of the -image root; the host turns them into the ISO's SPDX 2.3 SBOM, -`CybexOS-Live-44-BUILD-ID.spdx.json` (see -[the ISO release guide](../docs/iso-releases.md#software-bill-of-materials)). - -Moving Fedora/vendor updates mean builds are not byte-for-byte reproducible. -The compose adapter verifies upstream RPM signatures before installation; -local upstream application RPMs are inventory-checksummed at the transaction -boundary. The locally built desktop RPM becomes signed through the release -step below. zstd is available for measurement, but compression size/startup -benchmarks have not been run; xz remains the default. - -`--debug-on-failure` retains a failed builder only while the command remains -running; `builder.json` contains its SSH command. Interrupt when inspection -is finished to trigger cleanup. Do not retain superseded large test artifacts. - -## Desktop updates and signing - -Image installations use `cybex update` for DNF/Flatpak updates. Desktop package -versions come from `VERSION`, with a timestamp/revision release suffix and -installed provenance. Epoch 1 permits upgrading the older hardcoded alpha -version. Existing checkout installations retain their source updater. - -A build without `--update-channel` ships a **disabled** desktop update channel. -The release gate explicitly enables `image/channels/stable.json`; a custom or -private channel can also be supplied with `--update-channel` at build time. -Local `cybex update-channel status` output does not prove that remote metadata -is available. For a custom channel, prepare a public configuration, for example: - -```json -{ - "baseurl": "https://YOUR-HOST/cybexos/44/x86_64", - "fingerprint": "YOUR-COMPLETE-OPENPGP-PRIMARY-FINGERPRINT", - "key_file": "CYBEXOS-desktop.asc" -} -``` - -`key_file` is public ASCII armor, relative to this JSON file or an absolute -path. The builder checks its full fingerprint, rejects private/revoked/expired -key material and transfers only the normalized public channel. Both package -and repository metadata signature checks are enabled. Keep the URL/key stable -across updates; key rotation needs an explicit migration. - -After an authorized build, create a signed local repository from existing -RPMs on a Fedora signing host (`rpm-sign`, `createrepo_c`, `gnupg2`): - -```bash -./image/release-repository /path/to/artifacts/cybexos-desktop-*.rpm \ - --output /path/to/new-release-directory \ - --public-key /path/to/CYBEXOS-desktop.asc \ - --key YOUR-COMPLETE-OPENPGP-PRIMARY-FINGERPRINT \ - --baseurl https://YOUR-HOST/cybexos/44/x86_64 -``` - -The RPM must have been built with that same enabled channel; a disabled or -different bundled channel is rejected. The tool signs copies, independently -verifies the RPM signatures using only the public key, signs/verifies metadata -and the release manifest, writes checksums, and atomically publishes a new -local directory. Original RPMs and the system RPM keyring remain untouched. -`--gnupghome` can select an existing signing keyring. Hosting/deployment is a -separate action; the tool does not upload anything or create signing keys. The -public GitHub Pages/RPM release tooling, baseline ISO variable, and signing -environment are configured. First publication still requires a reviewed -queued release, an operator-started ephemeral PXE runner, and passing release -gates. The repository-code MIT license does not resolve the separate -third-party software and asset redistribution audit. See -[the release instructions](../docs/releasing.md). - -## ISO qualification and PXE publication - -These commands are opt-in operations, **not part of source checks**. The -qualification report above identifies the exact tested runtime source and -artifacts; later runtime changes require a new build and qualification. -Completed testing ISOs belong in `/data/pxe/iso`; keep incomplete builds outside -that tree. - -On the iVentoy host, `image/publish-pxe /path/to/artifacts` verifies the artifact -set and prints a plan. Adding `--execute` copies the ISO/checksum through -staging, verifies them before publication, preserves existing images, then -requires iVentoy refresh success, completed refresh, running PXE, filename -presence and an active service. Identical-file retries are supported. -Staging uses the nearest writable ancestor of the served directory on the same -filesystem (for example `/data` when `/data/pxe` is root-owned), because -completed files are hardlinked into place; `--staging` selects another one. The -default API contract is iVentoy 1.0.41; review its installed UI after upgrades -and provide `--api-contract` for a changed schema. A failed refresh retains -the verified ISO for inspection and does not automatically restart PXE. - -When boot tests are separately authorized: - -```bash -./image/test-live /data/pxe/iso/CybexOS-Live-44-BUILD-ID.iso \ - --output /path/to/new-live-test --execute-vm - -./image/qualify /data/pxe/iso/CybexOS-Live-44-BUILD-ID.iso \ - --output /path/to/new-install-test --execute-vm --erase-disposable-disk -``` - -Qualification boots the ISO's default **Install CybexOS** entry, checks that -only the installation session runs, and drives the installer that session -opened. `--boot-entry live` instead selects **Try CybexOS live** with arrow keys, -audits the live desktop and offline applications, and starts the installer -itself. The smoke test (`image/test-live`) always selects the live entry. - -The harness detects Fedora/Debian UEFI firmware (raw or qcow2), uses bounded -readiness checks and blocks guest outbound networking. `--firmware bios` -selects BIOS. An adjacent `ISO-FILENAME.iso.sha256` must verify before a VM -can start. The smoke test checks the live desktop/applications; qualification -also installs to its newly created, serial-identified virtual disk, checks that -a second guard disk is unchanged, and reboots without the ISO. It verifies the -selected locale, timezone and keyboard, desktop defaults, SELinux and -live-account cleanup. Plain scenarios require password login; encrypted -scenarios check disk unlock and autologin, then verify an encrypted GNOME login -keyring without requesting an unlock, store a synthetic secret, and confirm -that secret is available after another cold boot. Logout, compositor crash and -SDDM restart must return to a greeter; password login must restore both the -desktop and keyring. Another cold boot temporarily disables the cached-password -PAM module in the disposable guest, checks that the vault stays locked, then -verifies recovery through normal password login. The final cold boot injects -a single early launcher failure in the guest. It requires a working greeter, -a consumed autologin attempt, disabled autologin after restarting SDDM, and -successful password-login recovery; the original launcher is then restored. -Chromium and Playwright operate the actual guest Cockpit installer through a -restricted SSH tunnel. The separate browser smoke fixture provides source -checks; it does not replace the graphical installation and boot results. - -Qualification reports use the versioned scenario evidence inventory in -`image/qualification_contract.py`, shared by the producer and GitHub release -verifier. Missing encrypted login/keyring checks, unknown execution modes, -stale artifact/source digests and incomplete public updater recovery evidence -fail admission. Legacy reports must be regenerated, not relabelled. - -The existing encrypted-US upgrade guest also qualifies the public updater; -no extra locale matrix or guest is added. Invoke it with the existing -`--candidate-rpm DESKTOP.rpm --candidate-offline-rpm OFFLINE.rpm --recovery-check` -options (and `--candidate-dependencies DIR` when needed). Both candidates and -`--recovery-check` are required for candidate qualification. After the existing -RPM/manual-recovery check, the harness authenticates normally and starts the -public durable updater. A root-owned guest-only DNF adapter selects the same -sealed offline RPM transaction and runs real DNF with repositories disabled; -it changes no signature checks, sudo rules, health checks or recovery code. -The harness kills the updater's complete cgroup after DNF applied the candidate -while the durable journal still says `applying`. A detached harmless writer -must stop before reboot. The installed recovery service then restores root and -vendor files before login and automatically restarts. An observer saves that -first boot's ordering evidence outside root. Another cold boot must retain the -restored root UUID and terminal journal, with baseline package identities and -post-checkpoint personal edits intact. Finally the public updater must commit -the exact candidate RPMs successfully, pass desktop health and leave no workers. -SSH access is bootstrapped again only after the automatic recovery restart, -through the normal authenticated console and login barrier. This supports a -restored baseline with SSH disabled and its firewall closed without enabling -SSH or opening the firewall permanently; the next cold boot uses the same -normal bootstrap fallback. -The same guest runs a harmless detached writer through the installed -provisioning helper with a short real systemd timeout, then proves the cgroup is -empty, its writes stopped and the next locked run cannot overlap it. -Provisioning uses `systemd-run --wait` with journal output: Fedora 44's systemd -259 rejects `--pipe` for this service. Each launch has a unique journal identifier -so diagnostics survive unit collection and exclude earlier launches of the -fixed containment unit. The caller prints progress and finite journal snapshots -every 15 seconds and a final snapshot after draining the complete cgroup. -Each journal read is limited to five seconds and 100 entries; displayed batches -are limited to 32768 characters. No journal follower is left running, and a -journal read failure does not replace the worker's failure status. -`public_updater` records run IDs, source updater digest and recovery root/boot -identities; fixture tests exercise failure transitions but do not qualify a VM. -The release gate's existing upgrade invocation runs this scenario automatically. - -Installation qualification uses four virtual CPUs, 16 GiB RAM and two new -100 GiB sparse disks: an installation disk and an untouched guard. Fresh encrypted -scenarios perform one live boot and four installed cold boots; the three -logout/crash/restart cases reuse the running installation. Plain scenarios -perform one live boot and one installed boot. The installer deadline defaults -to 30 minutes (`--install-timeout 1800`); boot, -application seeding and recovery have separate bounded waits. Runtime has not -yet been benchmarked. - -The ISO path and checksum are mandatory even when QEMU runs locally. The -harness resolves the path and rejects a symlink to an image outside -`/data/pxe/iso`. `image/publish-pxe` runs on the actual iVentoy host and requires -its local service and API; it cannot publish remotely using only an HTTP URL. -A build workstation needs filesystem access to that verified publication or -must run qualification on the PXE host. A local directory with the same name -does not establish that PXE publication and refresh succeeded. - -`--hold` on the smoke test allows inspection using `image/vm-control` and -`vm.json`. By default cleanup removes disks, credentials, screenshots and VM -logs; small JSON reports remain. `--keep-artifacts` is only for unresolved -diagnostics, and retained paths/sizes must be reported and later cleaned. -The graphical bootstrap waits for the desktop and a terminal execution marker -before private input; it stops instead of blindly retrying passwords. -Physical GPUs, Secure Boot, international early-boot password entry, screen -lock, suspend/resume and real application/account credential behavior need -separate checks. - -## Graphics and release boundary - -The payload includes Fedora kernel drivers, Mesa OpenGL/EGL/Vulkan and explicit -AMD, Intel and NVIDIA firmware, plus DRM in the generic live initramfs. It does -not force a GPU vendor. Nouveau/NVK support depends on the GPU; proprietary -NVIDIA drivers are not bundled: installed systems with an NVIDIA GPU build -RPM Fusion's driver after the first boot. Investigate graphics with `lspci -nnk`, -`hyprctl monitors all` and `journalctl -b -k`; basic-graphics recovery intentionally -disables normal modesetting. - -The repository's original code and configuration are MIT-licensed. That does -not grant redistribution rights for third-party packages, artwork, fonts, -trademarks, or bundled images. The ISO and its application payload still need -a documented redistribution audit before public release; see -[licensing and asset provenance](../docs/licensing.md). A passing VM gate -also does not qualify physical hardware or Secure Boot. diff --git a/image/VALIDATION.md b/image/VALIDATION.md deleted file mode 100644 index 64fca0a4..00000000 --- a/image/VALIDATION.md +++ /dev/null @@ -1,195 +0,0 @@ -# Private alpha validation — 2026-09-05 - -This record predates the rename to CybexOS and keeps the former -`fedora-config` names of the artifacts that were validated. - -Validated ISO: `FC-LIVE-44.iso`, Fedora 44, desktop RPM -`fedora-config-desktop-0.1.0-0.1.alpha.fc44.noarch`. - -SHA-256: - -```text -3358a9dc418154b1ccfa809d00ccf8624d9c706b3571a34c6ddcfc401a2172e1 -``` - -The final artifacts are in `~/.local/share/fedora-config/images/alpha-01/`. -This record applies to this ISO; rebuilds use moving upstream repositories. - -| Check | Result | -| --- | --- | -| Six account-isolation and welcome/QML tests | Passed | -| Kickstart validation, including the interactive cleanup hook | Passed | -| ShellCheck and application desktop-file validation | Passed | -| Upstream RPM signature checks during composition | Passed | -| BIOS live boot, welcome and Quickshell audit | Passed | -| UEFI live boot, welcome and Quickshell audit | Passed | -| Graphical Anaconda installation, internet route removed before confirmation | Passed | -| Whole-disk installation with encrypted Btrfs root/home | Passed | -| Installed target cleanup and account/session configuration | Passed | -| UEFI boot from the installed disk without an ISO | Passed | -| Disk unlock, GDM login and installed Hyprland welcome | Passed | -| Installed personalization button and Quickshell audit | Passed | -| ISO/RPM checksums after copying to the delivery directory | Passed | - -The installation test used QEMU/KVM with a q35 machine, four virtual CPUs, -8 GiB RAM, virtio graphics, a blank 48 GiB virtual disk, and OVMF without -Secure Boot. The actual Anaconda Web UI was driven through a private SSH -tunnel. Temporary SSH access was added for inspection after boot; the ISO -and installer were not patched during the final test. - -Before the first installed boot, the target had no live account, live sudo -or Polkit grants, live startup unit, live dracut configuration, autologin, or -test SSH key. Root was locked and SSH disabled. The installed account had -Hyprland selected in AccountsService. The installed system booted with -SELinux enforcing and encrypted Btrfs mounted at `/` and `/home`. - -Each live shell test checked the service MainPID against every `qs` PID and -checked the current invocation's journal for QML errors. The final installed -test began and ended with one healthy service-owned Quickshell process. -Hyprland reported no configuration errors. Qt emitted software-rendering and -portal-registration warnings in the VM; the tested welcome and settings -interfaces rendered and worked. - -Diagnostic logs and screenshots are retained under -`~/.local/state/fedora-config/iso-research/`. The delivery directory includes -selected evidence in `validation/`. Test VMs are stopped; temporary host SSH -keys are removed. The final installed test disk is retained separately under -`images/live-test-08/installed.qcow2` for inspection. - -Not qualified by this test: physical hardware, Secure Boot, BIOS installation -to disk, dual boot, manual partitioning, recovery, and hardware-specific -graphics or networking. Public distribution also needs a repository license, -redistribution/branding review, and signed desktop RPM update hosting, as -described in [README.md](README.md). - -## Testing image rebuild — 2026-09-06 - -Built the current source with `image/build` and placed the requested testing -image at `/data/pxe/iso/CybexOS-Live-44.iso` (2,293,989,376 bytes). Its adjacent -`CybexOS-Live-44.iso.sha256` records the verified delivery checksum: - -```text -23cfb3c10e73955298da1ae156957e9a9d27da9f1a0c43508554f2dac6871061 -``` - -Composition, upstream package signature checks, graphics-package checks, -installer-tool checks, and artifact checksum verification passed. Inspection -with `xorriso` confirmed BIOS and standard UEFI boot entries. This rebuild -has not been boot-tested and does not inherit the previous image's validation. -The optional `mkefiboot` Mac HFS+ image step reported a mount failure; Mac boot -support is unverified. Temporary build outputs, logs, and the builder VM -workspace were removed; only the ISO and its checksum were retained. - -## Complete offline application image — 2026-09-06 - -Testing image: `/data/pxe/iso/CybexOS-Live-44-full-apps-20260906.iso` -(7,181,475,840 bytes, 6.69 GiB), with an adjacent `.sha256` file. - -```text -440618fa5115b5939016e5b33143bcd46bc334c2387e849d3cbadfbc1c1e26d2 -``` - -This image includes 140 explicit native application package selections, -1,693 installed RPMs including dependencies, all three configured Flatpaks -and their runtimes, and the offline user application/toolchain seed. -The application and CLI are named `cybex`; `fedora-config` remains a -compatibility command. - -| Check | Result | -| --- | --- | -| All 16 source-check stages, 11 image tests, and 3 application-default tests | Passed | -| Upstream signatures, pinned payload checksums, and complete application manifests | Passed | -| Uninterrupted UEFI live boot with outbound networking blocked | Passed | -| Fastfetch, AI CLIs, Rust, Android tools, Neovim, T3 Code digest, and dictation model offline | Passed in live and installed accounts | -| Graphical Anaconda installation onto a blank 100 GiB virtual disk, entirely offline | Passed | -| Graceful shutdown and UEFI boot from the installed disk without a CD-ROM | Passed | -| New-account Hyprland session, installed welcome, and live-account cleanup | Passed | -| Sole service-owned Quickshell process and current journal audit | Passed before and after live/installed checks | -| SELinux enforcing, no Hyprland configuration errors, Docker and Tailscale active | Passed | -| Delivered ISO checksum and iVentoy refresh/list/service checks | Passed | - -Tests used QEMU/KVM, four virtual CPUs, 16 GiB RAM, virtio graphics and OVMF -without Secure Boot. The installed root and home use Btrfs. The test harness -now allows 150 seconds for first-login seed copying and flushes guest writes -before stopping a held VM. The final installation was shut down normally; -abruptly stopping an earlier test had lost its final unflushed filesystem -changes. No image modification was needed for the successful repeat. - -Temporary builder/test VMs, disks, logs, screenshots, and duplicate build -outputs were removed. Only the delivered ISO and checksum remain from this -build. Existing PXE images were preserved, and iVentoy remained running. -Physical hardware, Secure Boot and BIOS installation were not tested for -this image. - -## Simple installer VM installation — 2026-09-25 - -Build `20260925T140839Z-5f33cb32` from commit `a2d31c2`, published as -`CybexOS-Live-44-20260925T140839Z-5f33cb32.iso`. - -SHA-256: - -```text -c8d527fef5628ffbf575414e962d27a44af483869a14d1a1703b79d96cad142b -``` - -This was a manual run on the PXE host, not `image/qualify`, which needs -Tesseract there. The VM booted the ISO's kernel with a serial debug shell to -enable SSH, launched `liveinst` inside the live Hyprland session and drove -`cybexos-installer-backend` with the page's requests. - -| Check | Result | -| --- | --- | -| Inventory, geolocated timezone (Europe/Amsterdam), live keyboard | Passed | -| Plan and confirmation of encrypted Btrfs on a blank 100 GiB virtual disk | Passed | -| All 41 Anaconda steps, both CybexOS `%post` scripts | Passed | -| Target policy: verified encrypted root, `autologin: true` | Passed | -| UEFI boot from the installed disk, passphrase, automatic login to the welcome window | Passed | - -The previous build failed at "Running post-installation scripts": -`firewall-offline-cmd --set-default-zone=cybexos` exits 16 -(`ZONE_ALREADY_SET`) because the live image already defaults to that zone. -Anaconda then skipped the rest of both scripts plus its log copy and SELinux -context tasks. Such installations boot, but show the SDDM greeter. - -Tests used QEMU/KVM, eight virtual CPUs, 16 GiB RAM, virtio graphics and OVMF -without Secure Boot. Keyring unlock, logout/crash recovery, physical hardware -and BIOS installation were not tested. The VM, its disk and screenshots were -removed. - -## Installer diagnostics and installation-only boot — 2026-09-26 - -Build `20260926T055804Z-dbdd33d6` from commit `c2afe14`, published as -`CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. - -SHA-256: - -```text -9b6d480e558590dfc257b6b661f221e3c479c9703ebcefa5f5dad517018c235a -``` - -The previous image (`20260925T194305Z-189bcf79`) failed every installation at -"Running post-installation scripts": offline provisioning copied the lingering -marker into `/var/lib/systemd/linger`, which does not exist in a fresh target. -Running that image's `%post` in a chroot of its root filesystem reproduced the -failure; with the fix, the complete script, the target helper and `dracut` -succeeded. The builder now performs that check on every composed image, and -it rejected the old image's root filesystem with the same error. - -`image/qualify` passed all 21 checks on UEFI QEMU, booting the default -**Install CybexOS** entry: the installation-only session (no Quickshell, app -seeding or dictation; installer opened by itself), encrypted installation, -installed boot without the ISO, encrypted Btrfs, autologin, desktop parity, -live cleanup, SELinux, and every keyring/logout/crash/manager-restart case. -The UEFI GRUB, the embedded EFI image and BIOS isolinux menus each list the -Install and Try-live pairs. The user then installed the published ISO on -physical hardware successfully. - -A first build showed Hyprland's random background splash, one of which reads -"Segmentation fault (core dumped)", behind the installation-only session; it -was not a crash (no core dumps). The published build disables the splash. -QEMU screendumps of the fullscreen installer can appear blank because of -direct scanout; a compositor capture showed the page rendered normally. - -Not exercised on a real installation: the log panel's `log` and `export-logs` -requests (covered by fixtures and the headless browser test), the live-desktop -entry after the menu change, BIOS, and Secure Boot. diff --git a/image/application_payload.py b/image/application_payload.py deleted file mode 100644 index fe4c8cc3..00000000 --- a/image/application_payload.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Portable filesystem helpers for the offline application seed.""" -import os -from pathlib import Path -import re - - -def include_applications(kickstart: str, packages: list[str]) -> str: - """Select apps directly, preserving DNF multilib selectors and user removals. - - Making every app a desktop RPM dependency would let removing Steam also - remove the desktop. Explicit compose selections install the full defaults - without coupling later application removals to the graphical session. - """ - section = re.search(r"(?m)^%packages[^\n]*\n", kickstart) - if section is None: - raise ValueError("The image kickstart has no package section") - offset = section.end() - return kickstart[:offset] + "\n".join(packages) + "\n" + kickstart[offset:] - - -def relativize_seed_links(seed: Path, original_home: Path) -> None: - """Relocate native installers' HOME links without changing system links.""" - for link in seed.rglob("*"): - if not link.is_symlink(): - continue - target = os.readlink(link) - if target.startswith(str(original_home) + "/"): - target = os.path.relpath(seed / Path(target).relative_to(original_home), link.parent) - link.unlink() - link.symlink_to(target) diff --git a/image/applications b/image/applications deleted file mode 100755 index 936bedda..00000000 --- a/image/applications +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -"""Assemble offline apps using the same pinned installers as the workstation.""" -import configparser -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess - -import yaml -from application_payload import relativize_seed_links -from source_build_provenance import collect as source_build_provenance -from system_packages import DEPENDENCY, contract as system_package_contract - -ROOT = Path(__file__).resolve().parents[1] -BUILD = Path("/home/builder/build") -SEED = Path("/home/cybex-seed") - - -def run(*args, **kwargs): - return subprocess.run(args, check=True, **kwargs) - - -def copy_tree(source, target): - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir() and not source.is_symlink(): - shutil.copytree(source, target, symlinks=True, dirs_exist_ok=True) - elif source.is_symlink(): - target.symlink_to(os.readlink(source)) - else: - shutil.copy2(source, target) - - -# Ansible tasks whose packages the image installs. image/tests checks that -# every other package-installing task in roles/ is excluded deliberately. -PACKAGE_TASKS = ( - ("roles/apps/tasks/packages.yml", { - "Install signed vendor applications", "Install Steam 32-bit graphics userspace", - }), - ("roles/apps/tasks/fonts.yml", {"Install shell and browser fallback fonts"}), - ("roles/base/tasks/main.yml", {"Install Docker packages when selected"}), - # Firmware must be available at the first physical boot, before the - # detected hardware role runs its machine-specific configuration. - ("roles/xps-2026/tasks/packages.yml", { - "Install explicit Panther Lake firmware, media, and regulatory packages", - "Install full Panther Lake video-codec acceleration", - }), - ("roles/desktop/tasks/titlebars.yml", {"Install native titlebar build dependencies"}), - ("roles/desktop/tasks/notification-images.yml", {"Install native notification image build dependencies"}), - ("roles/desktop/tasks/main.yml", {"Install stable Hyprland and desktop integration packages", - "Install Fedora fuzzel and matugen rather than COPR variants"}), -) - - -def manifest_package_name(selection, selectors): - """Use bare names for RPM queries; the shared RPM must enforce constraints.""" - dependency = DEPENDENCY.fullmatch(selection.strip()) if isinstance(selection, str) else None - if not dependency: - raise ValueError(f"Unsupported application package selection: {selection!r}") - name, operator, version = dependency.groups() - if operator: - constraint = f"{name} {operator} {version}" - if constraint not in selectors: - raise ValueError("Application package constraint is not represented in the shared RPM contract: " + constraint) - return name - - -def package_names(): - """Read package groups from the application and desktop contracts.""" - defaults = yaml.safe_load((ROOT / "roles/apps/defaults/main.yml").read_text()) - baseline = yaml.safe_load((ROOT / "roles/base/defaults/main.yml").read_text()) - packages = list(baseline["base_required_packages"]) - system_packages = system_package_contract(ROOT) - packages.extend(system_packages['packages']) - # The workstation skips unavailable optional packages at converge time. - # The compose has no --ignoremissing, so one Fedora drops fails the build - # visibly instead of shipping an image that silently lacks it. - for key in ("apps_required_fedora_packages", "apps_optional_fedora_packages", "apps_omawrite_packages", - "apps_developer_fedora_packages", "apps_podman_packages", "apps_steam_packages"): - packages.extend(defaults[key]) - for path, names in PACKAGE_TASKS: - for task in yaml.safe_load((ROOT / path).read_text()): - if task.get("name") in names: - selected = task["ansible.builtin.dnf"]["name"] - packages.extend([selected] if isinstance(selected, str) else selected) - packages += ["tailscale", "java-25-openjdk-devel", "rpmfusion-free-release", "rpmfusion-nonfree-release"] - selectors = set(system_packages['selectors']) - return sorted({manifest_package_name(package, selectors) for package in packages}), \ - defaults["apps_flatpaks"] + defaults["apps_steam_flatpaks"] - - -def application_repositories(repo_directory=Path("/etc/yum.repos.d")): - """Load every reviewed vendor policy; missing files must stop the build.""" - policies = configparser.ConfigParser(interpolation=None) - repo_files = [*repo_directory.glob("rpmfusion-*.repo")] - # Brave Origin is published in the Brave Browser repository. Package - # names and repository filenames are independent contracts. - repo_files += [ROOT / "roles/apps/files" / (name + ".repo") - for name in ("brave-browser", "1password", "chatgpt", "tailscale")] - for path in repo_files: - # ConfigParser.read silently ignores missing files, which otherwise - # hides a renamed policy until dependency resolution during compose. - with path.open() as stream: - policies.read_file(stream, source=str(path)) - for section in list(policies.sections()): - if not policies[section].getboolean("enabled", fallback=True): - policies.remove_section(section) - elif not policies[section].getboolean("gpgcheck", fallback=False): - raise RuntimeError(f"Unsigned application repository: {section}") - return policies - - -def main(): - if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "image-builder": - raise RuntimeError("Application preparation is only allowed in the disposable image builder") - environment = dict(os.environ, ANSIBLE_ROLES_PATH=str(ROOT / "roles"), - ANSIBLE_LOCAL_TEMP=str(BUILD / "ansible-tmp")) - run("ansible-playbook", "-i", "localhost,", str(ROOT / "image/applications.yml"), - "--extra-vars", json.dumps({"primary_user": "cybex-seed", "primary_group": "cybex-seed", - "primary_home": str(SEED), "config_repo": str(ROOT)}), env=environment) - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - packages, flatpaks = package_names() - # The upstream helper already verified these exact inventory digests. - # Recheck the bytes when admitting them into the compose's local repo. - repo = BUILD / "repo" - repo.mkdir(exist_ok=True) - pinned_rpms = {} - for app in inventory["github_release_apps"]: - if app["kind"] != "rpm": - continue - matches = list((Path("/var/cache/cybexos-upstream") / app["name"]).rglob("*.rpm")) - expected = app["checksum"].removeprefix("sha256:") - matches = [file for file in matches if hashlib.file_digest(file.open("rb"), "sha256").hexdigest() == expected] - if len(matches) != 1: - raise RuntimeError(f"Missing verified RPM for {app['name']}") - file = matches[0] - shutil.copy2(file, repo / file.name) - pinned_rpms[file.name] = expected - packages.append(app["name"]) - (BUILD / "pinned-rpms.json").write_text(json.dumps(pinned_rpms, indent=2) + "\n") - - payload = BUILD / "applications-root" - payload.mkdir(exist_ok=False) - # An explicit allowlist prevents builder accounts, keys, caches, logs, - # container storage and other disposable state from reaching the image. - for relative in ("opt/cybexos-apps", "opt/cybexos-builds", "usr/local/bin", - "usr/local/libexec", "usr/local/share/fonts", - "usr/local/share/applications/omawrite.desktop", - "usr/share/icons/hicolor/scalable/apps/omawrite.svg"): - source = Path("/") / relative - if source.exists(): - copy_tree(source, payload / relative) - for log_dir in (payload / "opt/cybexos-builds").glob("*/logs"): - shutil.rmtree(log_dir) - seed = payload / "usr/share/cybexos/user-seed" - for relative in (".local/bin", ".local/share/claude", ".local/share/cybexos-user-tools", - ".local/share/voxtype", ".local/share/t3code-nightly", ".cargo/bin", - ".cargo/env", ".rustup/toolchains", ".rustup/settings.toml", ".rustup/update-hashes", - "Android/Sdk", ".config/nvim", ".config/voxtype", ".npm-global"): - source = SEED / relative - if source.exists(): - copy_tree(source, seed / relative) - shutil.rmtree(seed / ".config/nvim/.git") - # Native installers use absolute HOME links. Relativize only seed-owned - # links, so the same payload works for every account without downloads. - relativize_seed_links(seed, SEED) - android = payload / "usr/local/libexec/cybexos-android-sdk-update" - android.write_text(android.read_text().replace(f"sdk={SEED}/Android/Sdk", 'sdk="$HOME/Android/Sdk"')) - # Existing repo definitions keep native RPM updates working after install. - # Vendor RPM scriptlets can rewrite their installed repo files. Compose - # from the reviewed definitions that reference our checksum-pinned keys. - policies = application_repositories() - with (BUILD / "application-repositories.repo").open("w") as output: - policies.write(output) - installed = run("flatpak", "list", "--system", "--app", "--columns=application", - capture_output=True, text=True).stdout.splitlines() - if not set(flatpaks).issubset(installed): - raise RuntimeError("Incomplete offline Flatpak application payload") - manifest = {"packages": sorted(set(packages)), "flatpaks": flatpaks, - "features": inventory["features"], "user_seed": str(SEED), - "source_builds": source_build_provenance(inventory)} - (BUILD / "applications.json").write_text(json.dumps(manifest, indent=2) + "\n") - - -if __name__ == "__main__": - main() diff --git a/image/applications.yml b/image/applications.yml deleted file mode 100644 index 4f25869b..00000000 --- a/image/applications.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- -- name: Prepare the complete offline application payload in the disposable builder - hosts: localhost - connection: local - gather_facts: true - module_defaults: - ansible.builtin.dnf: - install_weak_deps: false - vars_files: - - ../inventory/group_vars/all.yml - vars: - primary_user: cybex-seed - primary_group: cybex-seed - primary_home: /home/cybex-seed - config_repo: /home/builder/source - # Compose installs the ISO system baseline into the image root, not the - # disposable host that only prepares application stores and user tools. - apps_prepare_offline_payload: true - pre_tasks: - - name: Create the disposable application seed account - ansible.builtin.user: - name: "{{ primary_user }}" - home: "{{ primary_home }}" - shell: /bin/bash - - - name: Read the seed account identity for application handlers - ansible.builtin.getent: - database: passwd - key: "{{ primary_user }}" - - - name: Install application installer prerequisites - ansible.builtin.dnf: - name: [flatpak, gnupg2, curl, unzip, tar, zstd, fontconfig, java-25-openjdk-devel] - state: present - - - name: Create application ownership state - ansible.builtin.file: - path: /var/lib/cybexos/features - state: directory - mode: "0755" - - - name: Create application seed directories - ansible.builtin.file: - path: "{{ primary_home }}/{{ item }}" - state: directory - owner: "{{ primary_user }}" - group: "{{ primary_group }}" - mode: "0755" - loop: [.local/bin, .config/voxtype, .config/nvim] - - - name: Install the default dictation configuration before model setup - ansible.builtin.copy: - src: ../roles/dotfiles/files/voxtype.toml - dest: "{{ primary_home }}/.config/voxtype/config.toml" - owner: "{{ primary_user }}" - group: "{{ primary_group }}" - mode: "0644" - - roles: - - apps - - tasks: - - name: Install the T3 Code download helper - ansible.builtin.template: - src: ../roles/dotfiles/templates/t3code-update.j2 - dest: "{{ primary_home }}/.local/bin/t3code-update" - owner: "{{ primary_user }}" - group: "{{ primary_group }}" - mode: "0755" - - - name: Include the complete T3 Code application for offline first launch - become: true - become_user: "{{ primary_user }}" - ansible.builtin.command: "{{ primary_home }}/.local/bin/t3code-update" - register: image_t3_install - changed_when: "'CHANGED:' in image_t3_install.stdout" - - - name: Include the pinned Neovim starter - ansible.builtin.git: - repo: https://github.com/LazyVim/starter.git - dest: "{{ primary_home }}/.config/nvim" - version: "{{ lazyvim_starter_commit }}" - become: true - become_user: "{{ primary_user }}" diff --git a/image/benchmark-compression b/image/benchmark-compression deleted file mode 100755 index 3b5a7590..00000000 --- a/image/benchmark-compression +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""Compare xz and zstd on identical SquashFS inputs; retain only measurements. - -For a live ISO, extract its SquashFS first and pass the directory containing -LiveOS/rootfs.img. Directory workloads are also supported. Extraction timing -is a storage benchmark, not a boot or graphical installation qualification. -""" -import argparse -import hashlib -import json -from pathlib import Path -import shutil -import subprocess -import tempfile -import time - - -def identity(root): - digest = hashlib.sha256() - size = files = 0 - for path in sorted(root.rglob('*')): - relative = path.relative_to(root).as_posix() - if path.is_symlink(): - value = 'link:' + str(path.readlink()) - elif path.is_file(): - with path.open('rb') as stream: - value = hashlib.file_digest(stream, 'sha256').hexdigest() - size += path.stat().st_size - files += 1 - else: - continue - digest.update(json.dumps([relative, value], separators=(',', ':')).encode()) - return {'sha256': digest.hexdigest(), 'bytes': size, 'files': files} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('source', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--processors', type=int, default=8) - parser.add_argument('--runs', type=int, default=3) - args = parser.parse_args() - source, output = args.source.resolve(strict=True), args.output.absolute() - if not source.is_dir() or output.exists() or output.is_relative_to(source): - parser.error('Use an existing source directory and a new output directory outside it') - if not 1 <= args.processors <= 256 or not 1 <= args.runs <= 10: - parser.error('processors must be 1–256 and runs 1–10') - output.mkdir(parents=True, mode=0o700) - report = {'source': identity(source), 'processors': args.processors, 'measurements': [], - 'scope': 'SquashFS compression and extraction; not boot or installation time', 'status': 'running'} - try: - with tempfile.TemporaryDirectory(prefix='compression-', dir=output) as directory: - work = Path(directory) - for attempt in range(args.runs): - # Alternate order to reduce systematic warm-cache bias. - for compression in (('xz', 'zstd') if attempt % 2 == 0 else ('zstd', 'xz')): - image = work / f'{compression}.squashfs' - extracted = work / f'{compression}-root' - row = {'compression': compression, 'run': attempt + 1} - with (output / f'{compression}-{attempt + 1}.log').open('w') as log: - start = time.monotonic() - subprocess.run(['mksquashfs', str(source), str(image), '-comp', compression, - '-processors', str(args.processors), '-noappend', '-no-progress'], - stdout=log, stderr=subprocess.STDOUT, check=True) - row.update(compress_seconds=time.monotonic() - start, bytes=image.stat().st_size) - start = time.monotonic() - subprocess.run(['unsquashfs', '-no-progress', '-processors', str(args.processors), - '-d', str(extracted), str(image)], stdout=log, stderr=subprocess.STDOUT, check=True) - row['extract_seconds'] = time.monotonic() - start - if identity(extracted) != report['source']: - raise RuntimeError('Extracted data differs from the source') - shutil.rmtree(extracted) - image.unlink() - report['measurements'].append(row) - print(json.dumps(row), flush=True) - if identity(source) != report['source']: - raise RuntimeError('Source changed during the comparison') - report['status'] = 'passed' - except BaseException as error: - report.update(status='failed', error=str(error) or type(error).__name__) - raise - finally: - (output / 'measurements.json').write_text(json.dumps(report, indent=2) + '\n') - - -if __name__ == '__main__': - main() diff --git a/image/boot-theme/theme.txt b/image/boot-theme/theme.txt deleted file mode 100644 index d713acb4..00000000 --- a/image/boot-theme/theme.txt +++ /dev/null @@ -1,42 +0,0 @@ -# CybexOS firmware menu. The logo is the same asset used by Plymouth. -desktop-color: "#0d0d0d" -title-text: "" -message-color: "#e8e7df" -message-bg-color: "#0d0d0d" -terminal-font: "Unifont Regular 16" -+ image { - left = 50%-125 - top = 23% - width = 250 - height = 59 - file = "logo.png" -} -+ label { - left = 10% - top = 37% - width = 80% - height = 32 - align = "center" - color = "#d3d283" - text = "CybexOS" -} -+ boot_menu { - left = 15% - top = 48% - width = 70% - height = 32% - item_color = "#e8e7df" - selected_item_color = "#d3d283" - item_height = 30 - item_padding = 8 - item_spacing = 6 -} -+ label { - left = 10% - top = 90% - width = 80% - height = 24 - align = "center" - color = "#a7a59b" - text = "Enter to start | E to edit | Esc to go back" -} diff --git a/image/boot_branding.py b/image/boot_branding.py deleted file mode 100644 index 19fb6ff8..00000000 --- a/image/boot_branding.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Style the generated firmware menu without replacing its boot commands. - -Call after LiveImageCreator._configure_bootloader(isodir), before its EFI -filesystem is generated. The upstream subclass hook is documented in -https://github.com/livecd-tools/livecd-tools/blob/main/imgcreate/live.py. -""" -from pathlib import Path -import re -import shutil - -MARKER = "# CybexOS menu appearance" -# Kernel argument for the installation-only session; see cybexos-live-setup. -INSTALL_ARGUMENT = "cybexos.install" - - -def grub_install_entries(text): - """Offer each generated live entry twice: straight to the installer, then live. - - The first entry stays the default, so an unattended boot opens the installer. - """ - if INSTALL_ARGUMENT in text: - return text - - def pair(match): - block = match.group(0) - install = re.sub(r"(?m)^([ \t]*linux(?:efi)?[ \t].*?)[ \t]*$", rf"\1 {INSTALL_ARGUMENT}", block, count=1) - return (install.replace("menuentry 'Start CybexOS", "menuentry 'Install CybexOS", 1) - + block.replace("menuentry 'Start CybexOS", "menuentry 'Try CybexOS live", 1)) - return re.sub(r"(?ms)^[ \t]*menuentry 'Start CybexOS[^\n]*\{\n.*?^[ \t]*\}\n", pair, text) - - -def isolinux_install_entries(text): - """The BIOS menu equivalent; `menu default` moves to the installer entry.""" - if INSTALL_ARGUMENT in text: - return text - - def pair(match): - name, body = match.group(1), match.group(2) - label = re.search(r"(?m)^\s*menu label (.*)$", body).group(1) - if "Start CybexOS" not in label.replace("^", ""): - return match.group(0) - main = label.startswith("^Start CybexOS") - live_label = label.replace("Start CybexOS", "Try CybexOS live", 1) - install_label = ("^Install CybexOS" + label[len("^Start CybexOS"):] if main - else label.replace("^", "").replace("Start CybexOS", "Install CybexOS", 1)) - live = body.replace(f"menu label {label}", f"menu label {live_label}", 1) - live = re.sub(r"(?m)^[ \t]*menu default\n", "", live) - install = body.replace(f"menu label {label}", f"menu label {install_label}", 1) - install = re.sub(r"(?m)^([ \t]*append .*?)[ \t]*$", rf"\1 {INSTALL_ARGUMENT}", install, count=1) - return f"label install-{name}\n{install}label {name}\n{live}" - return re.sub(r"(?m)^label (\S+)\n((?:[ \t]+.*\n)*)", pair, text) - - -def brand_boot_menu(isodir, install_root, source=None): - isodir, install_root = Path(isodir), Path(install_root) - source = Path(source) if source else Path(__file__).with_name("boot-theme") - grub_paths = [path for relative in ("EFI/BOOT/grub.cfg", "boot/grub2/grub.cfg") - if (path := isodir / relative).is_file()] - if grub_paths: - theme = isodir / "boot/grub2/themes/cybexos" - theme.mkdir(parents=True, exist_ok=True) - shutil.copy2(source / "theme.txt", theme / "theme.txt") - shutil.copy2(install_root / "usr/share/plymouth/themes/cybex/logo.png", theme / "logo.png") - font = install_root / "usr/share/grub/unicode.pf2" - if font.is_file(): - shutil.copy2(font, theme / "unicode.pf2") - # Text colors are a fallback when firmware cannot use gfxterm. Keep - # every menuentry, kernel argument, media check and recovery entry. - appearance = "\n" + MARKER + "\n" + '''set color_normal=light-gray/black -set color_highlight=yellow/black -if loadfont /boot/grub2/themes/cybexos/unicode.pf2; then - insmod gfxterm - insmod png - set gfxmode=auto - terminal_output gfxterm - set theme=/boot/grub2/themes/cybexos/theme.txt - export theme -fi -''' - for path in grub_paths: - text = grub_install_entries(path.read_text()) - path.write_text(text if MARKER in text else text + appearance) - bios = isodir / "isolinux/isolinux.cfg" - if bios.is_file(): - text = isolinux_install_entries(bios.read_text()) - # A generic Fedora splash can be referenced by stock syslinux. Use a - # dark canvas with the same warm accent, retaining all boot entries. - text = re.sub(r"(?m)^menu background .*\n", "", text) - text = re.sub(r"(?m)^menu title .*", "menu title CybexOS", text) - colors = {"title": "#ffd3d283 #ff0d0d0d", "sel": "#ff0d0d0d #ffd3d283", - "unsel": "#ffe8e7df #ff0d0d0d", "border": "#00000000 #ff0d0d0d"} - for key, color in colors.items(): - line = f"menu color {key} 0 {color} none" - pattern = rf"(?m)^menu color {key} .*" - text = re.sub(pattern, line, text) if re.search(pattern, text) else text + "\n" + line + "\n" - bios.write_text(text) diff --git a/image/browser-smoke.cjs b/image/browser-smoke.cjs deleted file mode 100644 index 7317d8d3..00000000 --- a/image/browser-smoke.cjs +++ /dev/null @@ -1,435 +0,0 @@ -#!/usr/bin/env node -// Source-only browser test. The Cockpit transport is a fixture: no installer, -// disk, VM, ISO, or system service is contacted. See README for dependencies. -const assert = require("node:assert/strict"); -const fs = require("node:fs/promises"); -const http = require("node:http"); -const path = require("node:path"); -const { chromium } = require("playwright-core"); -const { waitForInitialSetup } = require("./real_browser_qualification.cjs"); -const root = path.join(__dirname, "live-rootfs/usr/share/cockpit/cybexos-installer"); - -const transport = ` -window.fixtureRequests = []; -window.fixturePhase = sessionStorage.getItem("fixturePhase") || "setup"; -window.cockpit = { - file() { return { watch(callback) { setTimeout(() => callback(""), 0); }, close() {} }; }, - spawn(args) { - let stream, done; - return { - stream(callback) { stream = callback; return this; }, - then(callback) { done = callback; return this; }, - input(raw) { - const data = JSON.parse(raw || "{}"), command = args[1]; - fixtureRequests.push({ command, hasPassword: !!data.password, - locale: data.locale, timezone: data.timezone, encrypted: data.encrypted, - layout: data.layout, shrink: data.shrink, oem: data.oem, seed: data.seed, - username: data.username, alongsideConfirmed: data.alongside_confirmed, - eraseConfirmed: data.erase_confirmed, source: data.source }); - const busyRemaining = Number(sessionStorage.getItem("fixtureBusyRemaining") || "0"); - if (command === "inventory" && busyRemaining > 0) { - sessionStorage.setItem("fixtureBusyRemaining", String(busyRemaining - 1)); - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: false, - error: "Another installer operation is still running." }) + "\\n"); - done(); - }, 5); - return this; - } - const initialError = command === "inventory" && sessionStorage.getItem("fixtureInitialError"); - if (initialError && initialError !== "done") { - sessionStorage.setItem("fixtureInitialError", "done"); - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: false, - error: "Anaconda inventory failed." }) + "\\n"); - done(); - }, 5); - return this; - } - let result; - if (command === "status") result = fixturePhase === "failed-install" - ? { phase: fixturePhase, message: "Installation stopped during “Running post-installation scripts”.", - failed_step: "Running post-installation scripts", log_source: "post" } - : { phase: fixturePhase, message: "Fixture progress" }; - else if (command === "log") result = { source: data.source, available: true, - text: Array.from({ length: 200 }, (_, line) => \`fixture \${data.source} log line \${line}\`).join("\\n") }; - else if (command === "log-destinations") result = { destinations: [ - { name: "sdb1", path: "/dev/sdb1", label: "STICK", fstype: "exfat", size: 32 * 1024 ** 3, mountpoint: "" }] }; - else if (command === "export-logs") result = { file: "cybexos-install-logs-fixture.tar.gz", - device: "/dev/" + data.device, label: "STICK" }; - else if (command === "inventory" || command === "rescan") result = { - disks: [{ name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", - partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }, - { name: "vdb", path: "/dev/vdb", size: 536870912000, model: "Fixture Windows SSD", - serial: "FIXTURE-WINDOWS", wwn: "", systems: ["Windows"], - partitions: [{ path: "/dev/vdb1", size: 104857600, filesystem: "vfat" }, - { path: "/dev/vdb3", size: 429496729600, filesystem: "ntfs" }] }], - keyboards: [{ id: "us", label: "English (US)" }, { id: "nl", label: "Dutch" }], - locales: ["en_US.UTF-8", "nl_NL.UTF-8"], locale: "en_US.UTF-8", - keyboard: "us", timezones: ["America/Argentina/Buenos_Aires", "Europe/Amsterdam", "UTC"], - timezone: "UTC", detected_timezone: "" - }; - else if (command === "geolocate") result = { timezone: "Europe/Amsterdam" }; - else if (command === "seed") { - const mode = sessionStorage.getItem("fixtureSeed"); - const seed = { digest: "fixture-digest", oem: false, encrypted: true, username: "lab", - full_name: "Lab Machine", hostname: "lab-1", keyboard: "us", locale: "en_US.UTF-8", - timezone: "UTC", ssh_keys: 1, ssh_server: false, tailscale: true, countdown: 10, - disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", partitions: [] } }; - result = mode === "valid" ? { found: true, valid: true, errors: [], seed } - : mode === "invalid" ? { found: true, valid: false, errors: ['Set "disk.erase: true" to confirm that the matched disk is erased.'] } - : { found: false }; - } - else if (command === "alongside") result = { - mode: "shrink", free: 10737418240, required: 68719476736, systems: ["Windows"], - partition: { id: "vdb3", path: "/dev/vdb3", size: 429496729600, min_size: 107374182400, filesystem: "ntfs" }, - floor: 112742891520, min_space: 68719476736, max_space: 327491256320, default_space: 198105366528 - }; - else if (command === "keyboard") result = { keyboard: data.keyboard, boot_keyboard: data.keyboard }; - else if (command === "plan" && data.seed) result = { - phase: "review", token: "seed-token", countdown: 10, - disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", partitions: [] }, - account: { username: "lab", encrypted: true, locale: "en_US.UTF-8", timezone: "UTC", unattended: true }, - layout: { mode: "erase" }, boot_keyboard: "us", - actions: [{ "action-description": "Create", "object-description": "encrypted Btrfs", "device-name": "vda" }], - warnings: [] - }; - else if (command === "plan" && data.layout === "alongside") result = { - phase: "review", token: "alongside-token", - disk: { name: "vdb", path: "/dev/vdb", size: 536870912000, model: "Fixture Windows SSD", - serial: "FIXTURE-WINDOWS", wwn: "", partitions: [] }, - account: { username: data.username, encrypted: data.encrypted, locale: data.locale, - timezone: data.timezone, passwordless_wheel: data.passwordless_wheel, oem: !!data.oem }, - layout: { mode: "alongside", systems: ["Windows"], space: 10737418240 + 429496729600 - data.shrink.size, - kept: ["/dev/vdb1"], - resize: { path: "/dev/vdb3", filesystem: "ntfs", from: 429496729600, to: data.shrink.size } }, - boot_keyboard: data.keyboard, - actions: [{ "action-description": "Resize device", "object-description": "partition", "device-name": "vdb3" }], - warnings: [] - }; - else if (command === "plan") result = { - phase: "review", token: "fixture-token", - disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", - partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }, - account: { username: data.username, encrypted: data.encrypted, - locale: data.locale, timezone: data.timezone, - passwordless_wheel: data.passwordless_wheel, oem: !!data.oem }, - layout: { mode: "erase" }, - boot_keyboard: data.keyboard, - actions: [{ "action-description": "Create", "object-description": "encrypted Btrfs", "device-name": "vda" }], - warnings: [] - }; - else if (command === "install" && !sessionStorage.getItem("fixtureRejected")) { - // Anaconda's plan check rejects the first confirmation; nothing starts. - sessionStorage.setItem("fixtureRejected", "1"); - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: false, - error: "The planned disk changes changed. Review the installation again." }) + "\\n"); - done(); - }, 5); - return this; - } - else if (command === "install") { - fixturePhase = "installing"; - sessionStorage.setItem("fixturePhase", fixturePhase); - result = { phase: "installing" }; - setTimeout(() => { - fixturePhase = "complete"; - sessionStorage.setItem("fixturePhase", fixturePhase); - }, 100); - } else if (command === "diagnostics") result = { - schema: 1, installer: { phase: fixturePhase, message: "Installation did not finish." }, - worker: { ActiveState: "failed", Result: "exit-code" }, backend_ready: true - }; - else result = { phase: "setup" }; - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: true, data: result }) + "\\n"); - done(); - }, 5); - return this; - } - }; -}}; -`; - -async function screenshot(page, name) { - if (!process.env.CYBEXOS_UI_SCREENSHOTS) return; - const directory = path.resolve(process.env.CYBEXOS_UI_SCREENSHOTS); - await fs.mkdir(directory, { recursive: true }); - await page.screenshot({ path: path.join(directory, `${name}.png`), animations: "disabled" }); -} - -async function main() { - const server = http.createServer(async (request, response) => { - try { - const pathname = new URL(request.url, "http://localhost").pathname; - if (pathname.endsWith("/base1/cockpit.js")) { - response.setHeader("Content-Type", "text/javascript"); - response.end(transport); - return; - } - const file = path.basename(pathname) || "index.html"; - if (!["index.html", "installer.css", "installer.js", "model.mjs", "cybex-wordmark.svg"].includes(file)) { - response.writeHead(404).end(); - return; - } - response.setHeader("Content-Type", file.endsWith(".html") ? "text/html" - : file.endsWith(".css") ? "text/css" : file.endsWith(".svg") ? "image/svg+xml" : "text/javascript"); - response.end(await fs.readFile(path.join(root, file))); - } catch { - response.writeHead(500).end("Fixture server error"); - } - }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - let browser; - try { - browser = await chromium.launch({ - executablePath: process.env.CYBEXOS_BROWSER || "/usr/bin/brave-origin", - headless: true, - args: ["--disable-dev-shm-usage"], - }); - const busyPage = await browser.newPage(); - await busyPage.addInitScript(() => sessionStorage.setItem("fixtureBusyRemaining", "4")); - await busyPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await waitForInitialSetup(busyPage, 5000, [10, 20, 40, 80, 160, 320, 320]); - assert.equal(await busyPage.locator("#setup").isVisible(), true); - assert.equal(await busyPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 5); - await busyPage.close(); - - const otherErrorPage = await browser.newPage(); - await otherErrorPage.addInitScript(() => sessionStorage.setItem("fixtureInitialError", "other")); - await otherErrorPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await assert.rejects(waitForInitialSetup(otherErrorPage, 250)); - assert.equal(await otherErrorPage.locator("#setup").isVisible(), false); - assert.equal(await otherErrorPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 1); - await otherErrorPage.close(); - - const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - await page.emulateMedia({ reducedMotion: "reduce" }); - const errors = []; - page.on("pageerror", error => errors.push(error.message)); - await page.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await page.locator("#setup").waitFor({ state: "visible" }); - await page.waitForFunction(() => !document.querySelector("#password").disabled); - await screenshot(page, "installer-setup"); - // Capitals become the lowercase account name as they are typed. - await page.locator("#username").pressSequentially("Alice"); - assert.equal(await page.inputValue("#username"), "alice"); - assert.equal(await page.inputValue("#disk"), ""); - assert.equal(await page.isChecked("#passwordless-wheel"), false); - await page.fill("#username", "root"); - await page.fill("#password", "fixture secret 123"); - await page.fill("#confirm", "fixture secret 123"); - await page.getByRole("button", { name: "Choose install location" }).click(); - assert.match(await page.textContent("#error"), /System names are reserved/); - await page.fill("#username", "alice"); - await page.fill("#password", "fixture secret 123"); - await page.fill("#confirm", "fixture secret 123"); - await page.check("#passwordless-wheel"); - await page.selectOption("#keyboard", "nl"); - await page.waitForFunction(() => !document.querySelector("#password").disabled); - assert.equal(await page.inputValue("#password"), ""); - assert.equal(await page.inputValue("#confirm"), ""); - // Geolocation fills the dropdown in the background unless a timezone was chosen. - await page.waitForFunction(() => document.querySelector("#timezone").value === "Europe/Amsterdam"); - assert.equal(await page.textContent("#timezone-status"), "Detected from your network location."); - assert.equal(await page.locator("#timezone option[value='America/Argentina/Buenos_Aires']").textContent(), - "Argentina / Buenos Aires"); - await page.fill("#keyboard-test", "ordinary test characters"); - await page.fill("#password", "fixture secret 123"); - await page.fill("#confirm", "fixture secret 123"); - await page.locator("#account-form details > summary").click(); - await page.selectOption("#locale", "nl_NL.UTF-8"); - await page.selectOption("#timezone", "Europe/Amsterdam"); - await page.getByRole("button", { name: "Choose install location" }).click(); - await page.locator("#location").waitFor({ state: "visible" }); - assert.equal(await page.isChecked("#encrypted"), true); - await page.click("#rescan-disks"); - await page.waitForFunction(() => !document.querySelector("#rescan-disks").disabled); - assert.equal(await page.inputValue("#disk"), ""); - await screenshot(page, "installer-location"); - await page.selectOption("#disk", "vda"); - assert.match(await page.textContent("#disk-details"), /FIXTURE-SERIAL/); - assert.match(await page.textContent("#disk-details"), /\/dev\/vda1.*2\.0 GiB/); - await page.getByRole("button", { name: "Review installation" }).click(); - await page.locator("#review").waitFor({ state: "visible" }); - assert.match(await page.textContent("#summary"), /Administrator commands do not ask for a password/); - assert.match(await page.textContent("#summary"), /FIXTURE-WWN/); - assert.equal(await page.isDisabled("#install"), true); - await screenshot(page, "installer-review"); - await page.check("#erase"); - await page.click("#install"); - // A rejected confirmation returns to disk selection instead of waiting on progress. - await page.locator("#location").waitFor({ state: "visible" }); - assert.match(await page.textContent("#error"), /planned disk changes changed/); - await page.getByRole("button", { name: "Review installation" }).click(); - await page.locator("#review").waitFor({ state: "visible" }); - assert.equal(await page.isChecked("#erase"), false); - await page.check("#erase"); - await page.click("#install"); - await page.getByRole("heading", { name: "Your workspace is ready." }).waitFor({ timeout: 10000 }); - assert.equal(await page.inputValue("#password"), ""); - assert.equal(await page.inputValue("#confirm"), ""); - assert.equal(await page.evaluate(() => fixtureRequests.filter(request => request.command === "install").length), 2); - assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").timezone), "Europe/Amsterdam"); - assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").locale), "nl_NL.UTF-8"); - await page.reload(); - await page.getByRole("heading", { name: "Your workspace is ready." }).waitFor(); - await page.evaluate(() => sessionStorage.setItem("fixturePhase", "failed-install")); - await page.reload(); - await page.getByRole("heading", { name: "Installation needs attention." }).waitFor(); - assert.equal(await page.isVisible("#failure-help"), true); - const downloadPromise = page.waitForEvent("download"); - await page.click("#save-diagnostics"); - const download = await downloadPromise; - assert.equal(download.suggestedFilename(), "cybexos-installer-diagnostics.json"); - assert.equal(await page.evaluate(() => fixtureRequests.some(request => request.command === "diagnostics")), true); - await page.setViewportSize({ width: 390, height: 844 }); - await screenshot(page, "installer-narrow"); - assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); - await page.setViewportSize({ width: 1280, height: 900 }); - // The details panel follows a chosen log at any stage. - if (!await page.locator("#log-panel").evaluate(panel => panel.open)) - await page.locator("#log-panel summary").click(); - await page.selectOption("#log-source", "steps"); - await page.waitForFunction(() => document.querySelector("#log").textContent.includes("fixture steps log line 199")); - await page.selectOption("#log-source", "storage"); - await page.waitForFunction(() => document.querySelector("#log").textContent.includes("fixture storage log line")); - // A failed installation names its step and opens the explaining log. - await page.evaluate(() => sessionStorage.setItem("fixturePhase", "failed-install")); - await page.reload(); - await page.getByRole("heading", { name: "Installation needs attention." }).waitFor(); - assert.match(await page.textContent("#progress-message"), /Running post-installation scripts/); - await page.waitForFunction(() => document.querySelector("#log-panel").open - && document.querySelector("#log").textContent.includes("fixture post log line 199")); - assert.equal(await page.inputValue("#log-source"), "post"); - await page.click("#find-destinations"); - await page.waitForFunction(() => document.querySelector("#log-destination").value === "sdb1"); - await page.click("#export-logs"); - await page.waitForFunction(() => document.querySelector("#export-status").textContent.startsWith("Saved")); - assert.equal(await page.textContent("#export-status"), - "Saved cybexos-install-logs-fixture.tar.gz to STICK. You can remove the drive."); - await screenshot(page, "installer-failed-details"); - const plain = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - plain.on("pageerror", error => errors.push(error.message)); - await plain.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await plain.locator("#setup").waitFor({ state: "visible" }); - await plain.waitForFunction(() => !document.querySelector("#password").disabled); - await plain.fill("#username", "qualification"); - await plain.fill("#password", "fixture secret 123"); - await plain.fill("#confirm", "fixture secret 123"); - await plain.locator("#account-form details > summary").click(); - await plain.selectOption("#locale", "en_US.UTF-8"); - await plain.selectOption("#timezone", "UTC"); - await plain.getByRole("button", { name: "Choose install location" }).click(); - await plain.locator("#location").waitFor({ state: "visible" }); - await plain.selectOption("#disk", "vda"); - await plain.locator("#disk-form details > summary").click(); - await plain.uncheck("#encrypted"); - await plain.getByRole("button", { name: "Review installation" }).click(); - await plain.locator("#review").waitFor({ state: "visible" }); - assert.match(await plain.textContent("#summary"), /unencrypted/); - assert.equal(await plain.evaluate(() => fixtureRequests.find(request => request.command === "plan").encrypted), false); - await plain.close(); - const alongside = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - alongside.on("pageerror", error => errors.push(error.message)); - await alongside.addInitScript(() => sessionStorage.setItem("fixtureRejected", "1")); - await alongside.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await alongside.locator("#setup").waitFor({ state: "visible" }); - await alongside.waitForFunction(() => !document.querySelector("#password").disabled); - await alongside.fill("#username", "alice"); - await alongside.fill("#password", "fixture secret 123"); - await alongside.fill("#confirm", "fixture secret 123"); - await alongside.getByRole("button", { name: "Choose install location" }).click(); - await alongside.locator("#location").waitFor({ state: "visible" }); - await alongside.selectOption("#disk", "vdb"); - await alongside.locator("#space-field").waitFor({ state: "visible" }); - assert.equal(await alongside.isChecked("#layout-alongside"), true); - assert.match(await alongside.textContent("#layout-legend"), /already has Windows/); - assert.match(await alongside.textContent("#alongside-details"), /\/dev\/vdb3 is shrunk from 400\.0 GiB/); - await alongside.evaluate(() => { - const range = document.querySelector("#space"); - range.value = String(100 * 1024 ** 3); - range.dispatchEvent(new Event("input")); - }); - assert.match(await alongside.textContent("#space-label"), /100\.0 GiB/); - await screenshot(alongside, "installer-alongside"); - await alongside.getByRole("button", { name: "Review installation" }).click(); - await alongside.locator("#review").waitFor({ state: "visible" }); - const alongsidePlan = await alongside.evaluate(() => fixtureRequests.find(request => request.command === "plan")); - assert.equal(alongsidePlan.layout, "alongside"); - assert.deepEqual(alongsidePlan.shrink, { device: "vdb3", size: 429496729600 - (100 * 1024 ** 3 - 10737418240) }); - assert.match(await alongside.textContent("#summary"), /Resized\/dev\/vdb3 \(ntfs\) from 400\.0 GiB to 310\.0 GiB/); - assert.match(await alongside.textContent("#erase-label"), /\/dev\/vdb3 will be resized/); - assert.equal(await alongside.textContent("#install"), "Resize and install"); - await alongside.check("#erase"); - await alongside.click("#install"); - await alongside.locator("#progress").waitFor({ state: "visible" }); - const commit = await alongside.evaluate(() => fixtureRequests.find(request => request.command === "install")); - assert.equal(commit.alongsideConfirmed, true); - assert.equal(commit.eraseConfirmed, undefined); - await alongside.close(); - - const owner = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - owner.on("pageerror", error => errors.push(error.message)); - await owner.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await owner.locator("#setup").waitFor({ state: "visible" }); - await owner.waitForFunction(() => !document.querySelector("#password").disabled); - await owner.locator("#account-form details > summary").click(); - await owner.check("#oem"); - assert.equal(await owner.isVisible("#username"), false); - assert.equal(await owner.textContent("#password-label"), "Temporary disk passphrase"); - await owner.fill("#password", "handover phrase 123"); - await owner.fill("#confirm", "handover phrase 123"); - await owner.getByRole("button", { name: "Choose install location" }).click(); - await owner.locator("#location").waitFor({ state: "visible" }); - await owner.selectOption("#disk", "vda"); - await owner.getByRole("button", { name: "Review installation" }).click(); - await owner.locator("#review").waitFor({ state: "visible" }); - assert.match(await owner.textContent("#summary"), /Created by the new owner at first start/); - assert.match(await owner.textContent("#summary"), /Temporary passphrase → new-owner setup/); - assert.equal(await owner.evaluate(() => fixtureRequests.find(request => request.command === "plan").oem), true); - await owner.close(); - - const invalidSeed = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - invalidSeed.on("pageerror", error => errors.push(error.message)); - await invalidSeed.addInitScript(() => sessionStorage.setItem("fixtureSeed", "invalid")); - await invalidSeed.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await invalidSeed.locator("#unattended").waitFor({ state: "visible" }); - assert.match(await invalidSeed.textContent("#seed-errors"), /disk\.erase: true/); - assert.equal(await invalidSeed.evaluate(() => fixtureRequests.some(request => request.command === "plan")), false); - await screenshot(invalidSeed, "installer-seed-invalid"); - await invalidSeed.click("#seed-guided"); - await invalidSeed.locator("#setup").waitFor({ state: "visible" }); - await invalidSeed.close(); - - const seed = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - seed.on("pageerror", error => errors.push(error.message)); - await seed.addInitScript(() => sessionStorage.setItem("fixtureSeed", "valid")); - await seed.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await seed.locator("#unattended").waitFor({ state: "visible" }); - await seed.waitForFunction(() => /Installing in 0:\d\d/.test(document.querySelector("#countdown").textContent)); - assert.match(await seed.textContent("#seed-summary"), /lab \(Lab Machine\)/); - assert.match(await seed.textContent("#seed-summary"), /joins Tailscale at first start/); - assert.equal(await seed.evaluate(() => fixtureRequests.find(request => request.command === "plan").seed), "fixture-digest"); - await screenshot(seed, "installer-seed-countdown"); - await seed.click("#seed-cancel"); - await seed.waitForFunction(() => document.querySelector("#unattended-title").textContent === "Unattended installation cancelled."); - assert.equal(await seed.evaluate(() => fixtureRequests.find(request => request.command === "cancel-seed").seed), "fixture-digest"); - await seed.waitForTimeout(11000); - assert.equal(await seed.evaluate(() => fixtureRequests.some(request => request.command === "install")), false); - await seed.close(); - - assert.deepEqual(errors, []); - console.log("PASS: three-screen flow, lowercase username, log panel and export, keyboard/locale/timezone choices, encrypted and plain disk paths, rejected-install recovery, erase confirmation, password clearing, progress/reload recovery, narrow layout, install alongside with a resize, new-owner mode, seed countdown, cancel and seed errors"); - } finally { - if (browser) await browser.close(); - await new Promise(resolve => server.close(resolve)); - } -} - -main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/image/browser_qualification.py b/image/browser_qualification.py deleted file mode 100644 index 736279bf..00000000 --- a/image/browser_qualification.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Connect a host browser to the guest's actual loopback Cockpit instance.""" -import json -import os -from pathlib import Path -import shutil -import socket -import subprocess -import time -from urllib.parse import urlsplit - -from vm_testing import free_port, run - -DRIVER = Path(__file__).with_name('real_browser_qualification.cjs') -DISCOVER = r''' -from pathlib import Path -for proc in Path('/proc').iterdir(): - if not proc.name.isdigit(): - continue - try: - args = proc.joinpath('cmdline').read_bytes().split(b'\0') - except (PermissionError, FileNotFoundError, ProcessLookupError): - continue - if not any(arg.endswith(b'/cybexos-installer-browser') for arg in args): - continue - for arg in args: - if arg.startswith(b'http://127.0.0.1') or arg.startswith(b'http://localhost'): - print(arg.decode('ascii')) - raise SystemExit(0) -raise SystemExit(1) -''' -INSTALLER_ACTIVE = r''' -from pathlib import Path -for proc in Path('/proc').iterdir(): - if not proc.name.isdigit(): - continue - try: - args = proc.joinpath('cmdline').read_bytes().split(b'\0') - except (PermissionError, FileNotFoundError, ProcessLookupError): - continue - if any(arg.endswith((b'/liveinst', b'/cybexos-installer-browser')) for arg in args): - raise SystemExit(0) -raise SystemExit(1) -''' - - -def ensure_installer_started(vm): - """Older ISO defaults open the desktop; newer defaults start the installer.""" - result = subprocess.run([*vm.ssh, 'python3 -'], input=INSTALLER_ACTIVE, - text=True, capture_output=True, timeout=10) - if result.returncode == 1: - run([*vm.ssh, 'XDG_RUNTIME_DIR=/run/user/1000 systemd-run --user --collect ' - '--unit=cybexos-qualification-anaconda /usr/bin/liveinst --nosave=all_ks'], timeout=30) - elif result.returncode: - raise RuntimeError('Could not inspect guest installer startup') - - -def validate_guest_url(value): - parsed = urlsplit(value) - if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost') - or parsed.username or parsed.password or parsed.query or parsed.fragment - or parsed.path not in ('/cockpit/@localhost/cybexos-installer/index.html', - '/cockpit/@localhost/anaconda-webui/index.html')): - raise ValueError('Guest installer URL was not the expected loopback Cockpit page') - port = parsed.port or 80 - if not 1 <= port <= 65535: - raise ValueError('Guest installer port is invalid') - return port - - -def discover_guest_port(vm, timeout=90): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - result = subprocess.run([*vm.ssh, 'python3 -'], input=DISCOVER, text=True, - capture_output=True, timeout=10) - if result.returncode == 0: - urls = [line for line in result.stdout.splitlines() if line.strip()] - if len(urls) != 1: - raise RuntimeError('Guest installer URL discovery was ambiguous') - return validate_guest_url(urls[0]) - vm.alive() - time.sleep(2) - raise RuntimeError('Guest installer browser URL was not discovered') - - -def wait_tunnel(port, process, timeout=20): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if process.poll() is not None: - raise RuntimeError('Installer Cockpit tunnel exited unexpectedly') - try: - with socket.create_connection(('127.0.0.1', port), timeout=1): - return - except OSError: - time.sleep(0.2) - raise RuntimeError('Installer Cockpit tunnel did not open') - - -def browser_dependencies(): - requested = os.environ.get('CYBEXOS_BROWSER') - browser = (shutil.which(requested) or requested) if requested else next((shutil.which(name) for name in - ('brave-origin', 'chromium', 'chromium-browser', 'google-chrome') if shutil.which(name)), None) - if not browser or not os.access(browser, os.X_OK): - raise RuntimeError('Install a Chromium browser or set CYBEXOS_BROWSER for graphical qualification') - check = subprocess.run(['node', '-e', - 'if(Number(process.versions.node.split(".")[0])<20) process.exit(1); require("playwright-core");'], - capture_output=True, text=True, timeout=10) - if check.returncode: - raise RuntimeError('Graphical qualification requires Node.js >=20 and playwright-core (see image/README.md)') - return browser - - -def qualify_browser(vm, *, password, target_disk, unused_disk, encrypted, keyboard, locale, - timezone, install_timeout, require_policy_controls=True): - browser = browser_dependencies() - remote_port = discover_guest_port(vm) - local_port = free_port() - tunnel = subprocess.Popen([*vm.ssh[:-1], '-N', '-L', - f'127.0.0.1:{local_port}:127.0.0.1:{remote_port}', vm.ssh[-1]], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - try: - wait_tunnel(local_port, tunnel) - payload = {'url': f'http://127.0.0.1:{local_port}/cockpit/@localhost/cybexos-installer/index.html', - 'browser': browser, 'password': password, 'target_disk': target_disk, - 'target_serial': 'CYBEXOS-QUALIFY', 'unused_disk': unused_disk, - 'encrypted': encrypted, 'keyboard': keyboard, 'locale': locale, - 'timezone': timezone, 'install_timeout_ms': install_timeout * 1000, - 'require_policy_controls': require_policy_controls} - try: - result = run(['node', str(DRIVER)], input=json.dumps(payload), text=True, - capture_output=True, timeout=install_timeout + 240) - except subprocess.CalledProcessError as error: - # Playwright prints the failed assertion/locator to stderr. Keep - # the useful part without ever including the fixture password. - detail = ((error.stderr or '') + '\n' + (error.stdout or '')).replace(password, '[redacted]') - raise RuntimeError(f'Installer browser driver failed: {detail[-5000:]}') from error - data = json.loads(result.stdout) - if data.get('check') != 'graphical-installer' or data.get('selected_disk') != target_disk: - raise RuntimeError('Browser driver did not confirm the selected disposable disk') - return data - finally: - tunnel.terminate() - try: - tunnel.wait(timeout=5) - except subprocess.TimeoutExpired: - tunnel.kill() - tunnel.wait() diff --git a/image/build b/image/build deleted file mode 100755 index ca6ebaf0..00000000 --- a/image/build +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env python3 -"""Build an ISO in a disposable Fedora VM; --preflight performs no build.""" -import argparse -from datetime import datetime, timezone -import json -import os -from pathlib import Path -import shutil -import signal -import socket -import subprocess -import sys -import tempfile -import time - -from build_support import (atomic_json, build_id, builder_cloud_config, create_seed, deliver_artifacts, - digest, phase, preflight, source_provenance, validate_qemu_path, - wait_for_builder_initialization) -from source_snapshot import archive_identity, tree_identity, write_archive - -ROOT = Path(__file__).resolve().parents[1] -IMAGE = "Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2" -IMAGE_URL = "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/" + IMAGE -IMAGE_SHA256 = "28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f" - - -def run(args, **kwargs): - return subprocess.run(args, check=True, **kwargs) - - -def source_archive(destination, additions=None): - write_archive(ROOT, destination, additions) - - -def stop(process): - if process is not None and process.poll() is None: - process.terminate() - try: - process.wait(timeout=20) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, help="New task-specific output directory (default: images/BUILD-ID)") - parser.add_argument("--cache", type=Path, default=Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "cybexos/image") - parser.add_argument("--memory", type=int, default=24576, help="Builder VM RAM in MiB") - parser.add_argument("--cpus", type=int, default=min(8, os.cpu_count() or 1)) - parser.add_argument("--compression", "--compress", choices=("xz", "zstd"), default="xz", help="xz is the previously validated default; zstd requires new qualification") - parser.add_argument("--preflight", action="store_true", help="Only validate host dependencies/resources; no downloads, files or VM") - parser.add_argument("--update-channel", type=Path, help="Reviewed public RPM update channel JSON; key is validated before transfer") - parser.add_argument("--debug-on-failure", action="store_true", help="Keep a failed builder available until interrupted") - args = parser.parse_args() - identifier = build_id() - output = (args.output or Path.home() / ".local/share/cybexos/images" / identifier).expanduser().resolve() - cache = args.cache.expanduser().resolve() - validate_qemu_path(output) - validate_qemu_path(cache) - if output.exists() and any(output.iterdir()): - parser.error("output must be empty; use a new directory") - if output == Path("/data/pxe/iso") or Path("/data/pxe/iso") in output.parents: - parser.error("build staging must be outside the served ISO tree; publish completed artifacts separately") - try: - from download_cache import cached_download, merge_cache, pins, verified_entries - except ImportError as error: - parser.error(f"{error}; install python3-pyyaml (Fedora) or python3-yaml (Debian)") - resources = preflight(output, cache, args.memory, args.cpus) - if args.update_channel and not shutil.which("gpg"): - parser.error("--update-channel requires gpg to validate the public signing key") - if args.preflight: - print(json.dumps(resources, indent=2)) - return - output.mkdir(parents=True, exist_ok=True, mode=0o700) - output.chmod(0o700) - cache.mkdir(parents=True, exist_ok=True) - phases = [] - provenance = {"build_id": identifier, "utc": datetime.now(timezone.utc).isoformat(), - "compression": args.compression, "resources": resources, "status": "running", "phases": phases} - status_path = output / "build.json" - atomic_json(status_path, provenance) - vm = build_process = None - contract = pins(ROOT) - try: - with tempfile.TemporaryDirectory(prefix=identifier + "-", dir=cache) as temporary: - work = Path(temporary) - additions = {} - if args.update_channel: - from release_metadata import stage_update_channel - with phase("Validate public update channel", phases): - additions.update(stage_update_channel(args.update_channel, work / "channel")) - with phase("Verify cached Fedora builder", phases): - base = cached_download(IMAGE_URL, IMAGE_SHA256, cache / "base") - with phase("Record source and prepare isolated VM", phases): - provenance.update(source_revision=subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip(), - source_dirty=bool(subprocess.check_output(["git", "-C", str(ROOT), "status", "--porcelain"], text=True).strip()), - source_epoch=int(subprocess.check_output(["git", "-C", str(ROOT), "show", "-s", "--format=%ct", "HEAD"], text=True))) - installed = {key: provenance[key] for key in ("utc", "build_id", "source_revision", "source_dirty", "source_epoch")} - installed.update(tree_identity(ROOT)) - atomic_json(work / "build-provenance.json", installed) - additions["image/build-provenance.json"] = work / "build-provenance.json" - source_archive(work / "source.tar.gz", additions) - if archive_identity(work / "source.tar.gz") != {key: installed[key] for key in ('source_content_sha256', 'source_file_count')}: - raise RuntimeError('Source changed while preparing the build archive; retry from a stable checkout') - provenance["source"] = source_provenance(ROOT, work / "source.tar.gz") - # -no-user-config: host /etc/qemu may be unreadable and must not shape the builder. - provenance["tools"] = {command[0]: subprocess.check_output([*command, "--version"], text=True).splitlines()[0] - for command in (["qemu-system-x86_64", "-no-user-config"], ["qemu-img"])} - provenance["tools"]["python"] = sys.version - atomic_json(status_path, provenance) - key = work / "id_ed25519" - run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)]) - (work / "user-data").write_text("#cloud-config\n" + json.dumps( - builder_cloud_config(key.with_suffix(".pub").read_text().strip()))) - (work / "meta-data").write_text(f"instance-id: {identifier}\n") - create_seed(work, resources["seed_tool"]) - run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", str(base), str(work / "system.qcow2"), "220G"]) - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - ssh_options = ["-i", str(key), "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=10", "-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={work / 'known_hosts'}"] - ssh = ["ssh", *ssh_options, "-p", str(port), "builder@127.0.0.1"] - atomic_json(output / "builder.json", {"ssh": ssh, "work": str(work)}) - with (output / "builder-console.log").open("w") as console: - vm = subprocess.Popen([ - "qemu-system-x86_64", "-no-user-config", "-name", "cybexos-image-builder", "-machine", "q35,accel=kvm", "-cpu", "host", "-smp", str(args.cpus), "-m", str(args.memory), - "-drive", f"file={work / 'system.qcow2'},if=virtio,format=qcow2,werror=report,rerror=report", "-drive", f"file={work / 'seed.iso'},if=virtio,format=raw,readonly=on", - "-netdev", f"user,id=net,hostfwd=tcp:127.0.0.1:{port}-:22", "-device", "virtio-net-pci,netdev=net", "-display", "none", "-serial", "stdio", "-monitor", "none"], stdout=console, stderr=subprocess.STDOUT) - try: - with phase("Wait for builder and restore verified downloads", phases): - deadline = time.monotonic() + 600 - while time.monotonic() < deadline: - if vm.poll() is not None: - raise RuntimeError("Builder exited; see builder-console.log") - if subprocess.run([*ssh, "true"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: - break - time.sleep(2) - else: - raise RuntimeError("Builder SSH readiness timed out") - wait_for_builder_initialization(ssh, output) - with (work / "source.tar.gz").open("rb") as archive: - run([*ssh, "tar xzf - -C /home/builder/source"], stdin=archive) - cached = verified_entries(cache / "downloads", contract) - cache_stage = work / "cache-import" - cache_stage.mkdir() - for entry in cached: - shutil.copyfile(cache / "downloads" / entry["sha256"], cache_stage / entry["sha256"]) - atomic_json(cache_stage / "manifest.json", {"entries": cached}) - run(["scp", *ssh_options, "-P", str(port), "-r", str(cache_stage), "builder@127.0.0.1:/home/builder/"]) - provenance["download_cache_hits"] = len(cached) - with phase("Build applications, desktop RPM and compressed image", phases): - with (output / "build.log").open("w") as log: - build_process = subprocess.Popen([*ssh, f"sudo env CYBEXOS_IMAGE_COMPRESSION={args.compression} bash /home/builder/source/image/build-in-guest"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - for line in build_process.stdout: - print(line, end="", flush=True) - log.write(line) - log.flush() - if build_process.wait(): - raise RuntimeError("Guest build failed; see build.log for the failing phase") - with phase("Save verified download cache", phases): - run(["scp", *ssh_options, "-P", str(port), "-r", "builder@127.0.0.1:/home/builder/cache-export", str(work)]) - merge_cache(work / "cache-export", cache / "downloads", contract) - with phase("Verify and atomically deliver completed artifacts", phases): - with tempfile.TemporaryDirectory(prefix=".delivery-", dir=output) as staging: - run(["scp", *ssh_options, "-P", str(port), "-r", "builder@127.0.0.1:/home/builder/artifacts/.", staging]) - from build_support import checksum_entries - verified = checksum_entries(staging) - shutil.copyfile(work / "source.tar.gz", Path(staging) / "source.tar.gz") - from fedora_target import fedora_release - # image/compose names the ISO after inventory fedora_release. - name = f"CybexOS-Live-{fedora_release()}" - original = Path(staging) / f"{name}.iso" - original.rename(Path(staging) / f"{name}-{identifier}.iso") - # The SPDX SBOM names the final ISO and reuses the - # guest-verified digests instead of rehashing the image. - from sbom import write_build_sbom - write_build_sbom(staging, f"{name}-{identifier}.iso", - {**verified, f"{name}-{identifier}.iso": verified[original.name]}, - work / "cache-export/manifest.json") - files = sorted(path for path in Path(staging).iterdir() if path.name != "SHA256SUMS") - (Path(staging) / "SHA256SUMS").write_text("".join(f"{digest(path)} {path.name}\n" for path in files)) - provenance["artifacts"] = deliver_artifacts(staging, output / "artifacts") - except BaseException as error: - if not isinstance(error, (KeyboardInterrupt, SystemExit)) and args.debug_on_failure and vm.poll() is None: - print(f"Failed builder retained for inspection: {output / 'builder.json'}. Interrupt again to clean up.", flush=True) - while vm.poll() is None: - time.sleep(1) - raise - finally: - stop(build_process) - stop(vm) - provenance["status"] = "complete" - print(f"Build complete: {output / 'artifacts'}\nNo boot qualification or PXE publication was performed.") - except BaseException as error: - provenance["status"] = "failed" - provenance["error"] = str(error) or type(error).__name__ - raise - finally: - stop(build_process) - stop(vm) - (output / "builder.json").unlink(missing_ok=True) - atomic_json(status_path, provenance) - - -if __name__ == "__main__": - def interrupted(number, _frame): - raise KeyboardInterrupt(number) - for name in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(name, interrupted) - try: - main() - except KeyboardInterrupt as error: - number = error.args[0] if error.args else signal.SIGINT - print(f"image/build: interrupted by {signal.Signals(number).name}", file=sys.stderr) - sys.exit(128 + number) - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: - print(f"image/build: {error}", file=sys.stderr) - sys.exit(1) diff --git a/image/build-in-guest b/image/build-in-guest deleted file mode 100755 index 1d8e4b9d..00000000 --- a/image/build-in-guest +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# Called only inside the disposable builder created by image/build. -set -euo pipefail -[[ $(systemd-detect-virt) == kvm || $(systemd-detect-virt) == qemu ]] || { - echo 'Refusing to build outside the disposable QEMU VM.' >&2 - exit 2 -} -[[ $(hostname) == image-builder && $EUID == 0 ]] || exit 2 -source_root=/home/builder/source -build_root=/home/builder/build -artifacts=/home/builder/artifacts -mkdir -p "$build_root" "$artifacts" -phase_name=prerequisites -phase_started=$SECONDS -phase() { - printf '[guest] %s completed in %ss\n' "$phase_name" "$((SECONDS - phase_started))" - phase_name=$1 - phase_started=$SECONDS - printf '[guest] Starting %s\n' "$phase_name" -} -trap 'printf "[guest] FAILED: %s after %ss (line %s)\n" "$phase_name" "$((SECONDS - phase_started))" "$LINENO" >&2' ERR -printf '[guest] Starting %s\n' "$phase_name" -dnf install -y livecd-tools rpm-build createrepo_c python3-jinja2 python3-pyyaml \ - curl cpio policycoreutils pykickstart ansible-core -# Builder SELinux is permissive; live and installed systems enforce it. -setenforce 0 -phase verified-download-cache -python3 "$source_root/image/download_cache.py" import /home/builder/cache-import -if ! echo "581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7 $build_root/hyprland.gpg" | sha256sum --check --status; then - curl --fail --location --retry 3 --output "$build_root/hyprland.gpg" \ - https://download.copr.fedorainfracloud.org/results/sdegler/hyprland/pubkey.gpg -fi -echo "581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7 $build_root/hyprland.gpg" | sha256sum --check -phase offline-applications -python3 "$source_root/image/applications" -phase native-titlebars -python3 "$source_root/image/titlebars" -phase native-notification-images -python3 "$source_root/image/notification_images.py" -phase desktop-payload -python3 "$source_root/image/package" --output "$build_root" -phase desktop-rpm -rpmbuild -bb --define "_topdir $build_root/rpmbuild" "$build_root/desktop.spec" -mkdir -p "$build_root/repo" -cp "$build_root"/rpmbuild/RPMS/*/*.rpm "$build_root/repo/" -createrepo_c "$build_root/repo" -fedora_release=$(python3 "$source_root/image/fedora_target.py") -ksvalidator --version "F$fedora_release" "$build_root/live.ks" -mkdir -p "$build_root/tmp" -phase compose-and-compress -cd "$artifacts" -python3 "$source_root/image/compose" -phase manifests-and-cache -cp "$build_root/repo/"*.rpm "$artifacts/" -cp "$build_root/package-manifest.json" "$build_root/applications.json" "$artifacts/" -# Export the actual compiler images and resolved RPM versions independently of -# internal Podman/Cargo caches. applications.json also installs this evidence. -python3 - "$build_root/applications.json" "$artifacts/source-build-provenance.json" <<'PYTHON' -import json -from pathlib import Path -import sys -record = json.loads(Path(sys.argv[1]).read_text()) -Path(sys.argv[2]).write_text(json.dumps(record["source_builds"], indent=2) + "\n") -PYTHON -cp "$build_root/offline-payload.json" "$artifacts/" -cp "$build_root/titlebars.json" "$artifacts/" -cp "$build_root/notification-images.json" "$artifacts/" -cp "$source_root/image/build-provenance.json" "$artifacts/" -rpm -qa --qf '%{NEVRA}\n' | sort > "$artifacts/builder-packages.txt" -python3 "$source_root/image/download_cache.py" export /home/builder/cache-export -chown -R builder:builder /home/builder/cache-export -# Cover every delivered manifest as well as executable artifacts. -python3 - <<'PY' -from pathlib import Path -import hashlib -root = Path('/home/builder/artifacts') -with (root / 'SHA256SUMS').open('w') as output: - for path in sorted(root.iterdir()): - if path.name == 'SHA256SUMS': - continue - if not path.is_file() or path.is_symlink(): - raise RuntimeError(f'Unexpected artifact: {path.name}') - with path.open('rb') as stream: - checksum = hashlib.file_digest(stream, 'sha256').hexdigest() - output.write(f'{checksum} {path.name}\n') -PY -chown -R builder:builder "$artifacts" -phase complete diff --git a/image/build.repo b/image/build.repo deleted file mode 100644 index 5a263e88..00000000 --- a/image/build.repo +++ /dev/null @@ -1,36 +0,0 @@ -# The image compose adapter (image/compose) sets $releasever from inventory -# fedora_release in metalink and gpgkey values; livecd-tools substitutes it in -# baseurl. A release literal here would bypass that single source. -[fedora] -name=Fedora $releasever -metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=x86_64 -enabled=1 -gpgcheck=1 -gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64 - -[updates] -name=Fedora $releasever updates -metalink=https://mirrors.fedoraproject.org/metalink?repo=updates-released-f$releasever&arch=x86_64 -enabled=1 -gpgcheck=1 -gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64 - -[fedora-cisco-openh264] -name=Fedora $releasever openh264 (From Cisco) -metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-cisco-openh264-$releasever&arch=x86_64 -enabled=1 -gpgcheck=1 -gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64 - -[hyprland] -name=Hyprland COPR -baseurl=https://download.copr.fedorainfracloud.org/results/sdegler/hyprland/fedora-$releasever-x86_64/ -enabled=1 -gpgcheck=1 -gpgkey=file:///home/builder/build/hyprland.gpg - -[cybexos-local] -name=Locally built private alpha desktop -baseurl=file:///home/builder/build/repo -enabled=1 -gpgcheck=0 diff --git a/image/build_support.py b/image/build_support.py deleted file mode 100644 index dcff3cbb..00000000 --- a/image/build_support.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Host-side image tooling. Importing this module never builds or boots an image.""" -from contextlib import contextmanager -import ctypes -from datetime import datetime, timezone -import fnmatch -import hashlib -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import time -import uuid - - -SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+~^-]*\Z") - - -def builder_cloud_config(public_key): - """Configure the disposable builder without an init-local hostname race.""" - return { - "users": [{"name": "builder", "groups": ["wheel"], "shell": "/bin/bash", - "sudo": "ALL=(ALL) NOPASSWD:ALL", "ssh_authorized_keys": [public_key]}], - "ssh_pwauth": False, - "disable_root": True, - # Fedora's hostnamectl requires services unavailable in init-local. - # Cloud-init records that early attempt as a degraded boot even when - # its later retry succeeds. Set it once during the final stage. - "preserve_hostname": True, - # QEMU user networking forwards IPv6 poorly: a mirror reached over it - # can stop sending mid-download without closing the connection, and - # dnf then waits indefinitely (seen twice in one build, 2026-10-14). - # The builder needs no IPv6, so it never starts using it. bootcmd runs - # before cloud-init's later stages and before any package transfer; - # source-build containers share the builder's network namespace. - "bootcmd": [["sysctl", "-q", "-w", "net.ipv6.conf.all.disable_ipv6=1", - "net.ipv6.conf.default.disable_ipv6=1"]], - "runcmd": [["hostnamectl", "set-hostname", "image-builder"]], - } - - -def wait_for_builder_initialization(ssh, output): - """Keep cloud-init failures strict and preserve their diagnosis on cleanup.""" - output = Path(output) - result = subprocess.run([*ssh, "cloud-init status --wait --format=json"], - capture_output=True, text=True, timeout=300, check=False) - status = output / "builder-cloud-init-status.log" - status.write_text(result.stdout + result.stderr) - if result.returncode: - # Status 2 means degraded, not successful. Do not waive arbitrary - # warnings; keep the useful guest evidence before deleting its disk. - with (output / "builder-cloud-init.log").open("w") as stream: - try: - subprocess.run([*ssh, "sudo -n tail -n 400 /var/log/cloud-init.log"], - stdout=stream, stderr=subprocess.STDOUT, timeout=30, - check=False) - except (OSError, subprocess.TimeoutExpired) as error: - stream.write(f"Could not collect guest cloud-init log: {error}\n") - raise RuntimeError(f"Builder cloud-init exited {result.returncode}; see {status.name} and builder-cloud-init.log") - subprocess.run([*ssh, 'test "$(hostname)" = image-builder && sudo -n true && mkdir -p /home/builder/source'], - check=True, timeout=30) - - -def digest(path): - with Path(path).open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def verify_sidecar(path): - """Verify one exact filename against its adjacent checksum file.""" - path = Path(path) - sidecar = path.with_name(path.name + ".sha256") - if sidecar.is_symlink() or not sidecar.is_file(): - raise ValueError("Artifact needs its adjacent regular .sha256 file") - match = re.fullmatch(r"([0-9a-fA-F]{64}) [ *]" + re.escape(path.name) + r"\n?", sidecar.read_text()) - if not match or digest(path) != match.group(1).lower(): - raise ValueError("Artifact checksum does not match its adjacent .sha256 file") - return match.group(1).lower() - - -def build_id(): - return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8] - - -def atomic_json(path, value): - path = Path(path) - with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=".json-", delete=False) as stream: - temporary = Path(stream.name) - try: - json.dump(value, stream, indent=2, sort_keys=True) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - except BaseException: - temporary.unlink(missing_ok=True) - raise - try: - temporary.replace(path) - finally: - temporary.unlink(missing_ok=True) - - -def checksum_entries(directory): - """Validate a flat artifact set. Reject traversal, duplicates and symlinks.""" - directory = Path(directory) - entries = {} - manifest = directory / "SHA256SUMS" - if manifest.is_symlink() or not manifest.is_file(): - raise ValueError("SHA256SUMS must be a regular file, never a symlink") - for line in manifest.read_text().splitlines(): - match = re.fullmatch(r"([0-9a-fA-F]{64}) [ *](?:\./)?([A-Za-z0-9][A-Za-z0-9._+~^-]*)", line) - if not match: - raise ValueError("Invalid SHA256SUMS entry") - checksum, name = match.groups() - path = directory / name - if name in entries or path.is_symlink() or not path.is_file(): - raise ValueError(f"Unsafe or duplicate artifact: {name}") - if digest(path) != checksum.lower(): - raise ValueError(f"Artifact checksum mismatch: {name}") - entries[name] = checksum.lower() - if not entries: - raise ValueError("Empty artifact checksum manifest") - files = {path.name for path in directory.iterdir() if path.name != "SHA256SUMS"} - if files != set(entries): - raise ValueError("Artifact directory contains unchecked files") - return entries - - -def rename_new_directory(source, destination): - """Linux atomic publication that refuses replacing even an empty directory.""" - libc = ctypes.CDLL(None, use_errno=True) - renameat2 = libc.renameat2 - renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] - renameat2.restype = ctypes.c_int - if renameat2(-100, os.fsencode(source), -100, os.fsencode(destination), 1): - error = ctypes.get_errno() - raise OSError(error, os.strerror(error), str(destination)) - - -def deliver_artifacts(staging, destination): - """Verify all bytes before publishing an entire directory on one filesystem.""" - staging, destination = Path(staging), Path(destination) - entries = checksum_entries(staging) - if destination.exists() or destination.is_symlink(): - raise FileExistsError(f"Refusing to replace artifacts: {destination}") - for path in staging.iterdir(): - path.chmod(0o644) - with path.open("rb") as stream: - os.fsync(stream.fileno()) - staging.chmod(0o755) - # Linux RENAME_NOREPLACE also protects against a concurrent same-user - # publisher creating an empty target after the check above. - rename_new_directory(staging, destination) - descriptor = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - return entries - - -def validate_qemu_path(path): - if any(character == "," or ord(character) < 32 for character in str(path)): - raise ValueError("QEMU file paths cannot contain commas or control characters") - return Path(path) - - -def existing_parent(path): - path = Path(path).resolve() - while not path.exists(): - path = path.parent - return path - - -def preflight(output, cache, memory, cpus, minimum_free_gib=180): - missing = [name for name in ("qemu-img", "qemu-system-x86_64", "ssh", "ssh-keygen", "scp") if not shutil.which(name)] - seed_tool = next((name for name in ("cloud-localds", "xorriso", "genisoimage", "mkisofs") if shutil.which(name)), None) - if seed_tool is None: - missing.append("cloud-localds or xorriso") - errors = [] - if missing: - errors.append("Missing: " + ", ".join(missing) + ". Fedora: sudo dnf install qemu-kvm qemu-img xorriso openssh-clients python3-pyyaml edk2-ovmf. Debian: sudo apt install qemu-system-x86 qemu-utils cloud-image-utils openssh-client python3-yaml ovmf.") - if not os.access("/dev/kvm", os.R_OK | os.W_OK): - errors.append("Read/write access to /dev/kvm is required (check the kvm group).") - if memory < 4096 or cpus < 1: - errors.append("Specify at least 4096 MiB RAM and one CPU; full builds normally need 24576 MiB.") - available = {} - for line in Path("/proc/meminfo").read_text().splitlines(): - key, value = line.split(":", 1) - available[key] = int(value.split()[0]) - if memory * 1024 > available.get("MemAvailable", 0): - errors.append(f"Requested {memory} MiB RAM exceeds currently available {available.get('MemAvailable', 0) // 1024} MiB; close applications or reduce --memory.") - if cpus > (os.cpu_count() or 1): - errors.append(f"Requested {cpus} CPUs exceeds {os.cpu_count() or 1} host CPUs.") - # The sparse builder resides in cache. Its peak estimate already includes - # artifacts; reserve additional delivery space when output is another disk. - parents = {existing_parent(cache): minimum_free_gib} - output_parent = existing_parent(output) - if output_parent.stat().st_dev != existing_parent(cache).stat().st_dev: - parents[output_parent] = 24 - for parent, required in parents.items(): - free = shutil.disk_usage(parent).free / 2**30 - if free < required: - errors.append(f"{parent}: {free:.1f} GiB free; require {required} GiB for build staging/delivery.") - if errors: - raise RuntimeError("\n".join(errors)) - return {"seed_tool": seed_tool, "memory_mib": memory, "cpus": cpus, - "free_gib": {str(path): round(shutil.disk_usage(path).free / 2**30, 1) for path in parents}} - - -def create_seed(work, seed_tool): - work = Path(work) - if seed_tool == "cloud-localds": - command = [seed_tool, str(work / "seed.iso"), str(work / "user-data"), str(work / "meta-data")] - else: - command = ([seed_tool, "-as", "mkisofs"] if seed_tool == "xorriso" else [seed_tool]) - command += ["-quiet", "-output", str(work / "seed.iso"), "-volid", "cidata", "-joliet", "-rock", - str(work / "user-data"), str(work / "meta-data")] - subprocess.run(command, check=True) - - -def source_provenance(root, archive): - def git(*args): - result = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=True) - return result.stdout.strip() - return {"revision": git("rev-parse", "HEAD"), "changes": git("status", "--porcelain=v1").splitlines(), - "source_archive_sha256": digest(archive), - "configuration_sha256": digest(root / "inventory/group_vars/all.yml")} - - -@contextmanager -def phase(name, records): - started = time.monotonic() - record = {"phase": name, "started_utc": datetime.now(timezone.utc).isoformat(), "status": "running"} - records.append(record) - print(f"\n[image] {name}", flush=True) - try: - yield - except BaseException: - record["status"] = "failed" - print(f"[image] FAILED: {name}", flush=True) - raise - else: - record["status"] = "complete" - finally: - record["seconds"] = round(time.monotonic() - started, 2) - print(f"[image] {name}: {record['status']} ({record['seconds']:.1f}s)", flush=True) - - -def select_firmware(directory=Path("/usr/share/qemu/firmware"), fallback_root=Path("/usr/share")): - """Use QEMU's machine-readable descriptor, preferring non-Secure-Boot UEFI.""" - for path in sorted(Path(directory).glob("*.json")): - data = json.loads(path.read_text()) - if "uefi" not in data.get("interface-types", []) or "secure-boot" in data.get("features", []): - continue - if not any(target.get("architecture") == "x86_64" and any(fnmatch.fnmatch("pc-q35-9.0", machine) for machine in target.get("machines", [])) for target in data.get("targets", [])): - continue - mapping = data.get("mapping", {}) - if mapping.get("device") != "flash": - continue - code, variables = mapping.get("executable", {}), mapping.get("nvram-template", {}) - if all(item.get("format") in ("raw", "qcow2") and Path(item.get("filename", "")).is_file() for item in (code, variables)): - return {"code": code, "variables": variables, "descriptor": str(path)} - for folder, code, variables in ( - ("OVMF", "OVMF_CODE_4M.fd", "OVMF_VARS_4M.fd"), - ("OVMF", "OVMF_CODE.fd", "OVMF_VARS.fd"), - ("edk2/ovmf", "OVMF_CODE.fd", "OVMF_VARS.fd"), - ("edk2/x64", "OVMF_CODE.4m.fd", "OVMF_VARS.4m.fd"), - ): - paths = [Path(fallback_root) / folder / name for name in (code, variables)] - if all(path.is_file() for path in paths): - return {"code": {"filename": str(paths[0]), "format": "raw"}, - "variables": {"filename": str(paths[1]), "format": "raw"}, "descriptor": None} - raise RuntimeError("No non-Secure-Boot x86_64 UEFI firmware found; install edk2-ovmf (Fedora) or ovmf (Debian).") - - -def prepare_firmware(work, selection=None): - selected = selection or select_firmware() - code, variables = selected["code"], selected["variables"] - target = Path(work) / ("OVMF_VARS.qcow2" if variables["format"] == "qcow2" else "OVMF_VARS.fd") - if not target.exists(): - shutil.copyfile(variables["filename"], target) - return ["-drive", f"if=pflash,format={code['format']},readonly=on,file={code['filename']}", - "-drive", f"if=pflash,format={variables['format']},file={target}"] diff --git a/image/channels/CYBEXOS-desktop.asc b/image/channels/CYBEXOS-desktop.asc deleted file mode 100644 index f48cabad..00000000 --- a/image/channels/CYBEXOS-desktop.asc +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mDMEarerDxYJKwYBBAHaRw8BAQdAmmD5CaWuawHOraUCulVZjFjAT0iUKf9mFBPL -2LkA9B20O0N5YmV4T1MgUlBNIFJlbGVhc2UgU2lnbmluZyA8bm9yZXBseUBkaWdp -dGFscGFscy5naXRodWIuaW8+iJkEExYKAEEWIQQWxgZCtyeK7OOpM8NUIgg5/fcJ -ngUCarerDwIbAQUJBaOagAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBU -Igg5/fcJno2eAP98NYS4dX6fxbK0yUPM1Nb+aeH2v8P1d5WoeH2xn8j5cQD8C+lz -ETO5o+5dSmRbCJPv7bDfC9sWBNznW6uLqylJvwm4MwRqt6sPFgkrBgEEAdpHDwEB -B0DMqZcTm/bd+Yk8hrqRjWBi7/1yoAQ/tvfcJfYui627k4j1BBgWCgAmFiEEFsYG -QrcniuzjqTPDVCIIOf33CZ4FAmq3qw8CGwIFCQPCZwAAgQkQVCIIOf33CZ52IAQZ -FgoAHRYhBG6cYYz9wTHrUwGfYUyvtRx29FzVBQJqt6sPAAoJEEyvtRx29FzVk+sB -APoBywasY4Zv5bWN8czeindBYEauMAn7loIa62SwjOJHAP41NgT7cnuIF4moDHyp -TI83TSC9EB420/rWpPc5f3MqAtI4AQC73TWynBM+ACwHKf0n9qt4BXfjPAI/YQss -LlVC6dplXQEA06MRepyNZvoAzorba/isdLw/OVeoUsnvbF2AOl12SAc= -=61Bo ------END PGP PUBLIC KEY BLOCK----- diff --git a/image/channels/stable.json b/image/channels/stable.json deleted file mode 100644 index 3b39ae44..00000000 --- a/image/channels/stable.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "baseurl": "https://digitalpals.github.io/CybexOS/44/x86_64", - "fingerprint": "16C60642B7278AECE3A933C354220839FDF7099E", - "key_file": "CYBEXOS-desktop.asc" -} diff --git a/image/check-source b/image/check-source deleted file mode 100755 index a93b92a2..00000000 --- a/image/check-source +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Run image tooling and installer fixtures without building or booting an ISO.""" -from pathlib import Path -import os -import subprocess -import sys - - -def main(): - root = Path(__file__).resolve().parents[1] - environment = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", - "QT_QPA_PLATFORM": "offscreen", "QT_QUICK_BACKEND": "software"} - commands = [[sys.executable, "image/tests"], - [sys.executable, "-m", "unittest", "discover", "-s", "image", "-p", "test_*.py", "-v"]] - javascript = sorted({*root.glob("image/**/*.test.mjs"), *root.glob("image/**/*.test.cjs")}) - if javascript: - commands.append(["node", "--test", *(str(path) for path in javascript)]) - failures = 0 - for command in commands: - print("[source check] " + " ".join(command), flush=True) - try: - result = subprocess.run(command, cwd=root, env=environment, check=False) - failures += result.returncode != 0 - except OSError as error: - print(str(error), file=sys.stderr) - failures += 1 - return 1 if failures else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/image/compose b/image/compose deleted file mode 100755 index a7d720e6..00000000 --- a/image/compose +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 -"""Compose with livecd-tools' API so DNF repository signing policy is honored.""" -import logging -import configparser -import hashlib -import json -import gc -import os -from pathlib import Path -import shutil -import subprocess -import traceback - -import imgcreate -import imgcreate.creator -from imgcreate.dnfinst import DnfLiveCD -import importlib.util -import rpm -import yaml -from fedora_target import fedora_release -from boot_branding import brand_boot_menu -from sbom import capture_inventories -from post_install_check import check_post_install - -logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") -source = Path(__file__).resolve().parent -# Inventory fedora_release; build.repo and live.ks name no release of their own. -release = fedora_release() -build = Path("/home/builder/build") -dnf_instances = [] -# The same series and comparison the installed systems' versionlock enforces. -_series_spec = importlib.util.spec_from_file_location("cybexos_versionlock", source / "library/cybexos_versionlock.py") -versionlock = importlib.util.module_from_spec(_series_spec) -_series_spec.loader.exec_module(versionlock) -qualified_series = yaml.safe_load((source.parent / "inventory/group_vars/all.yml").read_text())["qualified_package_series"] - - -class VerifiedDnf(DnfLiveCD): - """Add signature enforcement missing from Fedora 44's livecd-tools 31. - - The shipped runInstall explicitly skips signature checks. Keep its image - construction logic, but verify the downloaded transaction before RPM can - execute any package scriptlets. Repository policy comes from build.repo. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - dnf_instances.append(self) - - def addRepository(self, name, url=None, mirrorlist=None): - policy = configparser.ConfigParser(interpolation=None) - policy.read(source / "build.repo") - policy.read(build / "application-repositories.repo") - for repository_id in policy.sections(): - entry = policy[repository_id] - repository = super().addRepository(repository_id, entry.get("baseurl"), entry.get("mirrorlist")) - if entry.get("metalink"): - repository.metalink = entry["metalink"].replace("$releasever", release).replace("$basearch", "x86_64") - repository.gpgcheck = entry.getboolean("gpgcheck") - repository.repo_gpgcheck = entry.getboolean("repo_gpgcheck", fallback=False) - repository.skip_if_unavailable = False - if repository_id == "cybexos-local": - repository.metadata_expire = 0 - if repository.gpgcheck: - repository.gpgkey = [key.replace("$releasever", release).replace("$basearch", "x86_64") - for key in entry["gpgkey"].split()] - elif repository_id != "cybexos-local": - raise RuntimeError("Only the locally built RPM may be unsigned") - return self.repos[name] - - def fill_sack(self, *args, load_system_repo=False, **kwargs): - # livecd-tools only detects the BerkeleyDB rpmdb. Also load RPM 6's - # SQLite database, so a later pass sees the packages already installed. - root = Path(self.conf.installroot) - load_system_repo = load_system_repo or any( - (root / directory / "rpmdb.sqlite").exists() for directory in ("usr/lib/sysimage/rpm", "var/lib/rpm")) - return super().fill_sack(*args, load_system_repo=load_system_repo, **kwargs) - - def do_transaction(self, *args, **kwargs): - for package in self.transaction.install_set: - if not package.repo.gpgcheck: - if package.reponame != "cybexos-local": - raise RuntimeError(f"Unexpected unsigned package: {package}") - if package.name not in ("cybexos-desktop", "cybexos-offline-apps"): - pinned = json.loads((build / "pinned-rpms.json").read_text()) - path = Path(package.localPkg()) - with path.open("rb") as stream: - digest = hashlib.file_digest(stream, "sha256").hexdigest() - if pinned.get(path.name) != digest: - raise RuntimeError(f"Unverified local application RPM: {package}") - continue - result, message = self._sig_check_pkg(package) - if result == 1: - self._get_key_for_package(package, lambda *_args, **_kwargs: True) - result, message = self._sig_check_pkg(package) - if result: - raise RuntimeError(f"Package signature verification failed: {package}: {message}") - logging.info("Verified all upstream package signatures before installation") - # RPM 6 must accept the locally built unsigned artifact. Upstream - # signatures have already been checked explicitly above. - rpm.addMacro("_pkgverify_level", "digest") - return super().do_transaction(*args, **kwargs) - - -imgcreate.creator.DnfLiveCD = VerifiedDnf -kickstart = imgcreate.read_kickstart(str(build / "live.ks")) -# RPM 6 checks Requires(pretrans) against packages installed before the -# transaction. These pretrans scriptlets need /bin/sh, rpm and sed, which an -# empty image root lacks, so they are installed in a second transaction. -PRETRANS_PACKAGES = ("chatgpt",) -selected_packages = kickstart.handler.packages.packageList -deferred_packages = [name for name in selected_packages if name in PRETRANS_PACKAGES] - - -def install_packages(creator): - kickstart.handler.packages.packageList = [name for name in selected_packages if name not in deferred_packages] - creator.install() - if deferred_packages: - groups = kickstart.handler.packages.groupList - kickstart.handler.packages.packageList = deferred_packages - kickstart.handler.packages.groupList = [] - try: - creator.install() - finally: - kickstart.handler.packages.groupList = groups - kickstart.handler.packages.packageList = selected_packages - - -class CybexImageCreator(imgcreate.LiveImageCreator): - def _configure_bootloader(self, isodir): - super()._configure_bootloader(isodir) - brand_boot_menu(isodir, self._instroot) - - -creator = CybexImageCreator( - kickstart, f"CybexOS-Live-{release}", fslabel=f"CYBEXOS-LIVE-{release}", releasever=release, - tmpdir=str(build / "tmp"), title="CybexOS", product="CybexOS", -) -compression = os.environ.get("CYBEXOS_IMAGE_COMPRESSION", os.environ.get("CYBEXOS_SQUASHFS_COMPRESSION", "xz")) -if compression not in ("xz", "zstd"): - raise ValueError("Unsupported live image compression") -creator.compress_args = compression -logging.info("Live image compression: %s", compression) -try: - creator.mount(cachedir=str(build / "cache")) - shutil.copyfile(source / "dracut-live.conf", Path(creator._instroot) / "etc/dracut.conf.d/99-liveos.conf") - install_packages(creator) - # Flatpak validates signed OSTree commits during preparation. Copy the - # complete system installation, including runtimes, before SELinux relabel. - flatpak_root = Path(creator._instroot) / "var/lib/flatpak" - flatpak_root.mkdir(parents=True, exist_ok=True) - subprocess.run(["cp", "-a", "--reflink=auto", "/var/lib/flatpak/.", str(flatpak_root)], check=True) - vendor_policy = configparser.ConfigParser(interpolation=None) - vendor_policy.read(build / "application-repositories.repo") - # RPM Fusion release RPMs already own their repository definitions. - for section in list(vendor_policy.sections()): - if section.startswith("rpmfusion-"): - vendor_policy.remove_section(section) - for entry in vendor_policy.values(): - for key_url in entry.get("gpgkey", "").split(): - if not key_url.startswith("file:///etc/pki/rpm-gpg/"): - raise RuntimeError(f"Vendor key is not a reviewed local key: {key_url}") - key = Path(key_url.removeprefix("file://")) - shutil.copy2(key, Path(creator._instroot) / "etc/pki/rpm-gpg" / key.name) - subprocess.run(["chroot", creator._instroot, "/usr/libexec/cybexos-repository-policy"], check=True) - # imgcreate stages its initrd before Kickstart post scripts run. Select - # the branded theme and rebuild now, while all installed kernels exist. - subprocess.run(["chroot", creator._instroot, "plymouth-set-default-theme", "cybex"], check=True) - subprocess.run(["chroot", creator._instroot, "dracut", "--regenerate-all", "--force"], check=True) - creator.configure() - applications = json.loads((build / "applications.json").read_text()) - subprocess.run(["rpm", "--root", creator._instroot, "-q", *applications["packages"]], check=True) - # Publish only the compositor and shell series this release qualified - # (docs/dependency-policy.md); installed systems then stay within them. - series_problems = versionlock.outside_series( - qualified_series, lambda name: versionlock.installed_evr(name, creator._instroot), require=True) - if series_problems: - raise RuntimeError("Resolved packages are outside their qualified series: " + "; ".join(series_problems)) - logging.info("Qualified series: %s", versionlock.describe(qualified_series)) - installed_flatpaks = subprocess.check_output([ - "chroot", creator._instroot, "flatpak", "list", "--system", "--app", "--columns=application", - ], text=True).splitlines() - if not set(applications["flatpaks"]).issubset(installed_flatpaks): - raise RuntimeError("Missing offline Flatpak applications") - # linux-firmware alone does not pull in vendor GPU firmware when weak - # dependencies are disabled. Fail before publishing an incomplete image. - subprocess.run([ - "rpm", "--root", creator._instroot, "-q", - "kernel-core", "kernel-modules-core", "kernel-modules", "kernel-modules-extra", - "linux-firmware", "amd-gpu-firmware", "intel-gpu-firmware", "nvidia-gpu-firmware", - "mesa-dri-drivers", "mesa-vulkan-drivers", "mesa-libEGL", "pciutils", "fastfetch", - ], check=True) - subprocess.run([ - "chroot", creator._instroot, "/usr/bin/bash", "-euc", - "for tool in cybex fastfetch anaconda liveinst efibootmgr grubby grub2-mkconfig cryptsetup btrfs mkfs.vfat mkfs.ext4; " - 'do command -v "$tool" >/dev/null || exit 1; done', - ], check=True) - for relative in ("", "usr", "etc", "usr/libexec/cybexos-live-setup"): - metadata = (Path(creator._instroot) / relative).stat() - if metadata.st_uid != 0 or metadata.st_gid != 0 or metadata.st_mode & 0o022: - raise RuntimeError(f"Unsafe image ownership or permissions: /{relative}") - # Run the installer's own post-install scripts against this image, as - # Anaconda will, so a failing step stops the build instead of every install. - logging.info("Checking the installer post-install scripts against the composed image") - check_post_install(creator._instroot, build / "tmp") - with Path("/home/builder/artifacts/packages.txt").open("w") as manifest: - subprocess.run(["rpm", "--root", creator._instroot, "-qa", "--qf", "%{NEVRA}\n"], - check=True, stdout=manifest) - # Raw RPM and Flatpak inventories of the finished root. The host turns - # them into the release SBOM once the ISO has its final name. - capture_inventories(creator._instroot, Path("/home/builder/artifacts")) - creator.unmount() - creator.package() -except BaseException as error: - # Exceptions in DNF retain locals (and file handles) through chained - # tracebacks. Release those frames before livecd-tools unmounts its root. - chained = error - while chained is not None: - traceback.clear_frames(chained.__traceback__) - chained = chained.__context__ - raise -finally: - # livecd-tools skips Base.close() when dependency resolution raises. Close - # those handles before unmounting, so a failed build can clean its mounts. - for instance in dnf_instances: - instance.close() - gc.collect() - creator.cleanup() diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec deleted file mode 100644 index cc5ddcfb..00000000 --- a/image/cybexos-desktop.spec +++ /dev/null @@ -1,167 +0,0 @@ -Name: cybexos-desktop -# The packager derives Version and Release from VERSION and build provenance. -# Epoch 1 allows source-versioned packages to replace the old hardcoded 0.1.0. -Epoch: 1 -Version: 0.1.0 -Release: 0.1.alpha%{?dist} -Summary: CybexOS Hyprland and Quickshell desktop -# CybexOS code is MIT. Bundled upstream software/artwork retains its own terms; -# aggregate redistribution clearance is still pending (docs/licensing.md). -License: MIT AND LicenseRef-CybexOS-Bundled-Components -URL: https://github.com/DigitalPals/CybexOS -Source0: desktop.tar -Source1: offline.files -%global offline_version 1.0.0 -%global offline_inputs unconfigured -BuildArch: x86_64 -Provides: cybexos-supported-fedora = %{fedora} -AutoReqProv: no -# Replaces the alpha package published under the project's former name. -Obsoletes: fedora-config-desktop < %{epoch}:%{version}-%{release} -# This RPM is an intermediate container; the live ISO compresses the installed -# filesystem separately. Avoid spending minutes recompressing user toolchains. -%global _binary_payload w3.zstdio -%global _binary_filedigest_algorithm 8 -Requires: bash coreutils util-linux systemd python3 ansible-core gnupg2 openssh-clients -Requires: sddm sddm-wayland-generic systemd-pam gnome-keyring-pam -# Settings > Security and the lock screen's fingerprint and security-key steps. -Requires: fprintd fprintd-pam pam-u2f pamu2fcfg -# Native compositor EVR requirement inserted by image/package -Requires: hyprland -Requires: cmake >= 3.27 -Requires: gcc-c++ make pkgconf-pkg-config hyprland-devel libdrm-devel libinput-devel systemd-devel pixman-devel wayland-devel libxkbcommon-devel patch binutils -Requires: hyprland-guiutils quickshell hypridle hyprlock hyprsunset -Requires: xdg-desktop-portal-hyprland xdg-desktop-portal-gtk xdg-utils -Requires: qt6-qtwebsockets-devel qt6-qt5compat qt6-qtsvg -Requires: qt6-qtbase qt6-qtdeclarative qt6-qtwayland -Requires: qt6-qtbase-devel qt6-qtdeclarative-devel -Requires: python3-pyside6 python3-websockets python3-gobject -Requires: kitty firefox nautilus jq curl NetworkManager iw qrencode iproute iputils dnsmasq -Requires: pipewire pipewire-pulseaudio wireplumber bluez brightnessctl playerctl -Requires: gnome-keyring polkit dbus-daemon dnf5-plugins flatpak sudo -Requires: gnome-online-accounts gnome-online-accounts-gtk evolution-data-server gnome-control-center zenity -Requires: glib2 NetworkManager-libnm nm-connection-editor pulseaudio-utils pavucontrol -Requires: grim slurp satty wl-clipboard cliphist wf-recorder libnotify -Requires: ImageMagick tesseract tesseract-langpack-eng btop matugen -Requires: hyprpicker zbar qt6-qtmultimedia -Requires: btrfs-progs tar zstd fastfetch dracut grub2-tools coreutils >= 9.5 -Requires: rsms-inter-fonts google-noto-sans-fonts google-noto-color-emoji-fonts -Requires: jetbrains-mono-fonts -Requires: xkeyboard-config tzdata -# Personal-file backups: the encrypted repository and its keyring password. -Requires: restic libsecret -# The graphical first-start setup for a new owner (cybexos-new-owner). -Requires: cage -Requires: cybexos-offline-apps >= 1:%{offline_version} -Requires: cybexos-offline-inputs = %{offline_inputs} - -%description -CybexOS (Cybex Opinionated System) is a Hyprland and Quickshell desktop for Fedora. -Shared desktop defaults, session services, and first-login welcome application. -Personal settings and overrides remain in each user's home directory. -Private alpha image integration; not a public distribution release. - -%package -n cybexos-offline-apps -Epoch: 1 -Version: %{offline_version} -Release: 1%{?dist} -Summary: CybexOS offline applications, fonts and developer toolchains -AutoReqProv: no -Provides: cybexos-offline-inputs = %{offline_inputs} - -%description -n cybexos-offline-apps -Independently versioned offline application payload for complete installations. -Desktop updates reuse this package until its reviewed application inputs change. - -%prep -%setup -q -c -n desktop - -%build - -# Bundled upstream binaries must retain the bytes their installers verified. -%global __os_install_post %{nil} -%global debug_package %{nil} - -%install -mkdir -p %{buildroot} -cp -a usr opt etc %{buildroot}/ - -%files -%license /usr/share/licenses/cybexos-desktop/LICENSE -%license /usr/share/licenses/cybexos-titlebars/ -%license /usr/share/licenses/cybexos-notification-images/ -/usr/lib64/qt6/qml/CybexOS/Images/ -/usr/lib64/cybexos/ -/usr/lib/systemd/system/cybexos-titlebars-build.service -/usr/lib/systemd/system/cybexos-titlebars-build.path -%config(noreplace) /etc/yum.repos.d/cybexos-desktop.repo -%config(noreplace) /etc/fonts/conf.d/49-cybexos-defaults.conf -/usr/bin/cybex -/usr/bin/cybexos-* -/usr/bin/hyprland-quickshell -/usr/libexec/cybexos-* -/usr/libexec/cybex_hermes/ -/usr/lib/systemd/user/*.service -/usr/lib/systemd/user/cybexos-backup.timer -/usr/lib/systemd/user/hypridle.service.d/ -/usr/lib/systemd/user/voxtype.service.d/ -/usr/lib/systemd/user/hyprland-session.target -/usr/lib/systemd/system/sddm.service.d/ -/usr/share/cybexos/ -/usr/share/applications/cybex.desktop -/usr/share/wayland-sessions/hyprland-quickshell.desktop -/usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override -/usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override -%exclude /usr/share/cybexos/user-seed/ -%exclude /usr/share/cybexos/final-seed/ -%exclude /usr/share/cybexos/seed-groups.json -%exclude /usr/share/cybexos/offline-inputs.json -/usr/share/plymouth/themes/cybex/ -/usr/lib/sysctl.d/60-cybexos-hardening.conf -/etc/grub.d/42_cybexos_recovery -/usr/lib/kernel/install.d/95-cybexos-recovery.install -/usr/lib/dracut/dracut.conf.d/90-cybexos-recovery.conf -/usr/lib/dracut/modules.d/90cybexos-recovery/ -/usr/lib/systemd/system/cybexos-recovery-refresh.service -/usr/lib/systemd/system/cybexos-update-recover.service -/usr/lib/systemd/system/systemd-user-sessions.service.d/60-cybexos-update-recover.conf -/usr/lib/systemd/system/cybexos-major-upgrade-validate.service -/usr/lib/systemd/system/cybexos-major-upgrade-validate.timer -/usr/lib/systemd/system/cybexos-update-download.service -/usr/lib/systemd/system/cybexos-update-download.timer -/usr/lib/systemd/system/cybexos-reconcile.service -/usr/lib/systemd/system/cybexos-reconcile.timer -/usr/lib/systemd/system/cybexos-hardware-setup.service -/usr/lib/systemd/system/cybexos-hardware-setup.timer -/usr/lib/systemd/system/cybexos-first-boot.service -/usr/lib/systemd/system/cybexos-new-owner.service -/usr/lib/systemd/system/cybexos-tailscale-enroll.service - -/usr/lib/firewalld/zones/cybexos.xml - -%files -n cybexos-offline-apps -f %{SOURCE1} - -%posttrans -systemctl enable --now --no-block cybexos-titlebars-build.path >/dev/null 2>&1 || : -systemctl enable cybexos-titlebars-build.service >/dev/null 2>&1 || : -# Record work only inside the RPM transaction. A timer runs it after RPM releases -# its transaction lock, and repeats only for changed payloads/new accounts. -/usr/libexec/cybexos-reconcile --queue -systemctl enable --now --no-block cybexos-reconcile.timer >/dev/null 2>&1 || : -# The SDDM RPM owns /etc/pam.d/sddm-autologin. Install the shared policy after -# all package payloads are present on a fresh installation. Upgrades use the -# ownership-aware deferred policy so local PAM edits are retained. -if [ "$1" -eq 1 ]; then - /usr/libexec/cybexos-login-prepare --install-pam -fi -# Bootable recovery points are refreshed at every boot; enabling is idempotent. -systemctl enable cybexos-recovery-refresh.service >/dev/null 2>&1 || : -systemctl enable cybexos-update-recover.service >/dev/null 2>&1 || : -systemctl enable cybexos-major-upgrade-validate.timer >/dev/null 2>&1 || : -systemctl enable cybexos-hardware-setup.timer >/dev/null 2>&1 || : -# Inert unless the installer or a factory reset left first-start work. -systemctl enable cybexos-first-boot.service cybexos-new-owner.service >/dev/null 2>&1 || : - -%changelog -* Sat Sep 05 2026 CybexOS - 0.1.0-0.1.alpha -- Initial private live-image desktop package. diff --git a/image/desktop_payload.py b/image/desktop_payload.py deleted file mode 100644 index f88667de..00000000 --- a/image/desktop_payload.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Portable desktop policy shared with the workstation; no host-home inputs.""" -import json -from pathlib import Path -import shutil -import hashlib - - -def prepare_font_licenses(payload, inventory): - """Keep the checkout's canonical fonts, verifying the offline inputs. - - The applications payload already includes these checksum-pinned files. - Installing a second font copy wastes space and makes the two paths differ. - """ - for font in inventory["pinned_font_files"]: - base = payload / "usr/local/share/fonts" / font["name"] / font["version"] - for filename, checksum in ((font["filename"], font["checksum"]), - (font["license_filename"], font["license_checksum"])): - source = base / filename - if not source.is_file() or source.is_symlink() or hashlib.sha256(source.read_bytes()).hexdigest() != checksum.removeprefix("sha256:"): - raise ValueError(f"Missing or corrupt prepared font input: {font['name']} {filename}") - destination = payload / "usr/share/licenses/cybexos-fonts" / (font["name"] + "-" + font["license_filename"]) - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(base / font["license_filename"], destination) - destination.chmod(0o644) - - -def prepare_quickshell(root, payload): - """Package the same shell sources deployed by the checkout desktop role.""" - quickshell = root / "roles/desktop/files/quickshell" - runtime = payload / "usr/share/cybexos/runtime/quickshell" - for source in sorted(quickshell.rglob("*")): - if not source.is_file() or source.is_symlink() or "__pycache__" in source.parts or source.suffix == ".pyc": - continue - target = runtime / source.relative_to(quickshell) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, target) - target.chmod(0o755 if source.parent.name == "scripts" and source.suffix != ".py" else 0o644) - if source.suffix in (".qml", ".js"): - target.write_text(target.read_text().replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - - -def prepare_defaults(root, payload, environment, inventory): - contract = json.loads((root / "assets/desktop-contract.json").read_text()) - vendor = payload / "usr/share/cybexos" - seed = vendor / "user-seed" - config = seed / ".config/cybexos" - config.mkdir(parents=True, exist_ok=True) - (config / "shell.json").write_text(json.dumps(contract["shell"], indent=2) + "\n") - shutil.copytree(root / "assets/wallpapers", seed / "Pictures/Wallpapers", dirs_exist_ok=True) - shutil.copy2(root / "assets/desktop-contract.json", vendor / "desktop-contract.json") - shutil.copy2(root / "assets/PROVENANCE.json", vendor / "artwork-provenance.json") - # Use the same MIME policy, including T3's OAuth callback, as Ansible. - mime = environment.from_string((root / "roles/dotfiles/templates/mimeapps.list.j2").read_text()) - (seed / ".config/mimeapps.list").write_text(mime.render(features=inventory["features"])) - - theme = payload / "usr/share/plymouth/themes/cybex" - theme.mkdir(parents=True, exist_ok=True) - for file in (root / "roles/boot/files").iterdir(): - if file.is_file() and not file.is_symlink(): - shutil.copy2(file, theme / file.name) - # Fail packaging when an image referred to by the shared script is absent. - import re - for name in re.findall(r'Image\("([^"/]+)"\)', (theme / "cybex.script").read_text()): - if not (theme / name).is_file(): - raise FileNotFoundError(f"Incomplete Plymouth artwork: {name}") - hardening = payload / "usr/lib/sysctl.d/60-cybexos-hardening.conf" - hardening.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(root / "roles/base/files/60-cybexos-hardening.conf", hardening) - firewall = payload / "usr/lib/firewalld/zones/cybexos.xml" - firewall.parent.mkdir(parents=True, exist_ok=True) - firewall.write_text(environment.from_string( - (root / "roles/base/templates/cybexos-zone.xml.j2").read_text() - ).render(features=inventory["features"], firewall_ports=inventory["firewall_ports"])) - return contract - - -# The shared cybexos_user_include module's first-run Kitty block. User -# preferences follow it, so explicit values have precedence over defaults. -KITTY_INCLUDE = "# BEGIN CYBEXOS MANAGED INCLUDE\ninclude cybexos.conf\n# END CYBEXOS MANAGED INCLUDE\n" - - -def prepare_app_launchers(root, payload): - """Package the same desktop launchers that checkout installs deploy.""" - applications = payload / 'usr/share/cybexos/user-seed/.local/share/applications' - applications.mkdir(parents=True, exist_ok=True) - for name in ('nvim-kitty', 'brave-origin', 'chatgpt', 'com.onepassword.OnePassword'): - target = applications / (name + '.desktop') - shutil.copyfile(root / 'roles/dotfiles/files' / target.name, target) - target.chmod(0o644) - - -def prepare_session(root, payload, inventory): - """Session pieces the workstation installs per user, packaged once. - - The seed is copied into a home only where a file is absent, so the - vendor Kitty settings live in the managed fragment that provisioning - keeps current; the seeded kitty.conf only includes it and is the user's. - """ - vendor = payload / "usr/share/cybexos" - kitty = vendor / "user-seed/.config/kitty" - kitty.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / "roles/dotfiles/files/kitty.conf", kitty / "cybexos.conf") - (kitty / "kitty.conf").write_text(KITTY_INCLUDE) - prepare_system_session(root, payload, inventory) - - -def prepare_system_session(root, payload, inventory): - """The system-wide session files; image/repair-installed stages these too.""" - vendor = payload / "usr/share/cybexos" - # Provisioning links every account's agent skill slots to this copy. - skills = vendor / "agent-skills/cybexos" - if skills.exists(): - shutil.rmtree(skills) - shutil.copytree(root / "agent-skills/cybexos", skills) - # The workstation's user unit renders the same features. Installed - # systems apply their saved choice in a drop-in (image/provision.yml). - features = inventory["features"] - unit = payload / "usr/lib/systemd/user/quickshell.service" - unit.parent.mkdir(parents=True, exist_ok=True) - unit.write_text( - "[Unit]\nDescription=Quickshell desktop shell\nPartOf=hyprland-session.target\n\n" - "[Service]\nExecStart=/usr/share/cybexos/bin/cybexos-runtime exec quickshell\n" - f"Environment=CYBEXOS_CONNECTED_WIDGETS={int(bool(features['connected_widgets']))}\n" - f"Environment=CYBEXOS_DEVELOPER_TOOLS={int(bool(features['developer_tools']))}\n" - "Restart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=hyprland-session.target\n") - prepare_backup_units(root, payload) - # XPS hardware only: the unit starts when the session exports - # CYBEXOS_XPS_2026=1 from /etc/cybexos/hardware.json. - watcher = payload / "usr/libexec/cybexos-external-monitor-toggle" - watcher.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / "roles/dotfiles/templates/external-monitor-toggle.j2", watcher) - watcher.chmod(0o755) - - -def prepare_backup_units(root, payload): - """The personal-file backup timer and service the desktop role installs - per account. The helper is packaged, so the unit runs it from there - rather than through each account's ~/.local/bin link.""" - units = payload / "usr/lib/systemd/user" - units.mkdir(parents=True, exist_ok=True) - for name in ("cybexos-backup.service", "cybexos-backup.timer"): - content = (root / "roles/desktop/files" / name).read_text() - content = content.replace("%h/.local/bin/cybexos-backup", "/usr/share/cybexos/bin/cybexos-backup") - (units / name).write_text(content) - (units / name).chmod(0o644) - - -def split_seed(vendor, contract): - """Move small session defaults out of the expensive toolchain seed. - - User init merges both trees without replacing personal files. No personal - plugins, tokens, identities, monitor overrides or runtime state are inputs. - """ - seed = vendor / "user-seed" - for name, paths in (("essential-seed", contract["essentialPaths"]), - ("final-seed", contract.get("activationPaths", []))): - target = vendor / name - target.mkdir(parents=True, exist_ok=True) - for relative in paths: - path = Path(relative) - if path.is_absolute() or ".." in path.parts: - raise ValueError(f"Invalid desktop seed path: {relative}") - source = seed / path - if source.exists() or source.is_symlink(): - destination = target / path - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(source, destination) - # Record seed size during packaging, avoiding a whole-toolchain traversal - # on the critical path to every login. - total = sum(file.stat().st_size for tree in (seed, vendor / "final-seed") for file in tree.rglob("*") - if file.is_file() and not file.is_symlink()) - (vendor / "seed-groups.json").write_text(json.dumps({"totalBytes": total}) + "\n") - return total - - -def prepare_managed_defaults(vendor): - """Version only vendor fragments; application stores and user settings stay seeded once.""" - paths = ( - '.config/fish/conf.d/50-cybexos.fish', - '.config/kitty/cybexos.conf', - '.local/share/nautilus-python/extensions/localsend.py', - ) - selected = [] - for relative in paths: - for tree in ('essential-seed', 'user-seed', 'final-seed'): - source = vendor / tree / relative - if source.is_file() and not source.is_symlink(): - destination = vendor / 'managed-seed' / relative - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(source, destination) - selected.append(relative) - break - (vendor / 'managed-defaults.json').write_text(json.dumps(selected) + '\n') diff --git a/image/distribution_review.py b/image/distribution_review.py deleted file mode 100644 index 4e4d5d52..00000000 --- a/image/distribution_review.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -"""Capture actual distribution bytes and require channel-specific owner review. - -Capture is available to internal builds without approval. check-public captures -again from the candidate, then requires complete, digest-bound review coverage. -This records decisions; it cannot establish legal rights or authenticate a reviewer. -""" -import argparse -from datetime import datetime -import hashlib -import json -from pathlib import Path -import re -import stat -import subprocess -import sys - -ROOT = Path(__file__).resolve().parents[1] -CHANNELS = ('source', 'desktop-rpm', 'offline-rpm', 'iso', 'web') - - -def digest(path): - with path.open('rb') as stream: - return hashlib.file_digest(stream, 'sha256').hexdigest() - - -def identity(document): - return hashlib.sha256(json.dumps(document, sort_keys=True, separators=(',', ':')).encode()).hexdigest() - - -def regular(path): - if path.is_symlink() or not path.is_file(): - raise ValueError(f'Expected regular file: {path}') - - -def files(root): - if root.is_symlink() or not root.is_dir(): - raise ValueError(f'Expected payload directory: {root}') - result = {} - for path in sorted(root.rglob('*')): - key = 'file:' + path.relative_to(root).as_posix() - mode = path.lstat().st_mode - if stat.S_ISLNK(mode): - result[key] = {'kind': 'link', 'target': str(path.readlink())} - elif stat.S_ISREG(mode): - result[key] = {'kind': 'file', 'bytes': path.stat().st_size, 'sha256': digest(path), - 'executable': bool(mode & 0o111)} - elif not stat.S_ISDIR(mode): - raise ValueError(f'Unsupported shipped file: {path}') - if not result: - raise ValueError('Empty payload inventory') - return result - - -def rpm_files(path): - regular(path) - def query(formatting): - return subprocess.check_output(['rpm', '-qp', '--queryformat', formatting, str(path)], - text=True).strip() - if query('%{FILEDIGESTALGO}') != '8': - raise ValueError('Public RPM inventory requires SHA-256 payload digests') - result = {} - for line in query('[%{FILENAMES}\t%{FILEDIGESTS}\t%{FILELINKTOS}\t%{FILEMODES:octal}\n]').splitlines(): - name, checksum, target, mode = line.split('\t') - key = 'rpm:' + name - if key in result: - raise ValueError(f'Duplicate RPM payload path: {name}') - result[key] = {'sha256': checksum, 'target': target, 'mode': mode} - if not result: - raise ValueError('Empty RPM payload inventory') - return result - - -def revalidate_artifacts(inventory, directory): - """Bind hosted Fedora RPM-query evidence to the exact later upload bytes.""" - if not inventory.get('artifacts'): - raise ValueError('Missing captured artifact identity') - for name, record in inventory['artifacts'].items(): - if Path(name).name != name or name in ('.', '..'): - raise ValueError('Unsafe captured artifact name') - path = directory / name - regular(path) - if path.stat().st_size != record.get('bytes') or digest(path) != record.get('sha256'): - raise ValueError(f'Artifact changed after payload capture: {name}') - - -def capture(channel, *, root=None, artifacts=(), sbom=None): - if channel not in CHANNELS: - raise ValueError('Unknown distribution channel') - artifact_map = {} - for path in artifacts: - regular(path) - if path.name in artifact_map: - raise ValueError('Duplicate artifact basename') - artifact_map[path.name] = {'sha256': digest(path), 'bytes': path.stat().st_size} - if channel in ('desktop-rpm', 'offline-rpm'): - prefix = 'cybexos-desktop-' if channel == 'desktop-rpm' else 'cybexos-offline-apps-' - if len(artifacts) != 1 or not artifacts[0].name.startswith(prefix) or artifacts[0].suffix != '.rpm': - raise ValueError('Expected exactly one channel-specific RPM') - items = rpm_files(artifacts[0]) - else: - if root is None: - raise ValueError('A complete payload directory is required') - items = files(root) - if channel == 'source' and (not artifact_map or 'file:LICENSE' not in items): - raise ValueError('Source review requires the actual archive and extracted payload') - if channel == 'iso': - # The prepared publication contains all ISO parts, their checksum files, - # the signed RPMs and metadata. Its SBOM describes the exact raw ISO. - if sbom is None or sbom.resolve().parent != root.resolve(): - raise ValueError('ISO review requires the shipped SPDX SBOM inside the asset directory') - regular(sbom) - from sbom import described_package, verify_release_sbom - sidecar = sbom.with_name(sbom.name.removesuffix('.spdx.json') + '.iso.sha256') - regular(sidecar) - match = re.fullmatch(r'([a-f0-9]{64}) ([A-Za-z0-9._-]+\.iso)\n?', sidecar.read_text()) - if not match: - raise ValueError('Missing exact ISO checksum') - iso_digest, iso_name = match.groups() - document = verify_release_sbom(sbom, iso_name, iso_digest) - parts_path = root / (iso_name + '.parts.json') - regular(parts_path) - parts = json.loads(parts_path.read_text()) - if (parts.get('format') != 1 or parts.get('file') != iso_name - or parts.get('sha256') != iso_digest or not parts.get('parts')): - raise ValueError('ISO reconstruction identity disagrees with the SBOM') - complete, total = hashlib.sha256(), 0 - for index, part in enumerate(parts['parts']): - name = f'{iso_name}.part-{index:03d}' - recorded = items.get('file:' + name, {}) - if (part.get('file') != name or recorded.get('kind') != 'file' - or recorded.get('sha256') != part.get('sha256') - or recorded.get('bytes') != part.get('bytes')): - raise ValueError('Missing or changed ISO part') - with (root / name).open('rb') as stream: - while block := stream.read(8 * 1024 * 1024): - complete.update(block) - total += len(block) - if total != parts.get('bytes') or complete.hexdigest() != iso_digest: - raise ValueError('ISO parts do not reconstruct the SBOM subject') - for package in document['packages']: - key = 'package:' + package['SPDXID'] - if key in items: - raise ValueError('Duplicate SPDX package') - items[key] = package - # Raw build inventories and toolchain facts must be available to reviewers, - # rather than existing only inside a Podman cache or an old audit report. - for name in ('rpm-inventory.tsv', 'flatpak-inventory.tsv', 'package-manifest.json', - 'source-build-provenance.json', 'build-provenance.json'): - if 'file:' + name not in items: - raise ValueError(f'Missing actual build evidence: {name}') - source_facts = items['file:source-build-provenance.json']['sha256'] - if source_facts not in described_package(document).get('sourceInfo', ''): - raise ValueError('Source toolchain evidence is not bound to the ISO SBOM') - for name, artifact in artifact_map.items(): - items['artifact:' + name] = artifact - return {'schema': 1, 'channel': channel, 'artifacts': artifact_map, 'items': items} - - -def nonempty(value): - return isinstance(value, str) and bool(value.strip()) - - -def verify(inventory, manifest, evidence_root=ROOT): - if manifest.get('schema') != 1 or manifest.get('publication') != 'PUBLIC': - raise ValueError('Expected explicit PUBLIC distribution review manifest') - matches = [entry for entry in manifest.get('reviews', []) - if entry.get('channel') == inventory['channel'] and entry.get('inventorySha256') == identity(inventory)] - if len(matches) != 1: - raise ValueError(f'No unique reviewed {inventory["channel"]} inventory for {identity(inventory)}') - review = matches[0] - if review.get('decision') != 'approved' or not nonempty(review.get('reviewedBy')): - raise ValueError('Owner review is unresolved') - try: - date = datetime.fromisoformat(review['reviewedAt'].replace('Z', '+00:00')) - if date.tzinfo is None: - raise ValueError('Review timestamp needs a timezone') - except (KeyError, TypeError, ValueError) as error: - raise ValueError('Review needs a valid timestamp with timezone') from error - covered = set() - for component in review.get('components', []): - if (component.get('decision') != 'approved' - or not all(nonempty(component.get(field)) for field in ('name', 'license', 'obligations')) - or component.get('license', '').upper() in ('UNKNOWN', 'NOASSERTION', 'UNRESOLVED', 'NONE') - or component.get('trademarks') not in ('reviewed', 'not-applicable')): - raise ValueError('Unresolved component copyright, obligations or trademark review') - evidence = component.get('evidence', {}) - relative = Path(evidence.get('path', '')) - path = evidence_root / relative - if (relative.is_absolute() or '..' in relative.parts or not relative.parts - or any(parent.is_symlink() for parent in [path, *path.parents])): - raise ValueError('Evidence must be a regular repository file') - regular(path) - if not re.fullmatch('[a-f0-9]{64}', evidence.get('sha256', '')) or digest(path) != evidence['sha256']: - raise ValueError('Review evidence changed or is missing a SHA-256') - members = component.get('items', []) - # Compact groups are still bound to the exact full inventory digest; - # prefixes never approve future files. Keep original code, each third- - # party component, and independent trademark decisions in separate groups. - if isinstance(members, dict): - prefixes = members.get('prefixes', []) - excludes = members.get('excludePrefixes', []) - if (not prefixes or not all(nonempty(value) for value in [*prefixes, *excludes]) - or set(members) - {'prefixes', 'excludePrefixes'}): - raise ValueError('Invalid component inventory selectors') - members = [key for key in inventory['items'] - if key.startswith(tuple(prefixes)) and not key.startswith(tuple(excludes))] - if not members or len(set(members)) != len(members): - raise ValueError('Component must identify exact inventory items') - if covered.intersection(members) or not set(members).issubset(inventory['items']): - raise ValueError('Duplicate or unshipped review item') - covered.update(members) - missing = set(inventory['items']) - covered - if missing: - raise ValueError(f'Unreviewed shipped items: {", ".join(sorted(missing)[:8])}') - return {'publication': 'PUBLIC', 'channel': inventory['channel'], - 'inventorySha256': identity(inventory), 'reviewedBy': review['reviewedBy'], - 'reviewedAt': review['reviewedAt']} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('command', choices=('capture', 'check-public')) - parser.add_argument('--channel', required=True, choices=CHANNELS) - parser.add_argument('--root', type=Path) - parser.add_argument('--artifact', action='append', type=Path, default=[]) - parser.add_argument('--sbom', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--reviews', type=Path, default=ROOT / 'assets/distribution-review.json') - args = parser.parse_args() - try: - if args.root and args.output.resolve().is_relative_to(args.root.resolve()): - raise ValueError('Write inventories outside their input payload directory') - record = capture(args.channel, root=args.root, artifacts=args.artifact, sbom=args.sbom) - args.output.write_text(json.dumps(record, indent=2, sort_keys=True) + '\n') - print(f'{args.channel} inventory SHA-256: {identity(record)}') - if args.command == 'check-public': - verify(record, json.loads(args.reviews.read_text())) - print('PUBLIC distribution review passed') - return 0 - except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: - print(f'distribution-review: {error}', file=sys.stderr) - return 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/download_cache.py b/image/download_cache.py deleted file mode 100644 index f3ba5cda..00000000 --- a/image/download_cache.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Reuse only inventory-checksummed downloads, never builder-installed state.""" -import argparse -import http.client -import json -import os -from pathlib import Path, PurePosixPath -import re -import shutil -import tempfile -import time -import urllib.request -import fcntl - -import yaml -from build_support import atomic_json, digest - -COPR_SHA256 = "581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7" - - -def pins(root): - inventory = yaml.safe_load((Path(root) / "inventory/group_vars/all.yml").read_text()) - result = [] - for app in inventory.get("github_release_apps", []) + inventory.get("nerd_font_apps", []): - tag = re.sub(r"[^A-Za-z0-9._+-]", "_", str(app["version"])) - result.append((app["checksum"].removeprefix("sha256:"), - f"/var/cache/cybexos-upstream/{app['name']}/{tag}/", app["asset_regex"])) - for font in inventory.get("pinned_font_archives", []): - result.append((font["checksum"].removeprefix("sha256:"), - f"/var/cache/cybexos-upstream/{font['name']}/{font['version']}.zip", None)) - for font in inventory.get("pinned_font_files", []): - for checksum, filename in (("checksum", "filename"), ("license_checksum", "license_filename")): - result.append((font[checksum].removeprefix("sha256:"), - f"/usr/local/share/fonts/{font['name']}/{font['version']}/{font[filename]}", None)) - result.append((COPR_SHA256, "/home/builder/build/hyprland.gpg", None)) - return result - - -def allowed(entry, contract): - path = entry.get("path", "") - if not path.startswith("/") or ".." in PurePosixPath(path).parts or "\x00" in path: - return False - for checksum, location, pattern in contract: - if entry.get("sha256") != checksum: - continue - if pattern is None and path == location: - return True - if pattern and str(PurePosixPath(path).parent) + "/" == location and re.search(pattern, PurePosixPath(path).name): - return True - return False - - -def verified_entries(directory, contract): - directory = Path(directory) - manifest = directory / "manifest.json" - if not manifest.exists(): - return [] - result = [] - for entry in json.loads(manifest.read_text())["entries"]: - if not allowed(entry, contract): - continue # An older inventory may leave obsolete entries; never import them. - blob = directory / entry["sha256"] - if blob.is_symlink() or not blob.is_file() or digest(blob) != entry["sha256"]: - # Corrupt caches are disposable; miss rather than trust them. - continue - result.append(entry) - return result - - -def merge_cache(source, destination, contract): - source, destination = Path(source), Path(destination) - destination.mkdir(parents=True, exist_ok=True) - with (destination / ".lock").open("a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - existing = {entry["path"]: entry for entry in verified_entries(destination, contract)} - for entry in verified_entries(source, contract): - target = destination / entry["sha256"] - if not target.is_file() or digest(target) != entry["sha256"]: - with tempfile.NamedTemporaryFile(dir=destination, delete=False) as stream: - temporary = Path(stream.name) - try: - shutil.copyfile(source / entry["sha256"], temporary) - if digest(temporary) != entry["sha256"]: - raise ValueError("Download cache changed during transfer") - temporary.replace(target) - finally: - temporary.unlink(missing_ok=True) - existing[entry["path"]] = entry - atomic_json(destination / "manifest.json", {"entries": list(existing.values())}) - - -def export_cache(directory, contract, filesystem=Path("/")): - directory = Path(directory) - directory.mkdir(parents=True, exist_ok=True) - entries = [] - for checksum, location, pattern in contract: - base = filesystem / location.lstrip("/") - candidates = list(base.iterdir()) if pattern and base.is_dir() else ([] if pattern else [base]) - for candidate in candidates: - if not candidate.is_file() or candidate.is_symlink(): - continue - entry = {"path": "/" + str(candidate.relative_to(filesystem)), "sha256": checksum} - if not allowed(entry, contract) or digest(candidate) != checksum: - continue - shutil.copyfile(candidate, directory / checksum) - entries.append(entry) - atomic_json(directory / "manifest.json", {"entries": entries}) - - -def import_cache(directory, contract, filesystem=Path("/")): - for entry in verified_entries(directory, contract): - target = filesystem / entry["path"].lstrip("/") - # Never let a poisoned guest path redirect a root copy outside its contract. - if any(parent.is_symlink() for parent in [target, *target.parents]): - raise ValueError("Refusing a symlink in download cache destination") - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(Path(directory) / entry["sha256"], target) - target.chmod(0o644) - - -def cached_download(url, checksum, directory): - """Content-addressed cloud base cache with verified atomic downloads.""" - directory = Path(directory) - directory.mkdir(parents=True, exist_ok=True) - target = directory / checksum - with (directory / (checksum + ".lock")).open("a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - if target.is_file() and not target.is_symlink() and digest(target) == checksum: - return target - with tempfile.NamedTemporaryFile(dir=directory, prefix=".download-", delete=False) as stream: - temporary = Path(stream.name) - try: - for attempt in range(1, 5): - try: - with urllib.request.urlopen(url, timeout=60) as response, temporary.open("wb") as stream: - shutil.copyfileobj(response, stream, 1024 * 1024) - if digest(temporary) == checksum: - temporary.replace(target) - break - problem = "checksum mismatch" - except (OSError, http.client.HTTPException) as error: - problem = str(error) or type(error).__name__ - temporary.unlink(missing_ok=True) - print(f"Download attempt {attempt} of 4 failed: {problem}", flush=True) - if attempt == 4: - raise RuntimeError(f"Could not download verified builder image: {problem}") - time.sleep(10 * attempt) - finally: - temporary.unlink(missing_ok=True) - return target - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("action", choices=("import", "export")) - parser.add_argument("directory", type=Path) - args = parser.parse_args() - if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "image-builder": - parser.error("only the disposable image builder may import/export guest cache") - contract = pins(Path(__file__).resolve().parents[1]) - (import_cache if args.action == "import" else export_cache)(args.directory, contract) - - -if __name__ == "__main__": - main() diff --git a/image/dracut-live.conf b/image/dracut-live.conf deleted file mode 100644 index f634d63d..00000000 --- a/image/dracut-live.conf +++ /dev/null @@ -1,8 +0,0 @@ -# Generic live initramfs. Do not force legacy IDE/FireWire modules that were -# removed from modern kernels (livecd-tools 31 still names them). -filesystems+=" vfat isofs ext4 xfs btrfs squashfs " -# Include early kernel modesetting for the generic live image. Let the kernel -# select the matching GPU driver; do not force a vendor or blacklist Nouveau. -add_dracutmodules+=" livenet dmsquash-live pollcdrom drm " -hostonly="no" -dracut_rescue_image="no" diff --git a/image/fedora_target.py b/image/fedora_target.py deleted file mode 100644 index 82bd7212..00000000 --- a/image/fedora_target.py +++ /dev/null @@ -1,36 +0,0 @@ -"""The Fedora release image tooling targets, read from the installation contract. - -`fedora_release` in inventory/group_vars/all.yml is the release both -installation paths assert at runtime (site.yml and image/provision.yml), and -release-manifest.json `supportedFedora` is what updates and major upgrades -accept. Image tooling derives its release from the former and refuses one the -latter does not support. Checksum-pinned inputs that still name a release (the -Fedora Cloud builder image, for one) are kept in step by -image/test_fedora_release.py. `python3 image/fedora_target.py` prints the -release for shell steps. -""" -import json -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -# One quoted decimal, exactly as the inventory declares it. CI workflows and -# the image builder call this module rather than parsing the line themselves. -DECLARATION = re.compile(r'^fedora_release: "([1-9][0-9]*)"$', re.MULTILINE) - - -def fedora_release(root=ROOT): - """Return the targeted release as a string, e.g. "44".""" - root = Path(root) - declared = DECLARATION.findall((root / "inventory/group_vars/all.yml").read_text()) - if len(declared) != 1: - raise ValueError('inventory/group_vars/all.yml must declare fedora_release once, as a quoted number') - release = declared[0] - supported = json.loads((root / "release-manifest.json").read_text()).get("supportedFedora") - if not isinstance(supported, list) or release not in supported: - raise ValueError(f"Fedora {release} is not in release-manifest.json supportedFedora") - return release - - -if __name__ == "__main__": - print(fedora_release()) diff --git a/image/github_release.py b/image/github_release.py deleted file mode 100644 index 56e41b9d..00000000 --- a/image/github_release.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Prepare verified GitHub Release assets and a small GitHub Pages RPM repository.""" -import argparse -import hashlib -import gzip -import json -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -from urllib.parse import urljoin -import xml.etree.ElementTree as ET - -from qualification_contract import FRESH_SCENARIOS, validate as validate_qualification -from release_metadata import fingerprint, public_key -from release_repository import rename_new_directory, sha256, verify_signed_rpm -from sbom import sbom_name, verify_release_sbom - -ASSET_LIMIT = 2 * 1024 ** 3 -ISO_PART_SIZE = 1900 * 1024 ** 2 - - -def github_url(repository, tag): - if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository): - raise ValueError('Use an owner/repository GitHub name') - if not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', tag): - raise ValueError('Use a versioned vX.Y.Z release tag') - return f'https://github.com/{repository}/releases/download/{tag}' - - -def verify_checksums(directory, filename="SHA256SUMS"): - manifest = directory / filename - checked = set() - for line in manifest.read_text().splitlines(): - match = re.fullmatch(r'([0-9a-f]{64}) (.+)', line) - if not match: - raise ValueError('Invalid checksum manifest') - if not re.fullmatch(r'[A-Za-z0-9_.+~^/-]+', match[2]): - raise ValueError('Checksum filenames must be safe ASCII paths') - name = Path(match[2]) - if name.is_absolute() or '..' in name.parts or name.as_posix() in checked: - raise ValueError('Unsafe or repeated checksum filename') - candidate = directory / name - if candidate.is_symlink() or not candidate.is_file() or not candidate.resolve().is_relative_to(directory.resolve()): - raise ValueError('Checksum file escapes the artifact directory') - if sha256(candidate) != match[1]: - raise ValueError(f'Artifact checksum mismatch: {name}') - checked.add(name.as_posix()) - inventory = set() - for path in directory.rglob('*'): - if path.is_symlink() or (not path.is_dir() and not path.is_file()): - raise ValueError('Artifact trees must contain only regular files and directories') - if path.is_file() and path != manifest: - inventory.add(path.relative_to(directory).as_posix()) - if checked != inventory: - raise ValueError('Checksum inventory does not cover the complete artifact directory') - return checked - - -def verify_release(directory, expected, temporary): - public_key(directory / 'CYBEXOS-desktop.asc', expected) - gpg_home = temporary / 'gpg' - gpg_home.mkdir(mode=0o700) - gpg = ['gpg', '--no-options', '--homedir', str(gpg_home), '--batch', '--no-autostart'] - subprocess.run([*gpg, '--import', str(directory / 'CYBEXOS-desktop.asc')], check=True, capture_output=True) - for relative in ('release.json', 'repodata/repomd.xml'): - subprocess.run([*gpg, '--verify', str(directory / (relative + '.asc')), str(directory / relative)], - check=True, capture_output=True) - - -def split_iso(source, destination, chunk_size=ISO_PART_SIZE): - """Keep the original filename/checksum, with a deterministic reconstruction manifest.""" - if not source.name.isascii() or not re.fullmatch(r'[A-Za-z0-9._-]+\.iso', source.name): - raise ValueError('ISO filename must be ASCII without spaces') - if not isinstance(chunk_size, int) or not 0 < chunk_size < ASSET_LIMIT: - raise ValueError('ISO part size must be positive and below the asset limit') - digest = hashlib.sha256() - parts = [] - with source.open('rb') as stream: - number = 0 - while True: - remaining = chunk_size - name = f'{source.name}.part-{number:03d}' - part = destination / name - part_digest = hashlib.sha256() - size = 0 - with part.open('xb') as output: - while remaining: - block = stream.read(min(8 * 1024 * 1024, remaining)) - if not block: - break - output.write(block) - digest.update(block) - part_digest.update(block) - size += len(block) - remaining -= len(block) - if not size: - part.unlink() - break - parts.append({'file': name, 'bytes': size, 'sha256': part_digest.hexdigest()}) - number += 1 - if not parts: - raise ValueError('ISO is empty') - record = {'format': 1, 'file': source.name, 'bytes': sum(item['bytes'] for item in parts), - 'sha256': digest.hexdigest(), 'parts': parts} - (destination / (source.name + '.parts.json')).write_text(json.dumps(record, indent=2) + '\n') - (destination / (source.name + '.sha256')).write_text(f"{record['sha256']} {source.name}\n") - return record - - -def verify_metadata(directory, packages): - """Bind authenticated repomd metadata to the exact RPMs we will upload.""" - namespace = {'r': 'http://linux.duke.edu/metadata/repo', - 'p': 'http://linux.duke.edu/metadata/common'} - root = ET.parse(directory / 'repodata/repomd.xml').getroot() - seen, primary = set(), None - for entry in root.findall('r:data', namespace): - location = entry.find('r:location', namespace) - checksum = entry.find('r:checksum', namespace) - if location is None or checksum is None or checksum.get('type') != 'sha256': - raise ValueError('Repository metadata requires SHA-256 locations') - relative = location.get('href', '') - path = Path(relative) - if path.is_absolute() or '..' in path.parts or len(path.parts) != 2 or path.parts[0] != 'repodata': - raise ValueError('Repository metadata location escapes repodata') - if relative in seen: - raise ValueError('Repeated repository metadata location') - seen.add(relative) - source = directory / path - if sha256(source) != checksum.text: - raise ValueError('Signed repository metadata checksum mismatch') - if entry.get('type') == 'primary': - if primary is not None: - raise ValueError('Repeated primary repository metadata') - if source.suffix != '.gz': - raise ValueError('Primary metadata must use gzip compression') - with gzip.open(source, 'rb') as stream: - content = stream.read(64 * 1024 * 1024 + 1) - if len(content) > 64 * 1024 * 1024: - raise ValueError('Primary repository metadata exceeds the size limit') - primary = ET.fromstring(content) - if primary is None: - raise ValueError('Repository is missing primary metadata') - listed = set() - records = {package['sha256']: package for package in packages} - if len(records) != len(packages): - raise ValueError('Repeated package digest') - for package in primary.findall('p:package', namespace): - checksum = package.find('p:checksum', namespace) - location = package.find('p:location', namespace) - if checksum is None or checksum.get('type') != 'sha256' or location is None: - raise ValueError('Primary package requires a SHA-256 checksum and URL') - record = records.get(checksum.text) - if record is None or checksum.text in listed: - raise ValueError('Primary metadata does not match the release packages') - listed.add(checksum.text) - base = location.get('{http://www.w3.org/XML/1998/namespace}base', '') - if urljoin(base, location.get('href', '')) != record['url']: - raise ValueError('Primary metadata package URL differs from this GitHub Release') - version = package.find('p:version', namespace) - if (package.findtext('p:name', namespaces=namespace) != record['name'] - or package.findtext('p:arch', namespaces=namespace) != record['arch'] - or version is None - or any(version.get(key) != record[field] for key, field in - (('epoch', 'epoch'), ('ver', 'version'), ('rel', 'release')))): - raise ValueError('Primary metadata package identity differs from the manifest') - if listed != set(records): - raise ValueError('Primary metadata omits a release package') - allowed = seen | {'repodata/repomd.xml', 'repodata/repomd.xml.asc'} - present = {str(path.relative_to(directory)) for path in (directory / 'repodata').rglob('*') if path.is_file()} - if present != allowed: - raise ValueError('Repository contains unreferenced metadata files') - - -def verify_qualifications(paths, iso_digest, packages): - """Require fresh installer and prior-release upgrade/recovery evidence.""" - scenarios = FRESH_SCENARIOS - fresh, upgrade, reports = set(), False, [] - candidates = {package['unsigned_input_sha256'] for package in packages - if package.get('name', 'cybexos-desktop') == 'cybexos-desktop'} - offline = next((package['unsigned_input_sha256'] for package in packages - if package.get('name') == 'cybexos-offline-apps'), None) - for path in paths: - path = Path(path) - if path.stat().st_size > 1024 * 1024: - raise ValueError('Qualification report exceeds the size limit') - report = json.loads(path.read_text()) - if not isinstance(report, dict) or report.get('status') != 'passed': - raise ValueError('Every qualification report must have passed') - validate_qualification(report) - checks = report.get('checks') - if not isinstance(checks, list) or not all(isinstance(item, str) for item in checks): - raise ValueError('Qualification checks must be a list of completed check names') - prior = report.get('iso_sha256', '') - if not isinstance(prior, str) or not re.fullmatch(r'[0-9a-f]{64}', prior): - raise ValueError('Qualification must identify the tested ISO SHA-256') - if (prior == iso_digest and report['qualification_kind'] == 'fresh' - and report['boot_entry'] == 'install' and report.get('scenario') in scenarios): - if report['scenario'].startswith('plain-'): - checkout = report.get('checkout_parity', {}) - content = report.get('source_content_sha256', '') - if (not isinstance(content, str) or not re.fullmatch(r'[0-9a-f]{64}', content) - or not {'installed-parity-fresh', 'installed-parity-saved'} <= set(checks) - or checkout.get('status') != 'passed' or checkout.get('scenario') != report['scenario'] - or checkout.get('source_content_sha256') != content - or checkout.get('source_revision') != report.get('source_revision') - or not {'full-checkout-installation', 'installed-parity-fresh', 'installed-parity-saved', - 'full-graphical-session'} <= set(checkout.get('checks', []))): - raise ValueError('Release requires same-source real checkout/ISO parity for fresh and saved choices') - fresh.add(report['scenario']) - if (report['qualification_kind'] == 'upgrade' and prior != iso_digest - and report.get('candidate_rpm_sha256') in candidates - and (offline is None or report.get('candidate_offline_rpm_sha256') == offline) - and {'installed-rpm-upgrade', 'recovery-boot-restore'} <= set(checks)): - upgrade = True - reports.append(report) - sources = {(report['source_revision'], report['source_content_sha256']) for report in reports} - if len(sources) > 1: - raise ValueError('Qualification reports must bind the same candidate source revision and content digest') - if fresh != scenarios: - raise ValueError('Release requires all four fresh ISO installer qualifications: ' + ', '.join(sorted(scenarios - fresh))) - if not upgrade: - raise ValueError('Release requires a different prior ISO with the exact candidate RPM upgrade and recovery qualification') - return reports - - -def prepare(signed, artifacts, destination, repository, tag, expected, qualifications=()): - signed, artifacts, destination = Path(signed), Path(artifacts), Path(destination).absolute() - expected = fingerprint(expected) - downloads = github_url(repository, tag) - if destination.exists() or destination.is_symlink(): - raise ValueError('Output must be a new directory') - repository_files = verify_checksums(signed) - artifact_files = verify_checksums(artifacts) - manifest = json.loads((signed / 'release.json').read_text()) - channel = json.loads((signed / 'update-channel.json').read_text()) - owner, project = repository.split('/') - pages_url = f'https://{owner.lower()}.github.io/{project}/44/x86_64' - if (manifest.get('format') != 1 or manifest.get('baseurl') != pages_url - or channel != {'baseurl': pages_url, 'fingerprint': expected, 'key_file': 'CYBEXOS-desktop.asc'}): - raise ValueError('Release channel must match this exact GitHub Pages repository') - if manifest['fingerprint'] != expected: - raise ValueError('Release manifest uses an unexpected signing key') - packages = manifest['packages'] - if not isinstance(packages, list) or not packages: - raise ValueError('Release contains no RPMs') - package_names = set() - identities = set() - for package in packages: - if (package.get('name') not in ('cybexos-desktop', 'cybexos-offline-apps') - or package.get('arch') != 'x86_64' - or (package['name'] == 'cybexos-desktop' and package.get('version') != tag[1:].replace('-', '~', 1)) - or (package['name'] == 'cybexos-offline-apps' and not re.fullmatch(r'\d+\.\d+\.\d+', package.get('version', '')))): - raise ValueError('RPM identity must match the release tag and desktop architecture') - if package['name'] in identities: - raise ValueError('Release contains duplicate package names') - identities.add(package['name']) - path = signed / package['file'] - if not re.fullmatch(r'Packages/[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', package['file']) or path.name in package_names: - raise ValueError('RPM filenames must be safe, distinct release assets') - package_names.add(path.name) - for key in ('sha256', 'unsigned_input_sha256'): - if not isinstance(package.get(key), str) or not re.fullmatch(r'[0-9a-f]{64}', package[key]): - raise ValueError('Release package is missing a complete SHA-256 identity') - if package['file'] not in repository_files or package.get('url') != downloads + '/' + path.name: - raise ValueError('RPM metadata must reference this exact GitHub Release') - if path.stat().st_size >= ASSET_LIMIT: - raise ValueError('Desktop RPM exceeds the GitHub 2 GiB asset limit; split the package before release') - if sha256(path) != package['sha256']: - raise ValueError('Signed manifest RPM digest mismatch') - if 'cybexos-desktop' not in identities: - raise ValueError('Release requires the desktop RPM') - isos = [artifacts / name for name in artifact_files if name.endswith('.iso')] - if len(isos) != 1: - raise ValueError('Exactly one checksum-verified ISO is required') - iso_digest = sha256(isos[0]) - # The SPDX SBOM must describe this exact ISO and the candidate RPM bytes. - sbom = artifacts / sbom_name(isos[0].name) - if sbom.name not in artifact_files or [name for name in artifact_files if name.endswith('.spdx.json')] != [sbom.name]: - raise ValueError('Exactly one checksum-verified SPDX SBOM named after the ISO is required') - sbom_digest = sha256(sbom) - verify_release_sbom(sbom, isos[0].name, iso_digest, - {package['name']: package['unsigned_input_sha256'] for package in packages}) - reports = verify_qualifications(qualifications, iso_digest, packages) - destination.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix='.cybexos-github-', dir=destination.parent) as directory: - work = Path(directory) - verify_release(signed, expected, work) - verify_metadata(signed, packages) - stage = work / 'output' - assets = stage / 'assets' - pages = stage / 'pages/44/x86_64' - assets.mkdir(parents=True) - pages.mkdir(parents=True) - for package in packages: - source = signed / package['file'] - copied = assets / source.name - shutil.copyfile(source, copied) - if sha256(copied) != package['sha256']: - raise ValueError('RPM changed during release preparation') - verify_signed_rpm(copied, signed / 'CYBEXOS-desktop.asc', work) - for name in ('release.json', 'release.json.asc', 'CYBEXOS-desktop.asc', 'update-channel.json'): - content = (signed / name).read_bytes() - if name == 'update-channel.json' and json.loads(content) != channel: - raise ValueError('Channel configuration changed during release preparation') - # Both delivery locations must contain the same snapshot; the - # copied Pages signatures are verified again below. - (assets / name).write_bytes(content) - (pages / name).write_bytes(content) - shutil.copytree(signed / 'repodata', pages / 'repodata') - iso = split_iso(isos[0], assets) - if iso['sha256'] != iso_digest: - raise ValueError('ISO changed during release preparation') - shutil.copyfile(Path(__file__).with_name('reconstruct-iso'), assets / 'reconstruct-iso.py') - shutil.copyfile(sbom, assets / sbom.name) - if sha256(assets / sbom.name) != sbom_digest: - raise ValueError('SBOM changed during release preparation') - (assets / 'qualification-reports.json').write_text(json.dumps(reports, indent=2) + '\n') - copied_verify = work / 'copied-verification' - copied_verify.mkdir() - verify_release(pages, expected, copied_verify) - verify_metadata(pages, packages) - # Metadata checksums contain only files hosted by Pages, never the large RPMs. - for target in (assets, pages): - files = sorted(path for path in target.rglob('*') if path.is_file()) - checksum_name = 'desktop-SHA256SUMS' if target == assets else 'SHA256SUMS' - (target / checksum_name).write_text(''.join(f'{sha256(path)} {path.relative_to(target)}\n' for path in files)) - (stage / 'pages/.nojekyll').touch() - (stage / 'pages/index.html').write_text('' - 'CybexOS updates

CybexOS desktop updates

' - '

Signed Fedora 44 x86_64 repository. Use the public channel configuration and independently verify its signing fingerprint.

' - 'Channel configuration\n') - rename_new_directory(stage, destination) - return destination - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--signed-repository', type=Path, required=True) - parser.add_argument('--artifacts', type=Path, required=True) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--repository', default='DigitalPals/CybexOS') - parser.add_argument('--tag', required=True) - parser.add_argument('--fingerprint', required=True) - parser.add_argument('--qualification', type=Path, action='append', required=True, - help='Passed VM qualification JSON; repeat for four installer scenarios and prior-release upgrade/recovery') - args = parser.parse_args() - try: - print(prepare(args.signed_repository, args.artifacts, args.output, - args.repository, args.tag, args.fingerprint, args.qualification)) - except (OSError, ValueError, KeyError, TypeError, ET.ParseError, subprocess.SubprocessError) as error: - parser.exit(1, f'github-release: {error}\n') - - -if __name__ == '__main__': - main() diff --git a/image/install-live-rootfs b/image/install-live-rootfs deleted file mode 100644 index f475e731..00000000 --- a/image/install-live-rootfs +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -"""Install live-only files without copying checkout ownership or directory modes.""" -from pathlib import Path -import shutil -import sys - - -def install_tree(source, destination): - for path in sorted(source.rglob("*")): - if path.is_symlink(): - raise ValueError(f"Live integration must not contain symlinks: {path}") - relative = path.relative_to(source) - target = destination / relative - if path.is_dir(): - target.mkdir(mode=0o755, parents=True, exist_ok=True) - elif path.is_file(): - target.parent.mkdir(mode=0o755, parents=True, exist_ok=True) - shutil.copyfile(path, target) - executable = relative.parts[:2] in (("usr", "libexec"), ("usr", "bin")) - target.chmod(0o755 if executable else 0o644) - - -if __name__ == "__main__": - install_tree(Path(__file__).with_name("live-rootfs"), Path(sys.argv[1])) diff --git a/image/installation_policy.py b/image/installation_policy.py deleted file mode 100644 index 48466459..00000000 --- a/image/installation_policy.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Fresh-install policy shared by the ISO and the checkout installer. - -The inventory supplies application and security defaults. Machine identity -and verified disk encryption are inputs, never properties of the build host. -Existing saved choices are applied separately and must not be reset here. -""" -import yaml - - -BOOLEANS = ('manage_system_identity', 'manage_personal_dotfiles', 'cleanup_legacy_xps_artifacts', - 'passwordless_wheel', 'passwordless_local_polkit', 'docker_sudoless', 'desktop_autologin', - 'start_optional_hardware_services', 'allow_insecure_sccache_transport', 'xps_2026_camera_enabled') -FEATURES = ('developer_tools', 'connected_widgets', 'proprietary_apps', 'tailscale', 'docker', 'podman', - 'steam', 'private_hooks', 'apple_display', 'source_builds', 'local_network_services') -# Choices an installation seed (or a factory reset's preserved selection) may -# make before the first account exists. Identity, automatic login and -# passwordless sudo are decided by the installer's own verified steps. -CHOICE_BOOLEANS = ('manage_personal_dotfiles', 'passwordless_local_polkit', 'docker_sudoless') - - -def choices(document): - """Validate explicit first-account choices in the config.yml schema. - - Only CHOICE_BOOLEANS and feature switches are accepted, each a boolean; - an absent key keeps the installation default. Raises ValueError with a - message that names the offending key. - """ - if document is None: - return {} - if not isinstance(document, dict): - raise ValueError('config must be a mapping of installation choices') - unknown = sorted(set(document) - set(CHOICE_BOOLEANS) - {'features'}) - if unknown: - raise ValueError(f'config.{unknown[0]} is not a choice an installation can make') - result = {} - for key in CHOICE_BOOLEANS: - if key in document: - if type(document[key]) is not bool: - raise ValueError(f'config.{key} must be true or false') - result[key] = document[key] - features = document.get('features', {}) - if not isinstance(features, dict): - raise ValueError('config.features must be a mapping') - unknown = sorted(set(features) - set(FEATURES)) - if unknown: - raise ValueError(f'config.features.{unknown[0]} is not a CybexOS feature') - for key, value in features.items(): - if type(value) is not bool: - raise ValueError(f'config.features.{key} must be true or false') - if features: - result['features'] = dict(features) - return result - - -def apply_choices(values, selected): - """Return defaults with validated choices applied (a new mapping).""" - selected = choices(selected) - result = {**values, 'features': dict(values['features'])} - result.update({key: value for key, value in selected.items() if key != 'features'}) - result['features'].update(selected.get('features', {})) - # The docker group is root-equivalent; it only exists with Docker selected. - result['docker_sudoless'] = result['docker_sudoless'] and result['features']['docker'] - return result - - -def defaults(inventory, *, fresh_account=True, encrypted=False): - values = yaml.safe_load(inventory.read_text()) - if not isinstance(values, dict) or not isinstance(values.get('features'), dict): - raise ValueError('Installation defaults must contain the feature contract') - features = values['features'] - if set(features) != set(FEATURES): - raise ValueError('Installation feature keys must match both installer schemas') - for key in FEATURES: - if type(features.get(key)) is not bool: - raise ValueError(f'features.{key} must have a boolean installation default') - for key in BOOLEANS: - # The inventory retains the legacy gdm_autologin template for direct - # Ansible callers; fresh installers use verified encryption instead. - if key != 'desktop_autologin' and type(values.get(key)) is not bool: - raise ValueError(f'{key} must have a boolean installation default') - result = {key: values.get(key) is True for key in BOOLEANS} - # Match the ISO: installed identity remains owned by Fedora/Anaconda; - # a fresh account receives personal defaults and encrypted-root autologin. - # Repairing an older installation must never opt it into either choice. - result['manage_system_identity'] = False - result['manage_personal_dotfiles'] = fresh_account and values.get('manage_personal_dotfiles') is True - result['desktop_autologin'] = fresh_account and encrypted is True - result['features'] = {key: features[key] for key in FEATURES} - return result diff --git a/image/installed_outcomes.py b/image/installed_outcomes.py deleted file mode 100644 index 7ace6c94..00000000 --- a/image/installed_outcomes.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Capture real installed outcomes and compare checkout installation with ISO. - -Capture is read-only and must run as root inside a disposable qualification -guest with a live graphical account. Never substitute fixture output for it. -""" -import argparse -import configparser -import grp -import hashlib -import json -import os -from pathlib import Path -import pwd -import re -import shlex -import subprocess -import xml.etree.ElementTree as ET - -SYSTEM_UNITS = ('NetworkManager.service', 'firewalld.service', 'avahi-daemon.service', 'cups.service', - 'fstrim.timer', 'fwupd-refresh.timer', 'tuned.service', 'tuned-ppd.service', 'bluetooth.service', - 'tailscaled.service', 'docker.socket', 'docker.service', 'sddm.service', - 'cybexos-recovery-refresh.service', 'cybexos-update-recover.service', - 'cybexos-update-download.timer') -USER_UNITS = ('quickshell.service', 'hypridle.service', 'voxtype.service', 'hyprland-session.target', - 'cybexos-session-lock.service', 'cybexos-input-method.service') -COMMANDS = ('cybex', 'fastfetch', 'claude', 'opencode', 'codex', 'cargo', 'rustup', 'node', 'npm', - 'bun', 'lazygit', 'lazydocker', 'balena', 'mdview', 'awww', 'wayfreeze', 'voxtype', - 'localsend', 't3code-desktop', 'adb') -CHOICES = ('config_schema_version', 'machine_timezone', 'machine_locale', 'regional_locale', - 'machine_keyboard_layout', 'machine_keyboard_variant', 'manage_system_identity', - 'manage_personal_dotfiles', 'passwordless_wheel', 'passwordless_local_polkit', - 'docker_sudoless', 'desktop_autologin', 'start_optional_hardware_services', - 'allow_insecure_sccache_transport', 'features') -CAPTURE_FIELDS = {'format', 'installation', 'scenario', 'profile', 'source_revision', 'source_content_sha256', - 'hardware', 'required_packages', 'installed_packages', 'flatpaks', 'commands', 'choices', - 'system_units', 'user_units', 'settings', 'input', 'authentication', 'firewall', - 'default_apps', 'personal', 'recovery', 'selinux', 'boot_target', 'graphical_session', 'sole_managed_shell'} - - -def command(args, *, environment=None, accepted=(0,)): - result = subprocess.run(args, env=environment, capture_output=True, text=True, timeout=40, check=False) - if result.returncode not in accepted: - raise RuntimeError('Installed outcome read failed: ' + ' '.join(args[:3]) + ' … ' + str(args[-1])[:160]) - return result.stdout.strip() - - -def read_json(path): - return json.loads(Path(path).read_text()) - - -def normalize(value, home): - if isinstance(value, str): - return value.replace(home, '$HOME').replace('/usr/share/cybexos/runtime', '$RUNTIME').replace('$HOME/.local/share/cybexos/runtime', '$RUNTIME') - if isinstance(value, dict): - return {key: normalize(item, home) for key, item in value.items()} - if isinstance(value, list): - return [normalize(item, home) for item in value] - return value - - -def effective_unit_state(name, enabled, active, session_dependencies): - """Global target links and per-user enablement start the same session unit.""" - linked = name in session_dependencies and enabled in ('enabled', 'enabled-runtime', 'disabled', 'static', 'indirect') - return {'enabled': 'session-target' if linked else enabled, 'active': active} - - -def policy_choices(config): - selected = {key: config.get(key) for key in CHOICES} - # Anaconda writes the canonical spelling; timedatectl also accepts UTC. - # Compare the same timezone without rewriting the person's saved choice. - if selected['machine_timezone'] == 'UTC': - selected['machine_timezone'] = 'Etc/UTC' - return selected - - -def capture(user, installation, scenario, profile, manifest, provenance): - import yaml - if os.geteuid() != 0 or user != 'qualification' or command(['systemd-detect-virt']) not in ('kvm', 'qemu'): - raise RuntimeError('Capture requires the disposable qualification guest and account') - account = pwd.getpwnam(user) - home = Path(account.pw_dir) - environment = {**os.environ, 'HOME': str(home), 'USER': user, 'LOGNAME': user, - 'XDG_RUNTIME_DIR': '/run/user/' + str(account.pw_uid), - 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/' + str(account.pw_uid) + '/bus', - 'PATH': ':'.join([str(home / relative) for relative in ('.local/bin', '.cargo/bin', '.npm-global/bin', 'Android/Sdk/platform-tools')] - + ['/usr/local/bin', '/usr/bin', '/bin'])} - def user_command(args, accepted=(0,)): - return command(['runuser', '-u', user, '--', *args], environment=environment, accepted=accepted) - for line in user_command(['systemctl', '--user', 'show-environment']).splitlines(): - if line.startswith(('HYPRLAND_INSTANCE_SIGNATURE=', 'WAYLAND_DISPLAY=')): - key, value = line.split('=', 1) - environment[key] = value - if not environment.get('HYPRLAND_INSTANCE_SIGNATURE'): - raise RuntimeError('Capture requires an actual running Hyprland desktop') - build = read_json(provenance) - for key, pattern in (('source_revision', r'[0-9a-f]{40,64}'), ('source_content_sha256', r'[0-9a-f]{64}')): - if not re.fullmatch(pattern, str(build.get(key, ''))): - raise RuntimeError('Installed build is missing exact source provenance') - applications = read_json(manifest) - required = {} - for selector in applications['packages']: - required[selector] = sorted(command(['rpm', '-q', '--qf', '%{NAME}.%{ARCH}=%{EVR}\n', selector]).splitlines()) - installed = sorted(command(['rpm', '-qa', '--qf', '%{NAME}.%{ARCH}=%{EVR}\n']).splitlines()) - # `active` is the deployed commit. Flatpak 1.18 rejects the older `commit` - # column name with "Unknown column", which failed every capture. - flatpaks = sorted(command(['flatpak', 'list', '--system', '--app', '--columns=application,branch,active']).splitlines()) - flatpak_ids = {line.split()[0] for line in flatpaks} - if not set(applications['flatpaks']) <= flatpak_ids: - raise RuntimeError('Installed Flatpak application contract is incomplete') - executable = {} - for name in COMMANDS: - # No shell expansion; the user environment supplies both installation paths. - executable[name] = user_command(['python3', '-c', 'import os,shutil,sys; p=shutil.which(sys.argv[1]); print(bool(p and os.access(p,os.X_OK)))', name]) == 'True' - if not all(executable.values()): - raise RuntimeError('Application commands missing: ' + ', '.join(name for name, present in executable.items() if not present)) - config = yaml.safe_load(Path('/etc/cybexos/config.yml').read_text()) - settings = json.loads(user_command(['cybexos-runtime', 'ipc', 'settings', 'values'])) - units = {} - for name in SYSTEM_UNITS: - units[name] = command(['systemctl', 'is-enabled', name], accepted=(0, 1, 3, 4)) - user_units = {} - session_dependencies = set(user_command(['systemctl', '--user', 'show', 'hyprland-session.target', - '-p', 'Wants', '-p', 'Requires', '--value']).split()) - for name in USER_UNITS: - # Static units are pulled into the session target; compare activation too. - user_units[name] = effective_unit_state( - name, user_command(['systemctl', '--user', 'is-enabled', name], accepted=(0, 1, 3, 4)), - user_command(['systemctl', '--user', 'is-active', name], accepted=(0, 1, 3, 4)), session_dependencies) - main_pid = user_command(['systemctl', '--user', 'show', 'quickshell.service', '-p', 'MainPID', '--value']) - processes = user_command(['pgrep', '-x', 'qs'], accepted=(0, 1)).splitlines() - if processes != [main_pid] or main_pid in ('', '0'): - raise RuntimeError('Managed Quickshell must be the sole running shell') - sudo = subprocess.run(['runuser', '-u', user, '--', 'sudo', '-k', '-n', 'true'], capture_output=True, timeout=10) - if sudo.returncode not in (0, 1) or (sudo.returncode == 0) != config.get('passwordless_wheel'): - raise RuntimeError('Effective sudo authorization differs from the saved installation choice') - login = read_json('/etc/cybexos/login.json') - sddm = configparser.ConfigParser() - sddm.read('/etc/sddm.conf') - zone = ET.parse('/etc/firewalld/zones/cybexos.xml').getroot() - policy = sorted(ET.tostring(node, encoding='unicode').strip() for node in zone) - groups = sorted(group.gr_name for group in grp.getgrall() if user in group.gr_mem) - keyboard = {} - for name in ('kb_layout', 'kb_variant', 'kb_options', 'repeat_rate', 'sensitivity', 'touchpad:tap_to_click', 'touchpad:natural_scroll', 'touchpad:scroll_factor'): - result = json.loads(user_command(['hyprctl', '-j', 'getoption', 'input:' + name])) - keyboard[name] = {key: result[key] for key in ('int', 'float', 'str') if key in result} - personal = {} - for relative in ('.config/cybexos/input.json', '.config/cybexos/hypr/user.lua', 'qualification-personal-marker'): - path = home / relative - personal[relative] = hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None - default_apps = {mime: user_command(['xdg-mime', 'query', 'default', mime]) for mime in - ('text/html', 'inode/directory', 'application/pdf', 'x-scheme-handler/http', 'x-scheme-handler/https')} - if not all(default_apps.values()): - raise RuntimeError('A standard application association is missing') - recovery = next((path for path in ('/usr/libexec/cybexos-system-snapshot', '/usr/local/libexec/cybexos-system-snapshot') if Path(path).is_file()), None) - if not recovery: - raise RuntimeError('Recovery helper is missing') - recovery_state = json.loads(command([recovery, 'list', '--json'])) - hardware = {name: Path('/sys/class/dmi/id/' + name).read_text().strip() for name in ('sys_vendor', 'product_name')} - result = {'format': 1, 'installation': installation, 'scenario': scenario, 'profile': profile, - 'source_revision': build['source_revision'], 'source_content_sha256': build['source_content_sha256'], - 'hardware': hardware, 'required_packages': required, 'installed_packages': installed, - 'flatpaks': flatpaks, 'commands': executable, 'choices': policy_choices(config), - 'system_units': units, 'user_units': user_units, 'settings': settings, 'input': keyboard, - 'authentication': {'login': login, 'shell': account.pw_shell, 'groups': groups, - 'passwordless_sudo': sudo.returncode == 0, - 'passwordless_polkit': Path('/etc/polkit-1/rules.d/49-wheel-local.rules').exists(), - 'autologin_user': sddm.get('Autologin', 'User', fallback='')}, - 'firewall': {'default_zone': command(['firewall-cmd', '--get-default-zone']), 'policy': policy, - 'permanent_services': sorted(command(['firewall-cmd', '--permanent', '--zone=cybexos', '--list-services']).split()), - 'permanent_ports': sorted(command(['firewall-cmd', '--permanent', '--zone=cybexos', '--list-ports']).split())}, - 'default_apps': default_apps, 'personal': personal, - 'recovery': {'supported': recovery_state.get('supported'), - 'root_filesystem': command(['findmnt', '-n', '-o', 'FSTYPE', '/'])}, - 'selinux': command(['getenforce']), 'boot_target': command(['systemctl', 'get-default']), - 'graphical_session': True, 'sole_managed_shell': True} - return normalize(result, str(home)) - - -def compare(iso, checkout, exceptions): - """No implicit allowlist: every extra package difference needs a reason.""" - if iso.get('installation') != 'iso' or checkout.get('installation') != 'checkout': - raise ValueError('Comparison requires ISO and checkout captures') - for key in ('source_revision', 'source_content_sha256', 'scenario', 'profile', 'hardware'): - if not iso.get(key) or iso[key] != checkout.get(key): - raise ValueError('Installed outcomes cannot be compared: mismatched ' + key) - for result in (iso, checkout): - if CAPTURE_FIELDS - set(result): - raise ValueError('Installed outcomes are incomplete: ' + ', '.join(sorted(CAPTURE_FIELDS - set(result)))) - if result.get('format') != 1 or result.get('graphical_session') is not True or result.get('sole_managed_shell') is not True: - raise ValueError('Installed outcomes lack a verified graphical session') - for key in ('required_packages', 'settings', 'commands', 'choices', 'system_units', 'user_units', 'input', 'authentication', 'firewall', 'default_apps', 'recovery'): - if not isinstance(result[key], dict) or not result[key]: - raise ValueError('Installed outcomes have no effective ' + key) - ignored = {'installation', 'installed_packages'} - differences = {key: {'iso': iso.get(key), 'checkout': checkout.get(key)} for key in sorted(set(iso) | set(checkout)) - if key not in ignored and iso.get(key) != checkout.get(key)} - package_delta = {} - for side, other in (('iso', 'checkout'), ('checkout', 'iso')): - own = iso if side == 'iso' else checkout - peer = checkout if other == 'checkout' else iso - delta = sorted(set(own['installed_packages']) - set(peer['installed_packages'])) - approved = exceptions.get(side, {}) - if not isinstance(approved, dict) or any(not isinstance(reason, str) or len(reason.strip()) < 12 for reason in approved.values()): - raise ValueError('Every package exception must include a review reason') - if 'gpg-pubkey.(none)' in approved: - raise ValueError('Signing-key exceptions must identify the exact fingerprint and RPM release') - unreviewed = [package for package in delta - if package not in approved and package.split('=', 1)[0] not in approved] - package_delta[side] = {'all': delta, 'unreviewed': unreviewed} - reviewed = not any(item['unreviewed'] for item in package_delta.values()) - return {'format': 1, 'status': 'failed' if differences or not reviewed else 'passed', - 'source_revision': iso['source_revision'], 'source_content_sha256': iso['source_content_sha256'], - 'scenario': iso['scenario'], 'profile': iso['profile'], 'differences': differences, 'package_delta': package_delta, - 'checks': ['real-installed-outcomes', 'same-source-content', 'graphical-session'] + (['package-delta-reviewed'] if reviewed else [])} - - -def capture_guest(vm, root_script, password, *, installation, scenario, profile, manifest, provenance): - args = ['--capture', '--user', 'qualification', '--installation', installation, '--scenario', scenario, - '--profile', profile, '--manifest', manifest, '--provenance', provenance] - script = 'python3 - ' + shlex.join(args) + " <<'CYBEXOS_OUTCOMES_PY'\n" + Path(__file__).read_text() + '\nCYBEXOS_OUTCOMES_PY\n' - try: - return json.loads(root_script(vm, script, password, timeout=300).stdout) - except subprocess.CalledProcessError as error: - detail = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-4000:] - raise RuntimeError('Installed outcome capture failed: ' + detail) from error - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--capture', action='store_true') - parser.add_argument('--user', default='qualification') - parser.add_argument('--installation', choices=('iso', 'checkout')) - parser.add_argument('--scenario') - parser.add_argument('--profile', default='fresh') - parser.add_argument('--manifest') - parser.add_argument('--provenance') - parser.add_argument('--iso', type=Path) - parser.add_argument('--checkout', type=Path) - parser.add_argument('--exceptions', type=Path, default=Path(__file__).with_name('parity-exceptions.json')) - parser.add_argument('--output', type=Path) - args = parser.parse_args() - if args.capture: - if not all((args.installation, args.scenario, args.manifest, args.provenance)): - parser.error('Capture needs installation, scenario, manifest and provenance') - result = capture(args.user, args.installation, args.scenario, args.profile, args.manifest, args.provenance) - else: - if not all((args.iso, args.checkout, args.output)): - parser.error('Comparison needs --iso --checkout --output') - result = compare(read_json(args.iso), read_json(args.checkout), read_json(args.exceptions)) - args.output.write_text(json.dumps(result, indent=2) + '\n') - print(json.dumps(result, sort_keys=True)) - return 1 if result.get('status') == 'failed' else 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/installer-tests/browser-driver.test.cjs b/image/installer-tests/browser-driver.test.cjs deleted file mode 100644 index 8eafc6c1..00000000 --- a/image/installer-tests/browser-driver.test.cjs +++ /dev/null @@ -1,66 +0,0 @@ -const assert = require("node:assert/strict"); -const test = require("node:test"); -const vm = require("node:vm"); -const {selectKeyboard} = require("../real_browser_qualification.cjs"); - -const BUSY = "Another installer operation is still running."; - -function keyboardPage(results) { - let error; - let value; - const page = { - attempts: 0, - delays: [], - async selectOption(selector, keyboard) { - assert.equal(selector, "#keyboard"); - value = keyboard; - error = results[Math.min(page.attempts++, results.length - 1)]; - }, - async waitForFunction(predicate, expected) { - const elements = { - "#keyboard": {value}, "#password": {disabled: Boolean(error)}, - "#error": {hidden: !error, textContent: error || ""}, - }; - const ready = vm.runInNewContext(`(${predicate.toString()})(expected)`, { - expected, document: {querySelector: selector => elements[selector]}, - }); - assert.equal(ready, true, "helper must recognize success or the visible error"); - }, - async inputValue() { return value; }, - locator(selector) { - return { - async isDisabled() { assert.equal(selector, "#password"); return Boolean(error); }, - async textContent() { assert.equal(selector, "#error"); return error; }, - }; - }, - async waitForTimeout(delay) { page.delays.push(delay); }, - }; - return page; -} - -test("keyboard selection retries the transient installer lock and waits for applied layout", async () => { - const page = keyboardPage([BUSY, null]); - await selectKeyboard(page, "nl", 10000, [1]); - assert.equal(page.attempts, 2); - assert.deepEqual(page.delays, [1]); -}); - -test("persistent keyboard lock exhausts a bounded number of retries", async () => { - const page = keyboardPage([BUSY]); - await assert.rejects(selectKeyboard(page, "us", 10000, [1, 2]), /bounded retries/); - assert.equal(page.attempts, 3); - assert.deepEqual(page.delays, [1, 2]); -}); - -test("non-busy keyboard errors fail without retrying", async () => { - const page = keyboardPage(["The live keyboard could not be applied."]); - await assert.rejects(selectKeyboard(page, "us", 10000, [1]), /could not be applied/); - assert.equal(page.attempts, 1); - assert.deepEqual(page.delays, []); -}); - -test("an expired keyboard deadline does not start an operation", async () => { - const page = keyboardPage([null]); - await assert.rejects(selectKeyboard(page, "us", 0), /deadline/); - assert.equal(page.attempts, 0); -}); diff --git a/image/installer-tests/model.test.mjs b/image/installer-tests/model.test.mjs deleted file mode 100644 index f7b8e209..00000000 --- a/image/installer-tests/model.test.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import {Wizard, accountName, GiB, systemsLabel, clampSpace, shrinkFor, countdownText, reviewFields, confirmationCopy} - from "../live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs"; - -test("account validation handles mismatch, username and a long non-ASCII phrase", () => { - const wizard = new Wizard(); - assert.throws(() => wizard.account({username: "Alice", password: "long fixture password", confirm: "long fixture password"})); - assert.throws(() => wizard.account({username: "alice", password: "long fixture password", confirm: "other"})); - assert.equal(wizard.account({username: "alice", password: "lang wachtwoord café", confirm: "lang wachtwoord café"}).username, "alice"); - for (const username of ["root", "liveuser", "sddm", "chrony"]) { - assert.throws(() => wizard.account({username, password: "long fixture password", confirm: "long fixture password"}), /reserved/); - } - for (const password of ["long\nfixture password", "x".repeat(513)]) { - assert.throws(() => wizard.account({username: "alice", password, confirm: password})); - } -}); -test("typed capitals become the lowercase account name", () => { - assert.equal(accountName("John"), "john"); - assert.equal(new Wizard().account({username: accountName("JOHN_2"), password: "long fixture password", confirm: "long fixture password"}).username, "john_2"); -}); -test("review and installation require a plan and explicit erase confirmation", () => { - const wizard = new Wizard(); - assert.throws(() => wizard.move("review")); - wizard.plan = {token: "fixture", disk: {name: "vda"}}; - wizard.move("review"); - assert.throws(() => wizard.confirmation(false)); - assert.deepEqual(wizard.confirmation(true), {token: "fixture", confirmed_disk: "vda", erase_confirmed: true}); - wizard.move("location"); - assert.equal(wizard.plan, null); -}); -test("failed operations release busy state and permit correction/retry", async () => { - const wizard = new Wizard(); - await assert.rejects(wizard.operation(async () => {throw new Error("fixture failure");})); - assert.equal(wizard.busy, false); - assert.equal(await wizard.operation(async () => "retried"), "retried"); -}); -test("navigation is locked during an operation or installation", async () => { - const wizard = new Wizard(); - await wizard.operation(async () => assert.throws(() => wizard.move("location"))); - wizard.page = "progress"; - assert.throws(() => wizard.move("setup")); -}); -test("a new-owner installation needs no account, only the temporary disk passphrase", () => { - const wizard = new Wizard(); - assert.equal(wizard.account({oem: true, username: "", password: "handover phrase 1", confirm: "handover phrase 1"}).oem, true); - assert.throws(() => wizard.account({oem: true, password: "short", confirm: "short"}), /passphrase of 12/); - assert.throws(() => wizard.account({oem: true, password: "handover phrase 1", confirm: "other phrase 12"}), /passphrases do not match/); - // Without new-owner mode the account is still required. - assert.throws(() => wizard.account({username: "", password: "handover phrase 1", confirm: "handover phrase 1"}), /username/); -}); -test("installing alongside confirms the resize, not an erase", () => { - const wizard = new Wizard(); - wizard.plan = {token: "fixture", disk: {name: "vdb"}, layout: {mode: "alongside", resize: {path: "/dev/vdb3"}}}; - wizard.move("review"); - assert.deepEqual(wizard.confirmation(true), {token: "fixture", confirmed_disk: "vdb", alongside_confirmed: true}); - assert.match(confirmationCopy(wizard.plan).check, /\/dev\/vdb3 will be resized/); - assert.equal(confirmationCopy(wizard.plan).button, "Resize and install"); - assert.equal(confirmationCopy({layout: {mode: "alongside"}}).button, "Install alongside"); - assert.equal(confirmationCopy({layout: {mode: "erase"}}).button, "Erase disk and install"); - assert.equal(confirmationCopy(null).button, "Erase disk and install"); -}); -test("the size slider shrinks only the offered partition and keeps its floor", () => { - const options = {mode: "shrink", free: 10 * GiB, min_space: 64 * GiB, max_space: 305 * GiB, floor: 105 * GiB, - partition: {id: "vdb3", size: 400 * GiB}}; - assert.equal(clampSpace(options, 1), 64 * GiB); - assert.equal(clampSpace(options, 999 * GiB), 305 * GiB); - assert.equal(clampSpace(options, 100.4 * GiB), 100 * GiB); - assert.deepEqual(shrinkFor(options, 100 * GiB), {device: "vdb3", size: 310 * GiB}); - assert.deepEqual(shrinkFor(options, 305 * GiB), {device: "vdb3", size: 105 * GiB}); - assert.equal(shrinkFor({mode: "free", free: 200 * GiB}, 100 * GiB), null); - assert.equal(shrinkFor(null, 1), null); -}); -test("labels, countdown and review copy are plain and exact", () => { - assert.equal(systemsLabel(["Windows"]), "Windows"); - assert.equal(systemsLabel(["Windows", "Linux", "macOS"]), "Windows, Linux and macOS"); - assert.equal(systemsLabel([]), ""); - assert.equal(countdownText(30), "Installing in 0:30"); - assert.equal(countdownText(61.2), "Installing in 1:02"); - assert.equal(countdownText(-4), "Installing in 0:00"); - const identity = disk => disk.path; - const alongside = Object.fromEntries(reviewFields({ - disk: {path: "/dev/vdb"}, boot_keyboard: "us", - account: {username: "alice", encrypted: true, locale: "en_US.UTF-8", timezone: "UTC"}, - layout: {mode: "alongside", systems: ["Windows"], space: 90 * GiB, kept: ["/dev/vdb1"], - resize: {path: "/dev/vdb3", filesystem: "ntfs", from: 400 * GiB, to: 310 * GiB}}}, identity)); - assert.equal(alongside["Resized"], "/dev/vdb3 (ntfs) from 400.0 GiB to 310.0 GiB"); - assert.equal(alongside["New for CybexOS"], "90.0 GiB of freed space"); - assert.match(alongside["Unchanged"], /\/dev\/vdb1; the EFI system partition is shared/); - assert.match(alongside["Install location"], /Alongside Windows/); - const owner = Object.fromEntries(reviewFields({ - disk: {path: "/dev/vda"}, boot_keyboard: "us", - account: {oem: true, username: "", encrypted: true, locale: "en_US.UTF-8", timezone: "UTC"}}, identity)); - assert.equal(owner["Account"], "Created by the new owner at first start"); - assert.equal(owner["At startup"], "Temporary passphrase → new-owner setup"); - assert.equal(owner["Install location"], "Erase the entire disk"); - assert.match(owner["Sudo"], /new owner's password/); -}); -test("a countdown page cannot be left through normal navigation", () => { - const wizard = new Wizard(); - wizard.page = "unattended"; - assert.throws(() => wizard.move("setup")); -}); diff --git a/image/library/cybexos_docker_access.py b/image/library/cybexos_docker_access.py deleted file mode 100644 index 507bab4d..00000000 --- a/image/library/cybexos_docker_access.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/python3 -"""Grant/revoke only Docker membership, retaining independently managed access.""" -import grp -from pathlib import Path -import pwd -import subprocess - - -def reconcile(user, enabled, explicit=False, legacy_owner=False, - state=Path('/var/lib/cybexos'), check=False): - state = Path(state) - marker = state / 'accounts' / user / 'docker' - owned = marker.is_file() or (legacy_owner and (state / 'features/docker').is_file()) - account = pwd.getpwnam(user) - try: - group = grp.getgrnam('docker') - except KeyError: - group = None - primary = group is not None and account.pw_gid == group.gr_gid - member = primary or (group is not None and user in group.gr_mem) - # A machine-wide saved opt-in is not authorization for every account. - # Both grants and revocations need this account's intent or managed ownership. - if not (explicit or owned): - return {'changed': False, 'preserved': member} - if not enabled and primary: - raise ValueError('Docker is the primary group of this account. Choose a different ' - 'primary group before disabling Docker administrator access.') - changed = member != enabled or (not enabled and marker.exists()) - if check: - return {'changed': changed, 'preserved': False} - if enabled and not member: - # Persist intent before granting, so interruption cannot leave an unowned - # root-equivalent grant. Existing administrator grants are never adopted. - marker.parent.mkdir(parents=True, exist_ok=True, mode=0o755) - marker.write_text('managed\n') - marker.chmod(0o644) - if group is None: - subprocess.run(['groupadd', '--', 'docker'], check=True, capture_output=True) - subprocess.run(['usermod', '--append', '--groups', 'docker', '--', user], - check=True, capture_output=True) - elif not enabled: - if member: - subprocess.run(['gpasswd', '--delete', user, 'docker'], check=True, capture_output=True) - # Keep ownership until revocation succeeds, allowing failed runs to retry. - marker.unlink(missing_ok=True) - return {'changed': changed, 'preserved': False} - - -def main(): - from ansible.module_utils.basic import AnsibleModule - module = AnsibleModule(argument_spec={ - 'user': {'type': 'str', 'required': True}, - 'enabled': {'type': 'bool', 'required': True}, - 'explicit': {'type': 'bool', 'default': False}, - 'legacy_owner': {'type': 'bool', 'default': False}, - }, supports_check_mode=True) - try: - result = reconcile(**module.params, check=module.check_mode) - except (OSError, KeyError, ValueError, subprocess.CalledProcessError) as error: - module.fail_json(msg=str(error)) - module.exit_json(**result) - - -if __name__ == '__main__': - main() diff --git a/image/library/cybexos_managed_file.py b/image/library/cybexos_managed_file.py deleted file mode 100644 index c00e0a0d..00000000 --- a/image/library/cybexos_managed_file.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/python3 -"""Maintain vendor defaults only while their bytes still match our last write.""" -import fcntl -import hashlib -import json -import os -from pathlib import Path -import tempfile - - -def atomic(path, data, mode=0o600): - fd, temporary = tempfile.mkstemp(prefix='.cybexos-', dir=path.parent) - try: - with os.fdopen(fd, 'wb') as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - os.fchmod(stream.fileno(), mode) - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - - -def seed(destination, content, mode=0o600, check=False): - """Create a complete absent file, like ISO user-init's copy_absent. - - Existing bytes (including malformed/future documents), symlinks and edits - published while preparing the default are user-owned. Never parse or merge. - """ - destination = Path(destination) - if destination.exists() or destination.is_symlink() or any( - parent.is_symlink() for parent in destination.parents): - return {'changed': False, 'preserved': True} - if check: - return {'changed': True, 'preserved': False} - destination.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix='.cybexos-seed-', dir=destination.parent) - try: - with os.fdopen(fd, 'wb') as stream: - stream.write(content) - stream.flush() - os.fsync(stream.fileno()) - os.fchmod(stream.fileno(), mode) - try: - os.link(temporary, destination) - except FileExistsError: - return {'changed': False, 'preserved': True} - finally: - Path(temporary).unlink(missing_ok=True) - return {'changed': True, 'preserved': False} - - -def manage(destination, content, ledger, absent=False, mode=0o644, check=False, baseline=None, - seed_only=False): - if seed_only: - if absent: - raise ValueError('seed_only cannot remove user files') - return seed(destination, content, mode, check) - if check: - return _manage(destination, content, ledger, absent, mode, check, baseline) - ledger = Path(ledger) - ledger.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - # Login seeding and an explicit converge can target the same account. - with ledger.with_name(ledger.name + '.lock').open('a') as lock: - os.fchmod(lock.fileno(), 0o600) - fcntl.flock(lock, fcntl.LOCK_EX) - return _manage(destination, content, ledger, absent, mode, check, baseline) - - -def _manage(destination, content, ledger, absent=False, mode=0o644, check=False, baseline=None): - """Unknown/custom files and symlinks are never adopted or overwritten. - - Persist ownership before publishing new bytes, recording both old and new - digests, so interrupted publication is recoverable on the next invocation. - """ - destination, ledger = Path(destination), Path(ledger) - previous = json.loads(ledger.read_text()) if ledger.exists() else {} - key = str(destination) - old = previous.get(key, []) - if isinstance(old, str): - old = [old] - if destination.is_symlink() or any(p.is_symlink() for p in destination.parents): - return {'changed': False, 'preserved': True} - if destination.exists() and not destination.is_file(): - return {'changed': False, 'preserved': True} - current = destination.read_bytes() if destination.exists() else None - digest = hashlib.sha256(current).hexdigest() if current is not None else None - desired = None if absent else hashlib.sha256(content).hexdigest() - if baseline is not None and current == baseline: - old = [digest] - if digest is not None and digest != desired and digest not in old: - return {'changed': False, 'preserved': True} - # A user deletion is an override once we have adopted an existing file. - if current is None and old: - return {'changed': False, 'preserved': True} - changed = current != (None if absent else content) - if check: - return {'changed': changed, 'preserved': False} - ledger.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - if changed and current is not None: - backup = ledger.parent / 'backups' / hashlib.sha256(key.encode()).hexdigest() / digest - backup.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - if not backup.exists(): - atomic(backup, current, destination.stat().st_mode & 0o777) - previous[key] = list(dict.fromkeys(x for x in (digest, desired) if x)) - atomic(ledger, (json.dumps(previous, sort_keys=True) + '\n').encode()) - if changed: - if absent: - destination.unlink(missing_ok=True) - else: - destination.parent.mkdir(parents=True, exist_ok=True) - atomic(destination, content, mode) - previous[key] = [desired] if desired else [] - atomic(ledger, (json.dumps(previous, sort_keys=True) + '\n').encode()) - return {'changed': changed, 'preserved': False} - - -def main(): - from ansible.module_utils.basic import AnsibleModule - module = AnsibleModule(argument_spec={ - 'dest': {'type': 'path', 'required': True}, - 'content': {'type': 'str', 'default': ''}, - 'ledger': {'type': 'path', 'default': '/var/lib/cybexos/reconcile/managed-files.json'}, - 'baseline': {'type': 'str', 'default': None}, - 'state': {'choices': ['present', 'absent'], 'default': 'present'}, - 'mode': {'type': 'str', 'default': '0644'}, - 'seed_only': {'type': 'bool', 'default': False}, - }, supports_check_mode=True) - try: - result = manage(module.params['dest'], module.params['content'].encode(), - module.params['ledger'], - absent=module.params['state'] == 'absent', - mode=int(module.params['mode'], 8), check=module.check_mode, - baseline=(module.params['baseline'].encode() - if module.params['baseline'] is not None else None), - seed_only=module.params['seed_only']) - except (OSError, ValueError) as error: - module.fail_json(msg=str(error)) - module.exit_json(**result) - - -if __name__ == '__main__': - main() diff --git a/image/library/cybexos_user_include.py b/image/library/cybexos_user_include.py deleted file mode 100644 index 5e81a664..00000000 --- a/image/library/cybexos_user_include.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/python3 -"""Place vendor defaults below explicit user choices in each application's order.""" -import hashlib -import os -from pathlib import Path -import subprocess -import tempfile - -BEGIN = '# BEGIN CYBEXOS MANAGED INCLUDE' -END = '# END CYBEXOS MANAGED INCLUDE' -BLOCKS = { - 'kitty': 'include cybexos.conf', - 'git': '[include]\n path = ~/.config/cybexos/gitconfig', - # Reset the final user Host/Match scope before the fallback Include. - 'ssh': 'Host *\n Include ~/.config/cybexos/ssh.conf', -} - - -def render(text, kind, absent=False): - """Only move our exact include; a user-edited managed block is theirs.""" - lines = text.splitlines(keepends=True) - begins = [i for i, line in enumerate(lines) if line.rstrip('\r\n') == BEGIN] - ends = [i for i, line in enumerate(lines) if line.rstrip('\r\n') == END] - if begins or ends: - if len(begins) != 1 or len(ends) != 1 or ends[0] <= begins[0]: - return text, True - start, end = begins[0], ends[0] - body = '\n'.join(line.strip() for line in lines[start + 1:end]) - accepted = ['\n'.join(line.strip() for line in BLOCKS[kind].splitlines())] - if kind == 'ssh': - accepted.append('Include ~/.ssh/config.d/cybexos.conf') - if body not in accepted: - return text, True - lines = lines[:start] + lines[end + 1:] - remaining = ''.join(lines) - if absent: - return remaining, False - block = BEGIN + '\n' + BLOCKS[kind] + '\n' + END + '\n' - if kind == 'ssh': - # OpenSSH keeps the first obtained value. Kitty/Git scalar values use - # the last one, so they put vendor defaults at the beginning instead. - return remaining + ('\n' if remaining and not remaining.endswith('\n') else '') + block, False - return block + remaining, False - - -def update(path, kind, absent=False, check=False): - path = Path(path) - if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): - return {'changed': False, 'preserved': True} - if path.exists() and not path.is_file(): - return {'changed': False, 'preserved': True} - original = path.read_bytes() if path.exists() else b'' - try: - desired, preserved = render(original.decode(), kind, absent) - except UnicodeDecodeError: - return {'changed': False, 'preserved': True} - desired = desired.encode() - if preserved or desired == original: - return {'changed': False, 'preserved': preserved} - if check: - return {'changed': True, 'preserved': False} - path.parent.mkdir(parents=True, exist_ok=True) - if original: - backup = path.with_name(path.name + '.cybexos-before-' + hashlib.sha256(original).hexdigest()[:16]) - try: - with backup.open('xb') as stream: - os.fchmod(stream.fileno(), 0o600) - stream.write(original) - except FileExistsError: - pass - fd, temporary = tempfile.mkstemp(prefix='.cybexos-include-', dir=path.parent) - try: - with os.fdopen(fd, 'wb') as stream: - os.fchmod(stream.fileno(), path.stat().st_mode & 0o777 if path.exists() - else (0o600 if kind == 'ssh' else 0o644)) - stream.write(desired) - stream.flush() - os.fsync(stream.fileno()) - # Do not replace edits made while preparing the include. - if (path.read_bytes() if path.exists() else b'') != original: - return {'changed': False, 'preserved': True} - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - return {'changed': True, 'preserved': False} - - -def personal_git_credentials(path): - """Git credential helpers accumulate, unlike normal last-value settings. - - Respect personal helper chains (including nested includes) by omitting - vendor authentication defaults whenever the account has its own helper. - Only a boolean crosses the Ansible boundary, never credential commands. - """ - path = Path(path) - if not path.exists(): - return False - result = subprocess.run(['git', 'config', '--file', str(path), '--includes', - '--show-origin', '--get-regexp', r'^credential(\..*)?\.helper$'], - capture_output=True, text=True, timeout=10, check=False) - if result.returncode not in (0, 1): - raise ValueError('Could not read personal Git credential configuration') - vendor = path.parent / '.config/cybexos/gitconfig' - return any(line.split('\t', 1)[0] != 'file:' + str(vendor) - for line in result.stdout.splitlines()) - - -def main(): - from ansible.module_utils.basic import AnsibleModule - module = AnsibleModule(argument_spec={ - 'path': {'type': 'path', 'required': True}, - 'kind': {'choices': list(BLOCKS), 'required': True}, - 'state': {'choices': ['present', 'absent'], 'default': 'present'}, - 'inspect_git_credentials': {'type': 'bool', 'default': False}, - }, supports_check_mode=True) - try: - if module.params['inspect_git_credentials']: - result = {'changed': False, - 'personal_credentials': personal_git_credentials(module.params['path'])} - else: - result = update(module.params['path'], module.params['kind'], - absent=module.params['state'] == 'absent', check=module.check_mode) - except (OSError, ValueError, subprocess.TimeoutExpired) as error: - module.fail_json(msg=str(error)) - module.exit_json(**result) - - -if __name__ == '__main__': - main() diff --git a/image/library/cybexos_versionlock.py b/image/library/cybexos_versionlock.py deleted file mode 100644 index cad4994a..00000000 --- a/image/library/cybexos_versionlock.py +++ /dev/null @@ -1,347 +0,0 @@ -#!/usr/bin/python3 -"""Hold packages to their qualified series in dnf's versionlock.toml. - -The series come from `qualified_package_series` in inventory/group_vars/all.yml -(docs/dependency-policy.md). Each becomes one versionlock entry that admits -`minimum <= evr < below`, so patch releases inside the series still install -and update while the next minor or major release is held back until it has -been reviewed. The checkout installer, the image installer and upgrade -reconciliation all converge the file with this module. - -CybexOS owns only the entries whose comment starts with MARKER. Every other -entry is the administrator's and is kept as written. dnf combines the entries -for one package name with OR, so an administrator who has qualified another -version can admit it with an entry of their own, without editing ours. A file -this module cannot read safely is left untouched and reported. - -The image build imports this file too: image/compose fails when the resolved -packages fall outside their series (outside_series below). -""" -import os -from pathlib import Path -import re -import subprocess -import tempfile -import tomllib - -MARKER = "CybexOS qualified series" -COMMENT = MARKER + " (docs/dependency-policy.md); managed by CybexOS, edits are replaced" -DEFAULT_PATH = "/etc/dnf/versionlock.toml" -NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$") -VERSION = re.compile(r"^(?:[0-9]+:)?[A-Za-z0-9._+~^]{1,64}(?:-[A-Za-z0-9._+~^]{1,64})?$") -COMPARATORS = ("<", "<=", "=", ">=", ">", "!=") -MAX_BYTES = 1048576 - - -class Unmanageable(ValueError): - """The existing file cannot be rewritten without losing what it says.""" - - -# ------------------------------------------------------------------ RPM -- - -def rpmvercmp(first, second): - """RPM's version comparison (rpmvercmp.c), including ~ and ^.""" - if first == second: - return 0 - one, two = first, second - while one or two: - one = re.sub(r"^[^A-Za-z0-9~^]+", "", one) - two = re.sub(r"^[^A-Za-z0-9~^]+", "", two) - # A tilde sorts before everything, even the end of the string. - if one.startswith("~") or two.startswith("~"): - if not one.startswith("~"): - return 1 - if not two.startswith("~"): - return -1 - one, two = one[1:], two[1:] - continue - # A caret sorts after the end of the string but before anything else. - if one.startswith("^") or two.startswith("^"): - if not one: - return -1 - if not two: - return 1 - if not one.startswith("^"): - return 1 - if not two.startswith("^"): - return -1 - one, two = one[1:], two[1:] - continue - if not (one and two): - break - numeric = one[0].isdigit() - pattern = r"^[0-9]+" if numeric else r"^[A-Za-z]+" - left = re.match(pattern, one) - right = re.match(pattern, two) - if right is None: - # Numeric segments are newer than alphabetic ones. - return 1 if numeric else -1 - left, right = left.group(0), right.group(0) - one, two = one[len(left):], two[len(right):] - if numeric: - left, right = left.lstrip("0"), right.lstrip("0") - if len(left) != len(right): - return 1 if len(left) > len(right) else -1 - if left != right: - return 1 if left > right else -1 - if not one and not two: - return 0 - return -1 if not one else 1 - - -def split_evr(text): - """(epoch, version, release) with release "" when the text names none.""" - epoch, _, rest = text.rpartition(":") if ":" in text else ("0", "", text) - version, _, release = rest.partition("-") - return int(epoch or 0), version, release - - -def compare_evr(first, second): - """libsolv's EVR order, as dnf's versionlock conditions apply it. - - A value without a release compares as an empty release, which is older - than any real one: 0.56.2-2 is newer than 0.56.2. Release-less bounds - therefore read naturally: >= 0.56.2 admits 0.56.2-1, and < 0.57~ excludes - every 0.57 build, pre-releases included. - """ - first, second = split_evr(first), split_evr(second) - if first[0] != second[0]: - return 1 if first[0] > second[0] else -1 - return rpmvercmp(first[1], second[1]) or rpmvercmp(first[2], second[2]) - - -def satisfies(evr, comparator, value): - order = compare_evr(evr, value) - return {"<": order < 0, "<=": order <= 0, "=": order == 0, - ">=": order >= 0, ">": order > 0, "!=": order != 0}[comparator] - - -# --------------------------------------------------------------- series -- - -def validate_series(series): - if not isinstance(series, list): - raise ValueError("qualified_package_series must be a list") - names = set() - for entry in series: - if not isinstance(entry, dict) or set(entry) != {"name", "minimum", "below"}: - raise ValueError("each qualified series needs exactly name, minimum and below") - name, minimum, below = entry["name"], entry["minimum"], entry["below"] - if not isinstance(name, str) or not NAME.fullmatch(name) or name in names: - raise ValueError(f"invalid or repeated series package name: {name!r}") - for value in (minimum, below): - if not isinstance(value, str) or not VERSION.fullmatch(value): - raise ValueError(f"invalid version bound for {name}: {value!r}") - if compare_evr(minimum, below) >= 0: - raise ValueError(f"the {name} series is empty: {minimum} is not below {below}") - names.add(name) - return series - - -def desired_entries(series): - return [{"name": entry["name"], "comment": COMMENT, "conditions": [ - {"key": "evr", "comparator": ">=", "value": entry["minimum"]}, - {"key": "evr", "comparator": "<", "value": entry["below"]}, - ]} for entry in validate_series(series)] - - -def in_series(evr, entry): - return satisfies(evr, ">=", entry["minimum"]) and satisfies(evr, "<", entry["below"]) - - -def outside_series(series, installed, require=False): - """Human-readable problems: `installed(name)` returns an EVR or None.""" - problems = [] - for entry in validate_series(series): - evr = installed(entry["name"]) - if evr is None: - if require: - problems.append(f"{entry['name']} is not installed") - elif not in_series(evr, entry): - problems.append(f"{entry['name']} {evr} is outside its qualified series " - f">= {entry['minimum']}, < {entry['below']}") - return problems - - -def describe(series): - return ", ".join(f"{entry['name']} >= {entry['minimum']}, < {entry['below']}" - for entry in validate_series(series)) - - -# ----------------------------------------------------------------- TOML -- - -def _string(value): - escaped = [] - for character in value: - if character in ('"', "\\"): - escaped.append("\\" + character) - elif ord(character) < 0x20 or ord(character) == 0x7F: - escaped.append("\\u%04X" % ord(character)) - else: - escaped.append(character) - return '"' + "".join(escaped) + '"' - - -def _key(key): - return key if re.fullmatch(r"[A-Za-z0-9_-]+", key) else _string(key) - - -def _scalar(value): - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if isinstance(value, str): - return _string(value) - raise Unmanageable("the versionlock file holds a value this tool cannot preserve") - - -def _scalars(table): - return [f"{_key(key)} = {_scalar(value)}" for key, value in table.items()] - - -def render(document): - """TOML for the versionlock schema: scalars, packages and their conditions.""" - lines = [] - top = {key: value for key, value in document.items() if key != "packages"} - lines += _scalars(top) - for package in document.get("packages", []): - lines += ["", "[[packages]]"] - lines += _scalars({key: value for key, value in package.items() if key != "conditions"}) - for condition in package.get("conditions", []): - lines.append("[[packages.conditions]]") - lines += _scalars(condition) - return "\n".join(lines) + "\n" - - -def _managed(package): - return isinstance(package.get("comment"), str) and package["comment"].startswith(MARKER) - - -def _checked(document): - """The parsed document, refused when rewriting it could lose data.""" - if document.get("version") != "1.0": - raise Unmanageable("the versionlock file is not version 1.0") - packages = document.get("packages", []) - if not isinstance(packages, list): - raise Unmanageable("the versionlock packages are not a list") - for key, value in document.items(): - if key != "packages": - _scalar(value) - for package in packages: - if not isinstance(package, dict): - raise Unmanageable("a versionlock entry is not a table") - conditions = package.get("conditions", []) - if not isinstance(conditions, list) or not all(isinstance(item, dict) for item in conditions): - raise Unmanageable("a versionlock entry has unreadable conditions") - for key, value in package.items(): - if key != "conditions": - _scalar(value) - for condition in conditions: - for value in condition.values(): - _scalar(value) - return document - - -def converge(text, series): - """The file's new text, or None when it already says what it should.""" - wanted = desired_entries(series) - if text is None or not text.strip(): - document = {"version": "1.0", "packages": []} - else: - try: - document = _checked(tomllib.loads(text)) - except tomllib.TOMLDecodeError as error: - raise Unmanageable(f"the versionlock file is not valid TOML: {error}") from None - packages = document.get("packages", []) - kept = [package for package in packages if not _managed(package)] - # Ours stay last, in series order, so an unchanged file is left alone. - if packages == kept + wanted and text is not None: - return None - if not kept and not wanted and text is None: - return None - return render({**document, "packages": kept + wanted}) - - -def read(path): - path = Path(path) - if path.is_symlink(): - raise Unmanageable(f"{path} is a symbolic link") - try: - with path.open("rb") as stream: - raw = stream.read(MAX_BYTES + 1) - except FileNotFoundError: - return None - if len(raw) > MAX_BYTES: - raise Unmanageable(f"{path} is unexpectedly large") - try: - return raw.decode("utf-8") - except UnicodeDecodeError: - raise Unmanageable(f"{path} is not UTF-8 text") from None - - -def write(path, text): - path = Path(path) - path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix=".versionlock-", dir=path.parent) - try: - with os.fdopen(fd, "w", encoding="utf-8") as stream: - stream.write(text) - stream.flush() - os.fsync(stream.fileno()) - os.fchmod(stream.fileno(), 0o644) - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - - -def installed_evr(name, root=None): - command = ["rpm", "-q", "--qf", "%{EPOCHNUM}:%{VERSION}-%{RELEASE}\\n", name] - if root: - command[1:1] = ["--root", str(root)] - try: - result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=60) - except (OSError, subprocess.SubprocessError): - return None - lines = result.stdout.split() - return lines[-1] if result.returncode == 0 and lines else None - - -def manage(path, series, check=False): - """Converge the file; returns (changed, preserved_reason).""" - try: - text = read(path) - updated = converge(text, series) - except Unmanageable as error: - return False, str(error) - if updated is not None and not check: - write(path, updated) - return updated is not None, None - - -def main(): - from ansible.module_utils.basic import AnsibleModule - module = AnsibleModule(argument_spec={ - "series": {"type": "list", "elements": "dict", "required": True}, - "path": {"type": "path", "default": DEFAULT_PATH}, - "report_installed": {"type": "bool", "default": True}, - }, supports_check_mode=True) - series = module.params["series"] - try: - validate_series(series) - changed, preserved = manage(module.params["path"], series, module.check_mode) - except (OSError, ValueError) as error: - module.fail_json(msg=str(error)) - if preserved: - module.warn(f"{module.params['path']} was left as it is, so the qualified series " - f"({describe(series)}) is not enforced: {preserved}") - outside = [] - if module.params["report_installed"]: - outside = outside_series(series, installed_evr) - for problem in outside: - module.warn(problem + "; dnf keeps it but will not move within another series " - "until a CybexOS release qualifies it") - module.exit_json(changed=changed, preserved=bool(preserved), outside=outside) - - -if __name__ == "__main__": - main() diff --git a/image/live-rootfs/etc/anaconda/conf.d/20-cybexos.conf b/image/live-rootfs/etc/anaconda/conf.d/20-cybexos.conf deleted file mode 100644 index 7e1821b6..00000000 --- a/image/live-rootfs/etc/anaconda/conf.d/20-cybexos.conf +++ /dev/null @@ -1,12 +0,0 @@ -[User Interface] -webui_web_engine = /usr/bin/cybexos-installer-browser -# Keep account creation and date/time in the installer. There is no separate -# GNOME initial-setup dependency in this Hyprland image. -hidden_webui_pages = -hidden_spokes = - -[Storage] -default_scheme = BTRFS - -[Network] -default_on_boot = FIRST_WIRED_WITH_LINK diff --git a/image/live-rootfs/usr/bin/cybexos-installer-browser b/image/live-rootfs/usr/bin/cybexos-installer-browser deleted file mode 100755 index b7858810..00000000 --- a/image/live-rootfs/usr/bin/cybexos-installer-browser +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/python3 -"""Anaconda web-engine hook: same Cockpit server, CybexOS's first page.""" -import os -from pathlib import Path -import subprocess -import signal -import sys -import tempfile -from urllib.parse import urlsplit, urlunsplit - -def installer_url(value): - # Keep the origin/port supplied by webui-desktop; never accept a remote URL. - url = urlsplit(value) - if url.scheme != 'http' or url.hostname not in ('127.0.0.1', 'localhost') or url.username or url.password: - raise ValueError('The installer browser requires the local Anaconda URL.') - if url.path != '/cockpit/@localhost/anaconda-webui/index.html': - raise ValueError('Unexpected Anaconda page.') - return urlunsplit((url.scheme, url.netloc, '/cockpit/@localhost/cybexos-installer/index.html', '', '')) - - -def main(): - url = installer_url(sys.argv[-1]) - with tempfile.TemporaryDirectory(prefix='cybexos-installer-', dir=os.environ.get('XDG_RUNTIME_DIR')) as profile: - Path(profile, 'user.js').write_text('user_pref("signon.rememberSignons", false);\n' - 'user_pref("browser.shell.checkDefaultBrowser", false);\n' - 'user_pref("browser.sessionstore.resume_from_crash", false);\n' - 'user_pref("browser.formfill.enable", false);\n') - child = subprocess.Popen(['/usr/bin/firefox', '--no-remote', '--profile', profile, '--kiosk', url]) - def stop(_signal, _frame): - raise KeyboardInterrupt - signal.signal(signal.SIGTERM, stop) - signal.signal(signal.SIGINT, stop) - try: - return child.wait() - except KeyboardInterrupt: - return 0 - finally: - if child.poll() is None: - child.terminate() - try: - child.wait(timeout=10) - except subprocess.TimeoutExpired: - child.kill() - child.wait() - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/live-rootfs/usr/lib/systemd/system/cybexos-live.service b/image/live-rootfs/usr/lib/systemd/system/cybexos-live.service deleted file mode 100644 index 3d4b54f6..00000000 --- a/image/live-rootfs/usr/lib/systemd/system/cybexos-live.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Prepare the temporary CybexOS live session -ConditionKernelCommandLine=rd.live.image -After=systemd-user-sessions.service -Before=display-manager.service - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-live-setup -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target diff --git a/image/live-rootfs/usr/lib/systemd/user/cybexos-install-session.target b/image/live-rootfs/usr/lib/systemd/user/cybexos-install-session.target deleted file mode 100644 index 97b8b2d2..00000000 --- a/image/live-rootfs/usr/lib/systemd/user/cybexos-install-session.target +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=CybexOS installation session -# Started instead of hyprland-session.target when the live image boots with -# cybexos.install: no shell, dictation, idle daemon or offline app seeding. -# The installer window can still start the full live desktop. -DefaultDependencies=no -BindsTo=graphical-session.target -Wants=graphical-session-pre.target hyprpolkitagent.service cybexos-install-welcome.service -After=graphical-session-pre.target -Before=graphical-session.target diff --git a/image/live-rootfs/usr/lib/systemd/user/cybexos-install-welcome.service b/image/live-rootfs/usr/lib/systemd/user/cybexos-install-welcome.service deleted file mode 100644 index 3c25ef62..00000000 --- a/image/live-rootfs/usr/lib/systemd/user/cybexos-install-welcome.service +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -Description=CybexOS installer window -PartOf=cybexos-install-session.target - -[Service] -ExecStart=/usr/bin/cybexos-welcome --install-mode -Restart=no diff --git a/image/live-rootfs/usr/libexec/cybexos-installer-backend b/image/live-rootfs/usr/libexec/cybexos-installer-backend deleted file mode 100755 index 2bda840b..00000000 --- a/image/live-rootfs/usr/libexec/cybexos-installer-backend +++ /dev/null @@ -1,1099 +0,0 @@ -#!/usr/bin/python3 -"""CybexOS's small Anaconda 44 DBus client. Never partitions disks itself. - -Request JSON arrives on stdin; stdout is secret-free NDJSON. Anaconda owns -device discovery, in-memory partition planning, validation, installation and -progress. Only `install` starts its installation task, after plan confirmation. -An unattended seed volume is only ever mounted read-only, read and unmounted. -See image/INSTALLER.md for the versioned upstream interface and test limits. -""" -import fcntl -import hashlib -import importlib.machinery -import importlib.util -import io -import json -import os -from pathlib import Path -import pwd -import re -import secrets -import shutil -import sys -import subprocess -import tarfile -import time - -RUNTIME = Path('/run/cybexos-installer') -RESERVED = {'root', 'liveuser', 'bin', 'daemon', 'adm', 'mail', 'ftp', 'nobody', - 'dbus', 'systemd', 'gdm', 'sddm', 'sshd', 'polkitd', 'chrony'} -GiB = 1024**3 -# CybexOS's own minimum, before Anaconda's payload size is considered. -MINIMUM_BYTES = 64 * GiB -# Installing alongside never shrinks another system below its own minimum -# plus this much working space. -OTHER_SYSTEM_MARGIN = 5 * GiB -STORAGE_SERVICE = 'org.fedoraproject.Anaconda.Modules.Storage' -# lsblk file system types that indicate another operating system. -SYSTEM_FILESYSTEMS = {'ntfs': 'Windows', 'BitLocker': 'Windows', 'ext4': 'Linux', 'ext3': 'Linux', - 'xfs': 'Linux', 'btrfs': 'Linux', 'crypto_LUKS': 'Linux', 'LVM2_member': 'Linux', - 'apfs': 'macOS', 'hfsplus': 'macOS'} - - -def load_sibling(name, filename): - loader = importlib.machinery.SourceFileLoader(name, str(Path(__file__).resolve().with_name(filename))) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -seed_format = load_sibling('cybexos_installer_seed', 'cybexos-installer-seed') -# Logs the installer page may show and export. Anaconda writes the /tmp logs; -# a chroot %post --log lands in the target, a --nochroot one in the live system. -# Never add /tmp/sensitive-info.log or a path chosen by the request. -LOG_SOURCES = { - 'steps': [RUNTIME / 'worker.log'], - 'anaconda': [Path('/tmp/anaconda.log')], - 'programs': [Path('/tmp/program.log')], - 'storage': [Path('/tmp/storage.log')], - 'software': [Path('/tmp/packaging.log')], - 'post': [Path('/mnt/sysroot/var/log/anaconda/cybexos-cleanup.log'), - Path('/var/log/anaconda/cybexos-target-policy.log')], -} -LOG_LIMIT = 96 * 1024 -EXPORT_FILESYSTEMS = {'vfat', 'exfat', 'ext2', 'ext3', 'ext4', 'btrfs', 'xfs'} -LIVE_LABEL = 'CYBEXOS-LIVE-44' - - -class Invalid(ValueError): - """A message safe to display; never include secret input in one.""" - - -def plain(value): - if hasattr(value, 'unpack'): - return plain(value.unpack()) - if isinstance(value, dict): - return {key: plain(item) for key, item in value.items()} - if isinstance(value, (list, tuple)): - return [plain(item) for item in value] - return value - - -def validate_password(value, confirm, what='password'): - if not isinstance(value, str) or len(value) < 12 or len(value) > 512: - raise Invalid(f'Use a {what} of 12–512 characters.') - if any(ord(char) < 32 or ord(char) == 127 for char in value): - raise Invalid(f'The {what} cannot contain control characters.') - if value != confirm: - raise Invalid(f'The {what}s do not match.') - - -def validate_system(data, choices): - """Keyboard, language, timezone and computer name from Anaconda's lists.""" - keyboard = data.get('keyboard', '') - if keyboard not in {item['id'] for item in choices['keyboards']}: - raise Invalid('Choose an available keyboard layout.') - locale = data.get('locale', '') - if locale not in choices['locales']: - raise Invalid('Choose an available system language.') - timezone = data.get('timezone', '') - if timezone not in choices['timezones']: - raise Invalid('Choose a timezone from the list.') - hostname = data.get('hostname') or 'cybexos' - if not isinstance(hostname, str) or not re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?', hostname): - raise Invalid('The computer name must contain 1–63 letters, numbers or dashes.') - return dict(keyboard=keyboard, locale=locale, timezone=timezone, hostname=hostname) - - -def flag(data, key, default, message): - value = data.get(key, default) - if not isinstance(value, bool): - raise Invalid(message) - return value - - -def validate_layout(data): - """`erase` the whole disk, or install `alongside` its existing systems.""" - layout = data.get('layout', 'erase') - if layout not in ('erase', 'alongside'): - raise Invalid('Choose whether to erase the disk or install alongside.') - shrink = data.get('shrink') - if layout == 'erase' or shrink is None: - return layout, None - if (not isinstance(shrink, dict) or set(shrink) != {'device', 'size'} - or not isinstance(shrink['device'], str) or not re.fullmatch(r'[A-Za-z0-9_.:/+-]{1,200}', shrink['device']) - or type(shrink['size']) is not int or shrink['size'] <= 0): - raise Invalid('The partition size choice is invalid. Review the disk again.') - return layout, dict(shrink) - - -def validate_account(data, choices): - """Interactive choices; the password itself is checked but not returned.""" - oem = flag(data, 'oem', False, 'The new-owner choice is invalid.') - encrypted = flag(data, 'encrypted', True, 'The encryption choice is invalid.') - passwordless_wheel = flag(data, 'passwordless_wheel', False, 'The sudo password choice is invalid.') - username = '' - if oem: - # The temporary disk passphrase the new owner replaces at first start. - if encrypted: - validate_password(data.get('password', ''), data.get('confirm'), 'passphrase') - else: - username = data.get('username', '') - if not isinstance(username, str) or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', username) or username in RESERVED: - raise Invalid('Choose a username of 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter.') - validate_password(data.get('password', ''), data.get('confirm')) - layout, shrink = validate_layout(data) - return dict(username=username, full_name='', **validate_system(data, choices), - encrypted=encrypted, passwordless_wheel=passwordless_wheel, oem=oem, - layout=layout, shrink=shrink, unattended=False, ssh_authorized_keys=[], - ssh_server=False, tailscale=False, config={}) - - -def validate_seed(seed, choices): - """A validated seed as the same account record an interactive plan makes.""" - settings = seed.settings - if settings['username'] in RESERVED: - raise Invalid(f'The seed username "{settings["username"]}" is reserved by the system.') - try: - system = validate_system(settings, choices) - except Invalid as error: - raise Invalid('The seed system settings are not available: ' + str(error)) from None - secrets_ = {} - if settings['encrypted']: - secrets_['passphrase'] = seed.secrets['passphrase'] - if not settings['oem']: - secrets_['password_hash'] = seed.secrets['password_hash'] - account = dict(username=settings['username'], full_name=settings['full_name'], **system, - encrypted=settings['encrypted'], passwordless_wheel=settings['passwordless_wheel'], - oem=settings['oem'], layout='erase', shrink=None, unattended=True, - ssh_authorized_keys=settings['ssh_authorized_keys'], ssh_server=settings['ssh_server'], - tailscale=settings['tailscale'], config=settings['config']) - return account, secrets_ - - -def disk_systems(disk): - """Operating systems suggested by a disk's partitions, for the location step.""" - found = [] - for partition in disk.get('partitions', []): - system = SYSTEM_FILESYSTEMS.get(partition.get('filesystem', '')) - if system and system not in found: - found.append(system) - return found - - -def required_space(choices): - return max(MINIMUM_BYTES, int(choices.get('required_bytes', 0))) - - -def shrink_candidate(analysis): - """The largest partition Anaconda can shrink, the only one ever offered.""" - candidates = [item for item in analysis.get('partitions', []) if item.get('shrinkable')] - return max(candidates, key=lambda item: item['size'], default=None) - - -def alongside_options(analysis, required): - """What the location step can offer for installing alongside. - - Free space is used when it suffices. Otherwise the largest shrinkable - partition may be shrunk, never below its minimum plus a working margin. - """ - free = int(analysis.get('free', 0)) - if free >= required: - return {'mode': 'free', 'free': free, 'required': required, 'space': free} - largest = shrink_candidate(analysis) - if largest is None: - raise Invalid('This disk has too little free space and no partition CybexOS can shrink. ' - 'Choose Erase disk or another disk.') - floor = largest['min_size'] + OTHER_SYSTEM_MARGIN - maximum = free + largest['size'] - floor - if maximum < required: - raise Invalid(f'Shrinking {largest["path"]} cannot free the {required // GiB} GiB CybexOS needs. ' - 'Choose Erase disk or another disk.') - # Start halfway between the minimum and the most that can be taken. - default = min(maximum, max(required, (required + maximum) // 2)) - partition = {key: largest[key] for key in ('id', 'path', 'size', 'min_size', 'filesystem')} - return {'mode': 'shrink', 'free': free, 'required': required, 'partition': partition, - 'floor': floor, 'min_space': required, 'max_space': maximum, 'default_space': default} - - -def alongside_plan(analysis, required, requested): - """Validate the reviewed choice against a fresh analysis of the disk.""" - options = alongside_options(analysis, required) - if options['mode'] == 'free': - if requested is not None: - raise Invalid('This disk has enough free space; nothing needs to be shrunk. Review the disk again.') - return {'shrink': None, 'space': options['free']} - partition = options['partition'] - if requested is None or requested['device'] != partition['id']: - raise Invalid('Only the largest resizable partition can be shrunk. Review the disk again.') - if not options['floor'] <= requested['size'] < partition['size']: - raise Invalid(f'{partition["path"]} cannot be shrunk to that size. Review the disk again.') - space = options['free'] + partition['size'] - requested['size'] - if space < required: - raise Invalid(f'CybexOS needs at least {required // GiB} GiB. Shrink {partition["path"]} further.') - return {'shrink': {'device': partition['id'], 'size': requested['size']}, 'space': space, - 'partition': partition} - - -def verify_alongside_actions(actions, existing, shrink): - """Refuse a plan that touches any existing device except the one shrunk. - - Free-space automatic partitioning must only create new devices. Any - destroy, a format on an existing device, or a resize of anything other - than the confirmed partition means the plan is not what was reviewed. - """ - target = shrink['device'] if shrink else None - for action in actions: - kind = str(action.get('action-type', '')).lower() - device = action.get('device-id', '') - if kind == 'destroy': - raise Invalid('Anaconda planned to remove an existing partition. Nothing was changed; choose Erase disk or the full installer.') - if kind == 'resize': - if device != target: - raise Invalid('Anaconda planned to resize another partition. Nothing was changed.') - continue - if device in existing: - raise Invalid('Anaconda planned to change an existing partition. Nothing was changed; use the full installer.') - - -def action_set(actions): - """Order-free comparison key for Anaconda's planned device actions. - - DeviceTree.GetActions() re-sorts blivet's action list in place, and its - topological sort reverses the order of independent actions on each call. - """ - return sorted(json.dumps(action, sort_keys=True) for action in actions) - - -def fingerprint(disk): - identity = {key: disk.get(key) for key in ('name', 'path', 'size', 'serial', 'wwn', 'device_id')} - identity['partitions'] = sorted(disk.get('partitions', []), key=lambda item: item.get('path', '')) - return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() - - -def apply_live_keyboard(layout, run=subprocess.run): - """Apply only an Anaconda-listed XKB layout to the live user's compositor.""" - match = re.fullmatch(r'([A-Za-z0-9_-]+)(?:\s*\(([A-Za-z0-9_-]+)\))?', layout) - if not match: - raise Invalid('This keyboard layout needs the full installer.') - user = pwd.getpwnam('liveuser') - command = ['runuser', '-u', 'liveuser', '--', 'env', f'XDG_RUNTIME_DIR=/run/user/{user.pw_uid}'] - instances = json.loads(run(command + ['/usr/bin/hyprctl', '-j', 'instances'], - check=True, capture_output=True, text=True).stdout) - instances = [item for item in instances if Path(f"/proc/{int(item['pid'])}").stat().st_uid == user.pw_uid] - if len(instances) != 1: - raise Invalid('The live keyboard could not be applied to a unique desktop session.') - signature = instances[0]['instance'] - if not re.fullmatch(r'[A-Za-z0-9_.-]+', signature): - raise Invalid('The live desktop instance is invalid.') - # The Lua-configured compositor refuses `hyprctl keyword`. Set layout and - # variant in one evaluation; the validated identifiers are safe Lua strings. - code = 'hl.config({ input = { kb_layout = %s, kb_variant = %s } })' % ( - json.dumps(match[1]), json.dumps(match[2] or '')) - result = run(command + [f'HYPRLAND_INSTANCE_SIGNATURE={signature}', '/usr/bin/hyprctl', 'eval', code], - capture_output=True, text=True) - # hyprctl reports a Lua error as output, sometimes with exit status 0. - if result.returncode != 0 or result.stdout.strip() != 'ok': - raise Invalid('The live keyboard could not be applied. Choose another layout.') - - -class State: - def __init__(self, directory=RUNTIME): - self.directory = directory - - def read(self): - try: - return json.loads((self.directory / 'state.json').read_text()) - except FileNotFoundError: - return {'phase': 'setup'} - - def write(self, value): - # No password, password hash, passphrase or raw DBus request belongs here. - self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) - temporary = self.directory / ('.state-' + secrets.token_hex(12) + '.tmp') - with temporary.open('w', encoding='utf-8') as stream: - os.chmod(temporary, 0o600) - json.dump(value, stream) - temporary.replace(self.directory / 'state.json') - - -class StepLog: - """Timestamped installer steps and the error that stopped them, for the page.""" - def __init__(self, path=RUNTIME / 'worker.log'): - self.path = path - - def reset(self): - self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - self.path.write_text('') - - def write(self, text): - self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - with self.path.open('a', encoding='utf-8') as stream: - stream.write(time.strftime('%H:%M:%S ') + text + '\n') - - -def read_log(source, sources=LOG_SOURCES, limit=LOG_LIMIT): - """The end of one allow-listed log; several files are shown one after another.""" - if source not in sources: - raise Invalid('Choose one of the installer logs.') - parts = [] - for path in sources[source]: - try: - with path.open('rb') as stream: - size = stream.seek(0, os.SEEK_END) - stream.seek(max(0, size - limit)) - text = stream.read().decode('utf-8', 'replace') - except (FileNotFoundError, NotADirectoryError): - continue - if size > limit: - text = text.partition('\n')[2] # Drop the partial first line. - parts.append(f'==> {path} <==\n{text}' if len(sources[source]) > 1 else text) - return {'source': source, 'available': bool(parts), 'text': '\n'.join(parts)} - - -def safe_step(message): - # Only fixed upstream step labels belong in status/exported state. - return message if message in ('Running post-installation scripts', 'Installing the software') else 'Installing CybexOS…' - - -def failure_state(state, error=None): - """Name the step Anaconda was running and the log that explains it.""" - step = safe_step(state.get('message')) - source = 'post' if 'post-install' in step.lower() else 'steps' - state.update(phase='failed-install', failed_step=step, log_source=source, - message=f'Installation stopped during “{step}”. The installation details below show what went wrong. ' - 'Keep this session open.') - return state - - -def log_destinations(state, run=subprocess.run): - """Writable filesystems on removable drives, never the live medium or target disk.""" - output = run(['lsblk', '--json', '--bytes', '--output', - 'NAME,PATH,TYPE,FSTYPE,LABEL,SIZE,HOTPLUG,RM,RO,MOUNTPOINTS'], - check=True, capture_output=True, text=True, timeout=15).stdout - target = state.get('disk', {}).get('name') - - def flatten(device): - yield device - for child in device.get('children', []): - yield from flatten(child) - - destinations = [] - for disk in json.loads(output)['blockdevices']: - devices = list(flatten(disk)) - if (not (disk.get('hotplug') or disk.get('rm')) or disk.get('name') == target or disk.get('ro') - or any(item.get('label') == LIVE_LABEL or item.get('fstype') == 'iso9660' - or '/run/initramfs/live' in (item.get('mountpoints') or []) for item in devices)): - continue - for device in devices: - if device.get('fstype') in EXPORT_FILESYSTEMS and not device.get('ro'): - mounted = [point for point in device.get('mountpoints') or [] if point] - destinations.append(dict(name=device['name'], path=device['path'], label=device.get('label') or '', - fstype=device['fstype'], size=int(device.get('size') or 0), - mountpoint=mounted[0] if mounted else '')) - return destinations - - -def log_archive(path, state_file, run=subprocess.run): - journal = run(['journalctl', '--boot', '--no-pager', '--output=short-iso'], - capture_output=True, text=True, timeout=120).stdout - with tarfile.open(path, 'w:gz') as archive: - def add_text(name, text): - data = text.encode() - info = tarfile.TarInfo(name) - info.size, info.mtime, info.mode = len(data), int(time.time()), 0o644 - archive.addfile(info, io.BytesIO(data)) - for source, paths in LOG_SOURCES.items(): - for item in paths: - if item.is_file(): - add_text(f'cybexos-install-logs/{source}/{item.name}', item.read_text(errors='replace')) - if state_file.is_file(): - # The state file is secret-free: no password, hash or raw request. - add_text('cybexos-install-logs/state.json', state_file.read_text()) - add_text('cybexos-install-logs/journal.txt', journal) - - -def export_logs(name, state, run=subprocess.run, runtime=RUNTIME): - destination = next((item for item in log_destinations(state, run) if item['name'] == name), None) - if destination is None: - raise Invalid('Choose a connected USB drive to save the logs to.') - filename = time.strftime('cybexos-install-logs-%Y%m%d-%H%M%S.tar.gz') - archive = runtime / ('.export-' + secrets.token_hex(8) + '.tar.gz') - mountpoint, mounted = destination['mountpoint'], False - try: - log_archive(archive, runtime / 'state.json', run) - if not mountpoint: - mountpoint = str(runtime / 'export-mount') - Path(mountpoint).mkdir(mode=0o700, exist_ok=True) - run(['mount', '-o', 'nosuid,nodev,noexec', destination['path'], mountpoint], check=True, timeout=60) - mounted = True - target = Path(mountpoint) / filename - shutil.copyfile(archive, target) - target.chmod(0o644) - run(['sync', '--file-system', str(target)], check=True, timeout=120) - except (OSError, subprocess.SubprocessError): - raise Invalid('The logs could not be saved to that drive. Check that it is writable and try again.') from None - finally: - archive.unlink(missing_ok=True) - if mounted: - run(['umount', mountpoint], check=False, timeout=60) - return {'file': filename, 'device': destination['path'], 'label': destination['label']} - - -def installation_status(store, run=subprocess.run): - state = store.read() - if state.get('phase') == 'installing': - service = run(['systemctl', 'show', 'cybexos-installer-worker.service', - '--property=ActiveState', '--value'], capture_output=True, text=True) - if (time.time() - state.get('worker_queued_at', time.time()) > 30 - and service.stdout.strip() not in ('active', 'activating', 'reloading', 'deactivating')): - # A killed worker does not prove that Anaconda's storage task stopped. - # Fail closed: never report success or permit a replacement install. - state.update(phase='failed-install', log_source='anaconda', message='The installation monitor stopped unexpectedly. Anaconda may still be writing the disk. Keep this session open and inspect the installation details before restarting.') - # This status reader never writes: the worker alone owns progress - # and completion. Its concurrent final update must win this race. - return public_status(state) - - -def public_status(state): - """Never send persisted account, disk plan, or Anaconda text to the browser.""" - allowed = ('setup', 'planning', 'review', 'failed-plan', 'installing', 'complete', 'failed-install') - phase = state.get('phase') if state.get('phase') in allowed else 'failed-install' - messages = { - 'installing': 'Anaconda is installing CybexOS. Keep this session open.', - 'complete': 'CybexOS is installed. Restart and remove the installation medium.', - 'failed-install': 'Installation did not finish. Save diagnostics and check the installer logs before restarting.', - } - result = {'phase': phase, 'message': messages.get(phase, '')} - if phase == 'failed-install': - step = safe_step(state.get('failed_step')) - if step != 'Installing CybexOS…': - result.update(failed_step=step, message=f'Installation stopped during “{step}”. Check the installation details.') - if state.get('log_source') in LOG_SOURCES: - result['log_source'] = state['log_source'] - for key in ('step', 'total'): - if type(state.get(key)) is int and 0 <= state[key] <= 100000: - result[key] = state[key] - return result - - -def diagnostics(store, run=subprocess.run): - """Export useful status without raw logs, credentials, disk IDs, or usernames.""" - state = store.read() - service = run(['systemctl', 'show', 'cybexos-installer-worker.service', - '--property=ActiveState,SubState,Result', '--no-pager'], - capture_output=True, text=True, timeout=10) - fields = {} - for line in service.stdout.splitlines(): - key, separator, value = line.partition('=') - if separator and key in ('ActiveState', 'SubState', 'Result') and re.fullmatch(r'[a-z-]{1,32}', value): - fields[key] = value - return {'schema': 1, 'installer': public_status(state), 'worker': fields, - 'backend_ready': Path('/run/anaconda/backend_ready').exists()} - - -class SeedVolume: - """Finds the attached seed volume; replaced by a fixture in tests.""" - - def __init__(self, directory=RUNTIME): - self.mountpoint = directory / 'seed' - - def discover(self): - return seed_format.discover(self.mountpoint) - - -class Installer: - def __init__(self, backend, state, emit=lambda event: None, seeds=None, steps=None): - self.backend, self.state, self.emit = backend, state, emit - self.steps = steps or StepLog(state.directory / 'worker.log') - self.seeds = seeds or SeedVolume(state.directory) - - @property - def staged(self): - # Root-only, memory-backed files the target hook copies and deletes. - return self.state.directory / 'staged' - - def stage(self, name, value): - self.staged.mkdir(mode=0o700, parents=True, exist_ok=True) - descriptor = os.open(self.staged / name, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, 'w') as stream: - stream.write(value) - - def clear_staged(self): - for path in self.staged.glob('*') if self.staged.is_dir() else []: - path.unlink(missing_ok=True) - - def require_idle(self): - if self.state.read().get('phase') in ('installing', 'complete', 'failed-install'): - raise Invalid('Installation has already started. Check its status before leaving this window.') - - def inventory(self): - return self.backend.inventory() - - def rescan(self): - self.require_idle() - # Invalidate every review before the device tree is refreshed. - self.clear_staged() - self.state.write({'phase': 'setup'}) - self.backend.rescan() - return self.inventory() - - def keyboard(self, data): - self.require_idle() - layout = data.get('keyboard', '') - if layout not in {item['id'] for item in self.inventory()['keyboards']}: - raise Invalid('Choose an available keyboard layout.') - result = self.backend.keyboard(layout) - # Changing the active keyboard invalidates any old disk confirmation. - self.clear_staged() - self.state.write({'phase': 'setup', 'keyboard': layout}) - return result - - def seed(self): - """Report an attached unattended seed: its summary, or every problem.""" - self.require_idle() - try: - seed, seed_disk = self.seeds.discover() - except seed_format.SeedError as error: - return {'found': True, 'valid': False, 'errors': error.errors} - if seed is None: - return {'found': False} - summary = seed.summary() - if self.cancelled(seed): - # Reloading the page must not restart a countdown the user stopped. - return {'found': True, 'valid': False, 'cancelled': True, 'errors': [], 'seed': summary} - try: - choices = self.inventory() - validate_seed(seed, choices) - disk = seed_format.select_disk(seed.settings['match'], choices['disks'], excluded={seed_disk}) - if disk['size'] < required_space(choices): - raise Invalid(f'The matched disk {disk["path"]} is smaller than the ' - f'{required_space(choices) // GiB} GiB CybexOS needs.') - summary['disk'] = disk - except seed_format.SeedError as error: - return {'found': True, 'valid': False, 'errors': error.errors, 'seed': summary} - except Invalid as error: - return {'found': True, 'valid': False, 'errors': [str(error)], 'seed': summary} - return {'found': True, 'valid': True, 'errors': [], 'seed': summary} - - def cancelled(self, seed): - try: - return (self.state.directory / 'seed-cancelled').read_text() == seed.digest - except OSError: - return False - - def cancel_seed(self, data): - """Stop an unattended installation; the same seed is not offered again.""" - digest = data.get('seed') - if not isinstance(digest, str) or not re.fullmatch(r'[0-9a-f]{64}', digest): - raise Invalid('The seed to cancel is not valid.') - self.require_idle() - self.state.directory.mkdir(mode=0o700, parents=True, exist_ok=True) - (self.state.directory / 'seed-cancelled').write_text(digest) - return self.reset() - - def alongside(self, data): - """Free space and the one shrinkable partition for installing alongside.""" - self.require_idle() - self.clear_staged() - self.state.write({'phase': 'setup'}) # Any earlier review is invalid now. - choices = self.inventory() - disk = next((item for item in choices['disks'] if item['name'] == data.get('disk')), None) - if disk is None: - raise Invalid('Select an available installation disk.') - result = alongside_options(self.backend.alongside(disk), required_space(choices)) - result['systems'] = disk_systems(disk) - return result - - def plan(self, data): - self.require_idle() - self.state.write({'phase': 'planning'}) # Any old confirmation token is invalid now. - self.clear_staged() - try: - choices = self.inventory() - seed = None - if 'seed' in data: - try: - seed, seed_disk = self.seeds.discover() - except seed_format.SeedError as error: - raise Invalid('The seed is no longer valid: ' + ' '.join(error.errors)) from None - if seed is None or not secrets.compare_digest(seed.digest, str(data.get('seed'))): - raise Invalid('The seed volume changed. Check it again before installing.') - if self.cancelled(seed): - raise Invalid('This unattended installation was cancelled.') - account, credentials = validate_seed(seed, choices) - try: - disk = seed_format.select_disk(seed.settings['match'], choices['disks'], excluded={seed_disk}) - except seed_format.SeedError as error: - raise Invalid(' '.join(error.errors)) from None - else: - account = validate_account(data, choices) - credentials = {} - if not account['oem']: - credentials['password'] = data['password'] - if account['encrypted']: - credentials['passphrase'] = data['password'] - disk = next((item for item in choices['disks'] if item['name'] == data.get('disk')), None) - if disk is None: - raise Invalid('Select an available installation disk. The live medium cannot be selected.') - required = required_space(choices) - layout = {'mode': account['layout']} - existing = set() - if account['layout'] == 'alongside': - analysis = self.backend.alongside(disk) - decision = alongside_plan(analysis, required, account['shrink']) - account['shrink'] = decision['shrink'] - existing = {disk['name'], *(item['id'] for item in analysis.get('partitions', []))} - layout.update(space=decision['space'], systems=disk_systems(disk), - kept=[item['path'] for item in analysis.get('partitions', []) - if not decision['shrink'] or item['id'] != decision['shrink']['device']]) - if decision['shrink']: - partition = decision['partition'] - layout['resize'] = {'path': partition['path'], 'filesystem': partition['filesystem'], - 'from': partition['size'], 'to': decision['shrink']['size']} - elif disk['size'] < required: - raise Invalid(f'CybexOS needs a disk with at least {(required + GiB - 1) // GiB} GiB for its included applications.') - result = self.backend.plan(account, credentials, disk) - credentials.clear() - if result.get('errors'): - raise Invalid('Anaconda rejected the storage layout. Open Advanced storage to review the disk.') - if account['layout'] == 'alongside': - verify_alongside_actions(result['actions'], existing, account['shrink']) - state = dict(phase='review', token=secrets.token_hex(24), created=time.time(), - disk=disk, fingerprint=fingerprint(disk), account=account, layout=layout, - partitioning=result['partitioning'], actions=result['actions'], - warnings=result.get('warnings', []), boot_keyboard=result['boot_keyboard']) - if seed is not None: - # The countdown is enforced here as well as shown by the page. - state.update(seed_digest=seed.digest, countdown=seed.settings['countdown'], - countdown_ends=time.time() + seed.settings['countdown']) - if seed.secrets.get('tailscale_auth_key'): - self.stage('tailscale-authkey', seed.secrets['tailscale_auth_key']) - self.state.write(state) - return state - except Exception: - self.state.write({'phase': 'failed-plan'}) - self.clear_staged() - raise - finally: - data.pop('password', None) - data.pop('confirm', None) - - def install(self, data): - state = self.state.read() - if state.get('phase') != 'review': - raise Invalid('Review a new installation plan before installing.') - if not secrets.compare_digest(str(data.get('token', '')), state['token']): - raise Invalid('This review has expired. Review the disk again.') - if state.get('layout', {}).get('mode') == 'alongside': - if data.get('confirmed_disk') != state['disk']['name'] or data.get('alongside_confirmed') is not True: - raise Invalid('Confirm the disk changes before installing alongside.') - elif data.get('confirmed_disk') != state['disk']['name'] or data.get('erase_confirmed') is not True: - raise Invalid('Confirm erasing the selected disk before installing.') - if time.time() - state['created'] > 1800: - raise Invalid('This review has expired. Review the disk again.') - if state['account'].get('unattended') and time.time() < state.get('countdown_ends', 0) - 1: - raise Invalid('The unattended installation waits for its countdown. Cancel it to stop.') - current = next((item for item in self.inventory()['disks'] if item['name'] == state['disk']['name']), None) - if current is None or fingerprint(current) != state['fingerprint']: - self.state.write({'phase': 'failed-plan'}) - raise Invalid('The selected disk changed. Review the available disks again.') - # Reject storage edits made by another UI between review and confirmation. - self.backend.check_plan(state) - state['phase'] = 'installing' - state['worker_queued_at'] = time.time() - state.pop('token') - self.state.write(state) - self.steps.reset() - self.steps.write(f"Installing to {state['disk']['path']} ({state['disk']['model']}), " - f"{'encrypted' if state['account']['encrypted'] else 'unencrypted'}.") - - try: - self.backend.start_install(state) - except Exception: - state.update(phase='failed-install', log_source='steps', message='The installation worker could not start. Review its status before restarting the installer.') - self.steps.write('The installation worker could not start.') - self.state.write(state) - raise - return public_status(state) - - def worker(self): - state = self.state.read() - if state.get('phase') != 'installing' or state.get('task'): - raise Invalid('No new confirmed installation is queued.') - - def progress(step, total, message): - # Keep free-form task text in the explicitly opened log only. - safe_message = safe_step(message) - self.steps.write(f'[{step}/{total}] {message}') - state.update(step=step, total=total, message=safe_message) - self.state.write(state) - self.emit({'event': 'progress', 'phase': 'installing', 'step': step, - 'total': total, 'message': 'Installing CybexOS…'}) - - try: - self.backend.run_install(state, progress, self.state) - state.update(phase='complete', message='CybexOS is installed. Restart and remove the installation medium.') - except Exception as error: - failure_state(state) - self.steps.write(f"Installation stopped during: {state['failed_step']}") - # Anaconda's own error text; its installation task takes no - # parameters, so no request or secret can be part of it. - self.steps.write(f'{type(error).__name__}: {error}') - self.state.write(state) - raise - self.steps.write('Installation finished.') - self.state.write(state) - return state - - def geolocate(self): - return self.backend.geolocate() - - def reset(self): - self.require_idle() - self.backend.reset() - self.clear_staged() - self.state.write({'phase': 'setup'}) - return {'phase': 'setup'} - - -class Anaconda: - """Adapter for anaconda 44.30, matching anaconda-webui 68's API sequence.""" - def __init__(self): - # Always discover the installation bus from Anaconda's own root-owned file. - os.environ.pop('DBUS_ANACONDA_SESSION_ADDRESS', None) - os.environ.pop('DBUS_STARTER_ADDRESS', None) - from pyanaconda.core.dbus import AnacondaMessageBus - from gi.repository import GLib - self.bus = AnacondaMessageBus() - self.GLib = GLib - - def proxy(self, module, suffix=''): - service = 'org.fedoraproject.Anaconda.' + ('Boss' if module == 'Boss' else 'Modules.' + module) - path = '/' + service.replace('.', '/') + suffix - return self.bus.get_proxy(service, path) - - def task(self, module, path, result=False, progress=None, timeout=300): - service = 'org.fedoraproject.Anaconda.' + ('Boss' if module == 'Boss' else 'Modules.' + module) - task = self.bus.get_proxy(service, path) - context = self.GLib.MainContext.default() - stopped = [] - task.Stopped.connect(lambda: stopped.append(True)) - if progress: - task.ProgressChanged.connect(lambda step, message: progress(step, task.Steps, message)) - task.Start() - start = time.monotonic() - while not stopped: - while context.pending(): - context.iteration(False) - if time.monotonic() - start > timeout: - # Never cancel an installation whose filesystem transaction may be active. - raise Invalid('Anaconda is taking longer than expected. Keep the installer open and check its status.') - time.sleep(0.05) - task.Finish() # Propagates failures; stopped does not mean succeeded. - return plain(task.GetResult()) if result else None - - def inventory(self): - selection = self.proxy('Storage', '/DiskSelection') - viewer = self.proxy('Storage', '/DeviceTree') - disks = [] - for name in selection.GetUsableDisks(): - device = plain(viewer.GetDeviceData(name)) - if not device.get('is-disk') or device.get('protected'): - continue - attrs = device.get('attrs', {}) - partitions = [] - try: - report = subprocess.run(['lsblk', '--json', '--bytes', '--output', - 'PATH,SIZE,TYPE,FSTYPE', '--', device['path']], - check=True, capture_output=True, text=True, timeout=8) - def visit(nodes): - for node in nodes: - if node.get('type') == 'part': - partitions.append({'path': node.get('path', ''), 'size': node.get('size', 0), - 'filesystem': node.get('fstype') or ''}) - visit(node.get('children', [])) - visit(json.loads(report.stdout).get('blockdevices', [])) - except (OSError, ValueError, TypeError, subprocess.SubprocessError): - # Anaconda remains the authority for whether this disk is safe to select. - pass - disk = dict(name=name, path=device['path'], size=device['size'], - model=attrs.get('model') or device.get('description') or name, - serial=attrs.get('serial', ''), wwn=attrs.get('wwn', ''), - device_id=device.get('device-id', ''), removable=device.get('removable', False), - partitions=partitions) - # A hint for offering "Install alongside"; not part of the identity. - disk['systems'] = disk_systems(disk) - disks.append(disk) - localization = self.proxy('Localization') - keyboards = plain(localization.GetKeyboardLayouts()) - keyboard_choices = [{'id': item['layout-id'], 'label': item['description']} for item in keyboards] - # Common locales are only Anaconda's short list of popular choices. - # Enumerate its supported languages and regional locales so installed - # translations such as Dutch remain selectable. - locales = sorted({locale for language in localization.GetLanguages() - for locale in localization.GetLocales(language)}) - if localization.Language and localization.Language not in locales: - locales.append(localization.Language) - timezone = self.proxy('Timezone') - timezones = sorted({f'{region}/{city}' for region, cities in plain(timezone.GetAllValidTimezones()).items() - for city in cities} | {'UTC'}) - # Anaconda geolocates at startup but applies the result only if it - # arrives within seconds; a later result is still recorded here. - detected = plain(timezone.GeolocationResult).get('timezone', '') - detected = detected if detected in timezones else '' - selected = detected or timezone.Timezone - return dict(disks=disks, keyboards=keyboard_choices, locales=locales, - locale=localization.Language or 'en_US.UTF-8', - keyboard=(list(localization.XLayouts) or ['us'])[0], - timezones=timezones, timezone=selected if selected in timezones else 'UTC', - detected_timezone=detected, - required_bytes=int(self.proxy('Payloads').CalculateRequiredSpace())) - - def geolocate(self): - """Look up the timezone once the network is up. Selects nothing itself.""" - timezone = self.proxy('Timezone') - if not plain(timezone.GeolocationResult).get('timezone'): - # The task waits for networking and returns an empty result on failure. - self.task('Timezone', timezone.StartGeolocationWithTask(), timeout=90) - return {'timezone': plain(timezone.GeolocationResult).get('timezone', '')} - - def reset(self): - self.proxy('Storage').ResetPartitioning() - self.proxy('Users').Users = [] - self.proxy('Storage', '/DiskSelection').SelectedDisks = [] - initialization = self.proxy('Storage', '/DiskInitialization') - initialization.InitializationMode = 0 - initialization.DrivesToClear = [] - initialization.DevicesToClear = [] - - def rescan(self): - self.task('Storage', self.proxy('Storage').ScanDevicesWithTask(), timeout=300) - - def keyboard(self, layout): - localization = self.proxy('Localization') - localization.XLayouts = [layout] - localization.VirtualConsoleKeymap = '' - self.task('Localization', localization.PopulateMissingKeyboardConfigurationWithTask()) - if not localization.VirtualConsoleKeymap: - raise Invalid('This layout cannot be used at the boot unlock screen.') - apply_live_keyboard(layout) - return {'keyboard': layout, 'boot_keyboard': localization.VirtualConsoleKeymap} - - def partitioning(self, path): - return self.bus.get_proxy(STORAGE_SERVICE, path) - - def alongside(self, disk): - """Read-only: free space and resize limits from a scratch partitioning. - - anaconda-webui 68's reclaim dialog reads the same limits from an - AUTOMATIC partitioning's resizable device tree. Nothing is scheduled - here, and the playground is dropped again afterwards. - """ - self.reset() - self.proxy('Storage', '/DiskSelection').SelectedDisks = [disk['name']] - partitioning = self.partitioning(self.proxy('Storage').CreatePartitioning('AUTOMATIC')) - tree = self.partitioning(partitioning.GetDeviceTree()) - try: - partitions = [] - for device_id in tree.GetDevicePartitions(disk['name']): - data = plain(tree.GetDeviceData(device_id)) - shrinkable = bool(tree.IsDeviceShrinkable(device_id)) - minimum = int(tree.GetDeviceSizeLimits(device_id)[0]) if shrinkable else int(data['size']) - partitions.append(dict(id=device_id, path=data.get('path', ''), size=int(data['size']), - min_size=minimum, shrinkable=shrinkable, - filesystem=plain(tree.GetFormatData(device_id)).get('type', ''))) - return dict(free=int(tree.GetDiskFreeSpace([disk['name']])), partitions=partitions) - finally: - self.reset() - - def plan(self, account, credentials, disk): - from pyanaconda.core.users import crypt_password - from pyanaconda.modules.common.structures.user import UserData - from pyanaconda.modules.common.structures.partitioning import PartitioningRequest - self.reset() - users = self.proxy('Users') - if account['oem']: - # The new owner creates the only account at first start. - users.Users = [] - else: - user = UserData() - user.name = account['username'] - if account['full_name']: - user.gecos = account['full_name'] - # A seed provides only a hash; an interactive password is hashed here. - user.password = credentials.get('password_hash') or crypt_password(credentials['password']) - user.is_crypted = True - user.groups = ['wheel'] - users.Users = [UserData.to_structure(user)] - users.ClearRootPassword() - users.IsRootAccountLocked = True - localization = self.proxy('Localization') - localization.Language = account['locale'] - localization.XLayouts = [account['keyboard']] - localization.VirtualConsoleKeymap = '' - self.task('Localization', localization.PopulateMissingKeyboardConfigurationWithTask()) - boot_keyboard = localization.VirtualConsoleKeymap - if not boot_keyboard: - raise Invalid('Anaconda could not map this keyboard to the boot unlock screen. Choose another layout.') - self.proxy('Timezone').SetTimezoneWithPriority(account['timezone'], 90) - self.proxy('Network').Hostname = account['hostname'] - storage = self.proxy('Storage') - self.proxy('Storage', '/DiskSelection').SelectedDisks = [disk['name']] - initialization = self.proxy('Storage', '/DiskInitialization') - alongside = account['layout'] == 'alongside' - if alongside: - # CLEAR_PARTITIONS_NONE: automatic partitioning uses free space only, - # and no disk label is ever recreated. - initialization.InitializationMode = 0 - initialization.DrivesToClear = [] - initialization.InitializeLabelsEnabled = False - else: - initialization.InitializationMode = 1 # CLEAR_PARTITIONS_ALL; selected disk only. - initialization.DrivesToClear = [disk['name']] - initialization.InitializeLabelsEnabled = True - initialization.DevicesToClear = [] - path = storage.CreatePartitioning('AUTOMATIC') - partitioning = self.partitioning(path) - if alongside and account['shrink']: - # The same call the reclaim dialog makes on this partitioning's tree. - tree = self.partitioning(partitioning.GetDeviceTree()) - tree.ShrinkDevice(account['shrink']['device'], account['shrink']['size']) - request = PartitioningRequest.from_structure(partitioning.Request) - request.partitioning_scheme = 1 # BTRFS - request.encrypted = account['encrypted'] - request.luks_version = 'luks2' - partitioning.Request = PartitioningRequest.to_structure(request) - if account['encrypted']: - partitioning.SetPassphrase(credentials['passphrase']) - self.proxy('Storage', '/Bootloader').Drive = '' - self.task('Storage', partitioning.ConfigureWithTask()) - storage.ApplyPartitioning(path) - report = self.task('Storage', partitioning.ValidateWithTask(), result=True) - return dict(partitioning=path, actions=plain(self.proxy('Storage', '/DeviceTree').GetActions()), - errors=report.get('error-messages', []), warnings=report.get('warning-messages', []), - boot_keyboard=boot_keyboard) - - def check_plan(self, state): - storage = self.proxy('Storage') - if storage.AppliedPartitioning != state['partitioning']: - raise Invalid('The storage plan changed. Review the installation again.') - if list(self.proxy('Storage', '/DiskSelection').SelectedDisks) != [state['disk']['name']]: - raise Invalid('The selected storage changed. Review the installation again.') - actions = plain(self.proxy('Storage', '/DeviceTree').GetActions()) - if action_set(actions) != action_set(state['actions']): - raise Invalid('The planned disk changes changed. Review the installation again.') - partitioning = self.partitioning(state['partitioning']) - report = self.task('Storage', partitioning.ValidateWithTask(), result=True) - if report.get('error-messages'): - raise Invalid('Anaconda no longer considers this storage plan valid.') - - def start_install(self, state): - # The worker is independent of the browser/Cockpit channel. Closing a - # tab never terminates the monitor or starts a second installation. - subprocess.run(['/usr/bin/systemd-run', '--unit=cybexos-installer-worker', - '--collect', '--property=Type=exec', - '/usr/libexec/cybexos-installer-backend', 'worker'], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, check=True) - - def run_install(self, state, progress, store): - tasks = list(self.proxy('Boss').InstallWithTasks()) - if len(tasks) != 1: - raise Invalid('This Anaconda version returned an unsupported installation task sequence.') - state['task'] = tasks[0] - store.write(state) - self.task('Boss', tasks[0], progress=progress, timeout=4 * 3600) - - -def check_live_environment(require_backend=True): - if os.geteuid() != 0: - raise Invalid('The installer needs its authorized local administrator connection.') - cmdline = Path('/proc/cmdline').read_text().split() - if not Path('/run/cybexos-live').is_file() or not any(item in ('rd.live.image', 'rd.live.image=1') for item in cmdline): - raise Invalid('Installation is available only from the CybexOS live image.') - if require_backend and not Path('/run/anaconda/backend_ready').exists(): - raise Invalid('Anaconda is still starting. Try again in a moment.') - - -def main(): - os.umask(0o077) - command = sys.argv[1] if len(sys.argv) == 2 else '' - if command not in ('inventory', 'rescan', 'diagnostics', 'keyboard', 'plan', 'install', 'status', 'geolocate', 'reset', 'advanced', - 'worker', 'reboot', 'seed', 'alongside', 'cancel-seed', 'log', 'log-destinations', 'export-logs'): - raise Invalid('Unknown installer request.') - check_live_environment(require_backend=command not in ('status', 'diagnostics', 'reboot', 'log', 'log-destinations', 'export-logs')) - RUNTIME.mkdir(mode=0o700, parents=True, exist_ok=True) - # Serialize planning and commit. Status and logs remain readable during - # installation, and a slow geolocation lookup, which changes no selection, - # never blocks setup. - lock = (RUNTIME / 'lock').open('a') - reading_logs = command in ('log', 'log-destinations', 'export-logs') - if command not in ('status', 'geolocate', 'diagnostics') and not reading_logs: - try: - fcntl.flock(lock, fcntl.LOCK_EX | (0 if command == 'worker' else fcntl.LOCK_NB)) - except BlockingIOError: - raise Invalid('Another installer operation is still running.') from None - if command == 'status': - return installation_status(State()) - if command == 'diagnostics': - return diagnostics(State()) - if command == 'reboot': - if State().read().get('phase') != 'complete': - raise Invalid('Wait for installation to finish before restarting.') - subprocess.run(['systemctl', 'reboot'], check=True) - return {'phase': 'complete'} - if command == 'worker': - return Installer(Anaconda(), State(), emit).worker() - raw = sys.stdin.buffer.read(16385) - if len(raw) > 16384: - raise Invalid('Installer request is too large.') - try: - data = json.loads(raw or b'{}') - except (ValueError, UnicodeError): - raise Invalid('Installer request is invalid.') from None - if not isinstance(data, dict): - raise Invalid('Installer request is invalid.') - if command == 'log': - return read_log(data.get('source', 'steps')) - if command == 'log-destinations': - return {'destinations': log_destinations(State().read())} - if command == 'export-logs': - return export_logs(data.get('device', ''), State().read()) - installer = Installer(Anaconda(), State(), emit) - if command == 'inventory': - return installer.inventory() - if command == 'rescan': - return installer.rescan() - if command == 'geolocate': - return installer.geolocate() - if command == 'seed': - return installer.seed() - if command == 'cancel-seed': - return installer.cancel_seed(data) - if command in ('reset', 'advanced'): - return installer.reset() - return getattr(installer, command)(data) - - -def emit(event): - # The browser may disconnect while Anaconda continues installing. Persisted - # state still records completion; broken stdout must not abort the monitor. - try: - print(json.dumps(event), flush=True) - except BrokenPipeError: - pass - - -if __name__ == '__main__': - try: - emit({'event': 'result', 'ok': True, 'data': main()}) - except Invalid as error: - emit({'event': 'result', 'ok': False, 'error': str(error)}) - sys.exit(1) - except Exception: - # Remote errors can include method parameters; do not echo or log them. - emit({'event': 'result', 'ok': False, - 'error': 'Anaconda could not complete this operation. Open Advanced storage or check the installer diagnostics.'}) - sys.exit(1) diff --git a/image/live-rootfs/usr/libexec/cybexos-installer-seed b/image/live-rootfs/usr/libexec/cybexos-installer-seed deleted file mode 100755 index 1ca59541..00000000 --- a/image/live-rootfs/usr/libexec/cybexos-installer-seed +++ /dev/null @@ -1,435 +0,0 @@ -#!/usr/bin/python3 -"""Read and validate an unattended CybexOS installation seed. - -A seed is `cybexos-seed.yml` at the root of a volume labelled CYBEXOS-SEED -(or cloud-init's CIDATA). The backend mounts it read-only, reads only the -named regular files, and unmounts it again. Validation never prints or -returns a secret: the disk passphrase and the Tailscale auth key are read -from separate files and returned only in `Seed.secrets`. - - cybexos-installer-seed check DIRECTORY validate a prepared seed folder - -`check` validates everything except the disk match, which needs the target -machine's disks. See image/INSTALLER.md for the format and its trade-offs. -""" -import hashlib -import json -import os -from pathlib import Path -import re -import stat -import subprocess -import sys - -import yaml - -# Source fixtures exercise this checkout; the live image reads the RPM-owned -# copy of the policy that cybexos-config and the checkout installer share. -# Installed at /usr/libexec the file has only three parents, so the checkout -# layout (image/live-rootfs/usr/libexec) must be tested for, not indexed. -_PARENTS = Path(__file__).resolve().parents -_SOURCE = _PARENTS[3] if len(_PARENTS) > 3 else None -sys.path.insert(0, str(_SOURCE) if _SOURCE and (_SOURCE / 'installation_policy.py').is_file() - else '/usr/share/cybexos/lib') -from installation_policy import choices as validate_choices # noqa: E402 - -LABELS = ('CYBEXOS-SEED', 'CIDATA', 'cidata') -SEED_NAME = 'cybexos-seed.yml' -SEED_LIMIT = 64 * 1024 -FILE_LIMIT = 4096 -FILE_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]{0,63}') -USERNAME = re.compile(r'[a-z_][a-z0-9_-]{0,30}') -HOSTNAME = re.compile(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?') -# glibc/libxcrypt hashes Anaconda writes as-is: yescrypt (Fedora's default) -# and SHA-512 crypt. `mkpasswd -m yescrypt` or `openssl passwd -6` make them. -PASSWORD_HASH = re.compile(r'\$y\$[./A-Za-z0-9]+\$[./A-Za-z0-9]{1,86}\$[./A-Za-z0-9]{43}' - r'|\$6\$(?:rounds=[0-9]{4,9}\$)?[./A-Za-z0-9]{1,16}\$[./A-Za-z0-9]{86}') -SSH_KEY = re.compile(r'(?:ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(?:256|384|521)|' - r'sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)' - r' [A-Za-z0-9+/]{16,8192}={0,3}(?: [ -~]{0,200})?') -SIZE = re.compile(r'([0-9]+(?:\.[0-9]+)?)\s*([KMGT]i?B?|B)?', re.IGNORECASE) -UNITS = {'': 1, 'B': 1, 'K': 1000, 'KB': 1000, 'KIB': 1024, 'M': 1000**2, 'MB': 1000**2, 'MIB': 1024**2, - 'G': 1000**3, 'GB': 1000**3, 'GIB': 1024**3, 'T': 1000**4, 'TB': 1000**4, 'TIB': 1024**4} -MATCH_KEYS = ('serial', 'wwn', 'model', 'path', 'min_size', 'max_size') -TOP_KEYS = {'version', 'disk', 'encryption', 'account', 'oem', 'system', 'ssh', 'tailscale', 'config', - 'countdown'} - - -class SeedError(ValueError): - """Every problem in a seed, as display-safe sentences without secrets.""" - - def __init__(self, errors): - self.errors = list(errors) - super().__init__('; '.join(self.errors)) - - -class Seed: - """A validated seed. `summary` is display-safe; `secrets` is not.""" - - def __init__(self, settings, secrets, digest): - self.settings, self.secrets, self.digest = settings, secrets, digest - - def summary(self): - settings = self.settings - return {'digest': self.digest, 'oem': settings['oem'], 'encrypted': settings['encrypted'], - 'match': settings['match'], 'username': settings['username'], - 'full_name': settings['full_name'], 'hostname': settings['hostname'], - 'keyboard': settings['keyboard'], 'locale': settings['locale'], - 'timezone': settings['timezone'], 'passwordless_wheel': settings['passwordless_wheel'], - 'ssh_keys': len(settings['ssh_authorized_keys']), 'ssh_server': settings['ssh_server'], - 'tailscale': settings['tailscale'], 'config': settings['config'], - 'countdown': settings['countdown']} - - -def parse_size(value): - if type(value) is int and value > 0: - return value - match = SIZE.fullmatch(value.strip()) if isinstance(value, str) else None - if not match or match[2] and match[2].upper() not in UNITS: - raise ValueError - return int(float(match[1]) * UNITS[(match[2] or '').upper()]) - - -def control_free(value): - return not any(ord(char) < 32 or ord(char) == 127 for char in value) - - -def parse(text, read_file): - """Validate seed YAML; read_file(name) returns a referenced file's bytes. - - Raises SeedError listing every problem found. - """ - errors = [] - try: - document = yaml.safe_load(text) - except yaml.YAMLError as error: - mark = getattr(error, 'problem_mark', None) - where = f' (line {mark.line + 1})' if mark else '' - raise SeedError([f'{SEED_NAME} is not valid YAML{where}.']) from None - if not isinstance(document, dict): - raise SeedError([f'{SEED_NAME} must contain a mapping of settings.']) - for key in sorted(set(document) - TOP_KEYS): - errors.append(f'Unknown setting "{key}".') - if document.get('version') != 1 or type(document.get('version')) is not int: - errors.append('Set "version: 1".') - - def section(name, required=False): - value = document.get(name) - if value is None: - if required: - errors.append(f'The "{name}" section is required.') - return {} - if not isinstance(value, dict): - errors.append(f'"{name}" must be a mapping.') - return {} - return value - - def boolean(mapping, key, default, where): - value = mapping.get(key, default) - if type(value) is not bool: - errors.append(f'{where}.{key} must be true or false.') - return default - return value - - def unknown(mapping, allowed, where): - for key in sorted(set(mapping) - set(allowed)): - errors.append(f'Unknown setting "{where}.{key}".') - - secrets = {} - - def secret_file(mapping, key, where, minimum, maximum): - name = mapping.get(key) - if not isinstance(name, str) or not FILE_NAME.fullmatch(name) or name == SEED_NAME: - errors.append(f'{where}.{key} must name a file at the top of the seed volume.') - return None - try: - raw = read_file(name) - except (OSError, ValueError) as error: - errors.append(f'{where}.{key}: {name} {error}' if isinstance(error, ValueError) - else f'{where}.{key}: {name} could not be read.') - return None - try: - value = raw.decode('utf-8') - except UnicodeError: - errors.append(f'{where}.{key}: {name} must be UTF-8 text.') - return None - # One trailing line ending is what an editor adds; it is not part of the secret. - value = value[:-2] if value.endswith('\r\n') else value[:-1] if value.endswith('\n') else value - if not minimum <= len(value) <= maximum or not control_free(value): - errors.append(f'{where}.{key}: {name} must hold {minimum}–{maximum} characters ' - 'on one line, without control characters.') - return None - return value - - oem = document.get('oem', False) - if type(oem) is not bool: - errors.append('oem must be true or false.') - oem = False - - disk = section('disk', required=True) - unknown(disk, ('match', 'erase'), 'disk') - if disk and disk.get('erase') is not True: - errors.append('Set "disk.erase: true" to confirm that the matched disk is erased.') - match = disk.get('match') - rule = {} - if not isinstance(match, dict) or not match: - if disk: - errors.append('disk.match must name at least one of: ' + ', '.join(MATCH_KEYS) + '.') - else: - before = len(errors) - unknown(match, MATCH_KEYS, 'disk.match') - for key in ('serial', 'wwn', 'model', 'path'): - if key in match: - value = match[key] - if not isinstance(value, str) or not value.strip() or len(value) > 256 or not control_free(value): - errors.append(f'disk.match.{key} must be non-empty text.') - elif key == 'path' and not re.fullmatch(r'/dev/[A-Za-z0-9/_.:+-]+', value): - errors.append('disk.match.path must be a /dev path such as /dev/nvme0n1.') - else: - rule[key] = value.strip() - for key in ('min_size', 'max_size'): - if key in match: - try: - rule[key] = parse_size(match[key]) - except (ValueError, TypeError): - errors.append(f'disk.match.{key} must be a size such as 500GB or 465GiB.') - if 'min_size' in rule and 'max_size' in rule and rule['min_size'] > rule['max_size']: - errors.append('disk.match.min_size is larger than disk.match.max_size.') - if len(errors) == before and not set(rule) & {'serial', 'wwn', 'model', 'path'}: - # A size range alone selects whatever disk happens to fit. - errors.append('disk.match needs serial, wwn, model or path, not only a size.') - - encryption = section('encryption', required=True) - unknown(encryption, ('enabled', 'passphrase_file'), 'encryption') - encrypted = boolean(encryption, 'enabled', True, 'encryption') if encryption else True - if encrypted and encryption: - passphrase = secret_file(encryption, 'passphrase_file', 'encryption', 12, 512) - if passphrase is not None: - secrets['passphrase'] = passphrase - elif encryption.get('passphrase_file') is not None: - errors.append('encryption.passphrase_file is only used with "enabled: true".') - - account = section('account', required=not oem) - username, full_name, passwordless_wheel = '', '', False - if oem and account: - errors.append('Remove the "account" section: with "oem: true" the new owner creates the account.') - elif account: - # `password` is refused below with its own explanation. - unknown(account, ('username', 'full_name', 'password', 'password_hash', 'passwordless_sudo'), 'account') - username = account.get('username', '') - if not isinstance(username, str) or not USERNAME.fullmatch(username): - errors.append('account.username must be 1–31 lowercase letters, numbers, underscores or dashes, ' - 'starting with a letter.') - username = '' - full_name = account.get('full_name', '') - if not isinstance(full_name, str) or len(full_name) > 128 or not control_free(full_name) or ':' in full_name: - errors.append('account.full_name must be at most 128 characters without colons.') - full_name = '' - password_hash = account.get('password_hash') - if 'password' in account: - errors.append('account.password is not accepted. Use account.password_hash.') - if not isinstance(password_hash, str) or not PASSWORD_HASH.fullmatch(password_hash): - errors.append('account.password_hash must be a yescrypt ($y$) or SHA-512 ($6$) password hash.') - else: - secrets['password_hash'] = password_hash - passwordless_wheel = boolean(account, 'passwordless_sudo', False, 'account') - - system = section('system') - unknown(system, ('keyboard', 'locale', 'timezone', 'hostname'), 'system') - values = {} - for key, default, pattern in (('keyboard', 'us', r'[A-Za-z0-9_-]+(?: \([A-Za-z0-9_-]+\))?'), - ('locale', 'en_US.UTF-8', r'[A-Za-z][A-Za-z0-9_@.-]{1,63}'), - ('timezone', 'UTC', r'[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+){0,3}')): - value = system.get(key, default) - if not isinstance(value, str) or not re.fullmatch(pattern, value): - errors.append(f'system.{key} is not a valid value.') - value = default - values[key] = value - hostname = system.get('hostname', 'cybexos') - if not isinstance(hostname, str) or not HOSTNAME.fullmatch(hostname): - errors.append('system.hostname must contain 1–63 letters, numbers or dashes.') - hostname = 'cybexos' - - ssh = section('ssh') - unknown(ssh, ('authorized_keys', 'server'), 'ssh') - keys = ssh.get('authorized_keys', []) - if not isinstance(keys, list) or len(keys) > 32 or not all(isinstance(key, str) and SSH_KEY.fullmatch(key.strip()) - for key in keys): - errors.append('ssh.authorized_keys must be a list of up to 32 OpenSSH public keys.') - keys = [] - keys = [key.strip() for key in keys] - ssh_server = boolean(ssh, 'server', False, 'ssh') - if oem and (keys or ssh_server): - errors.append('SSH settings need an account; they cannot be combined with "oem: true".') - - tailscale = section('tailscale') - unknown(tailscale, ('auth_key_file',), 'tailscale') - if tailscale: - key = secret_file(tailscale, 'auth_key_file', 'tailscale', 16, 512) - if key is not None: - if not re.fullmatch(r'tskey-[A-Za-z0-9_-]+', key): - errors.append('tailscale.auth_key_file must hold a Tailscale auth key (tskey-…).') - else: - secrets['tailscale_auth_key'] = key - - try: - config = validate_choices(document.get('config')) - except ValueError as error: - errors.append(str(error) + '.') - config = {} - if tailscale and config.get('features', {}).get('tailscale') is False: - errors.append('A Tailscale auth key needs config.features.tailscale left enabled.') - - countdown = document.get('countdown', 30) - if type(countdown) is not int or not 10 <= countdown <= 600: - errors.append('countdown must be a number of seconds from 10 to 600.') - countdown = 30 - - if errors: - raise SeedError(errors) - settings = dict(oem=oem, encrypted=encrypted, match=rule, username=username, full_name=full_name, - passwordless_wheel=passwordless_wheel, hostname=hostname, - ssh_authorized_keys=keys, ssh_server=ssh_server, tailscale='tailscale_auth_key' in secrets, - config=config, countdown=countdown, **values) - digest = hashlib.sha256(text.encode() if isinstance(text, str) else text).hexdigest() - return Seed(settings, secrets, digest) - - -def normalized(text): - return ' '.join(str(text).split()).casefold() - - -def match_disks(rule, disks, resolve=os.path.realpath): - """Disks matching every key of the rule. The caller requires exactly one.""" - path = resolve(rule['path']) if 'path' in rule else None - found = [] - for disk in disks: - if 'serial' in rule and disk.get('serial', '').strip() != rule['serial']: - continue - if 'wwn' in rule and normalized(disk.get('wwn', '')) != normalized(rule['wwn']): - continue - if 'model' in rule and normalized(disk.get('model', '')) != normalized(rule['model']): - continue - if path is not None and disk.get('path') != path: - continue - if 'min_size' in rule and disk.get('size', 0) < rule['min_size']: - continue - if 'max_size' in rule and disk.get('size', 0) > rule['max_size']: - continue - found.append(disk) - return found - - -def select_disk(rule, disks, excluded=(), resolve=os.path.realpath): - """The one matching disk, or SeedError naming what was found.""" - candidates = [disk for disk in disks if disk.get('path') not in excluded] - found = match_disks(rule, candidates, resolve) - if len(found) == 1: - return found[0] - available = '; '.join(f"{disk.get('model') or 'Disk'} {disk.get('path')} " - f"({disk.get('size', 0) / 1024**3:.0f} GiB, serial {disk.get('serial') or 'not reported'})" - for disk in candidates) or 'none' - if not found: - raise SeedError([f'No disk matches disk.match. Available disks: {available}.']) - raise SeedError([f'{len(found)} disks match disk.match; add a serial or wwn so exactly one matches. ' - f'Available disks: {available}.']) - - -def read_regular(directory, name, limit): - """Read a regular, non-symlink file below directory, refusing large ones.""" - descriptor = os.open(directory / name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC) - try: - info = os.fstat(descriptor) - if not stat.S_ISREG(info.st_mode): - raise ValueError('is not a regular file.') - if info.st_size > limit: - raise ValueError('is too large.') - data = os.read(descriptor, limit + 1) - if len(data) > limit: - raise ValueError('is too large.') - return data - finally: - os.close(descriptor) - - -def load_directory(directory): - try: - text = read_regular(directory, SEED_NAME, SEED_LIMIT) - except FileNotFoundError: - return None - except ValueError as error: - raise SeedError([f'{SEED_NAME} {error}']) from None - try: - text = text.decode('utf-8') - except UnicodeError: - raise SeedError([f'{SEED_NAME} must be UTF-8 text.']) from None - return parse(text, lambda name: read_regular(directory, name, FILE_LIMIT)) - - -def seed_devices(by_label=Path('/dev/disk/by-label')): - """Distinct block devices carrying a seed volume label.""" - devices = [] - for label in LABELS: - link = by_label / label - if link.exists(): - device = os.path.realpath(link) - if device not in devices: - devices.append(device) - return devices - - -def parent_disk(device, run=subprocess.run): - """The whole-disk path holding a seed partition, so it is never a target.""" - try: - result = run(['lsblk', '--noheadings', '--nodeps', '--output', 'PKNAME', '--', device], - check=True, capture_output=True, text=True, timeout=8) - except (OSError, subprocess.SubprocessError): - return device - name = result.stdout.strip() - return f'/dev/{name}' if re.fullmatch(r'[A-Za-z0-9_-]+', name) else device - - -def discover(mountpoint, by_label=Path('/dev/disk/by-label'), run=subprocess.run): - """Mount the one seed volume read-only, read it, and unmount it. - - Returns (seed or None, the seed volume's disk path or None). A volume - without cybexos-seed.yml (an unrelated cloud-init CIDATA) is not a seed. - """ - devices = seed_devices(by_label) - if not devices: - return None, None - if len(devices) > 1: - raise SeedError(['More than one seed volume is attached. Attach only one.']) - device = devices[0] - mountpoint.mkdir(mode=0o700, parents=True, exist_ok=True) - try: - run(['mount', '-o', 'ro,nosuid,nodev,noexec', '--', device, str(mountpoint)], - check=True, capture_output=True, timeout=30) - except (OSError, subprocess.SubprocessError): - raise SeedError(['The seed volume could not be mounted read-only.']) from None - try: - seed = load_directory(mountpoint) - finally: - run(['umount', str(mountpoint)], check=False, capture_output=True, timeout=30) - return seed, parent_disk(device, run) - - -def main(argv): - if len(argv) != 2 or argv[0] != 'check': - print('Usage: cybexos-installer-seed check DIRECTORY', file=sys.stderr) - return 2 - try: - seed = load_directory(Path(argv[1])) - except SeedError as error: - for message in error.errors: - print(f'error: {message}', file=sys.stderr) - return 1 - if seed is None: - print(f'error: {argv[1]} has no {SEED_NAME}.', file=sys.stderr) - return 1 - print(json.dumps(seed.summary(), indent=2, sort_keys=True)) - return 0 - - -if __name__ == '__main__': - raise SystemExit(main(sys.argv[1:])) diff --git a/image/live-rootfs/usr/libexec/cybexos-installer-target b/image/live-rootfs/usr/libexec/cybexos-installer-target deleted file mode 100755 index 4bf5b9f6..00000000 --- a/image/live-rootfs/usr/libexec/cybexos-installer-target +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/python3 -"""Finalize only a mounted Anaconda target; no passwords are transferred. - -`--stage` runs before the in-target hook provisions the account: it records -explicit first-account choices from an unattended seed. The default final -step verifies the target and writes its login, sudo, OEM and recovery state. -The only credential it moves is a seed's Tailscale auth key, from root-only -memory-backed /run into a root-only first-boot file that is deleted after use. -""" -import json -import os -from pathlib import Path -import re -import subprocess -import sys - -STATE = Path('/run/cybexos-installer/state.json') -STAGED = Path('/run/cybexos-installer/staged') -FIRST_BOOT = 'var/lib/cybexos/first-boot' -OS_PROBER_BLOCK = re.compile(r'^### BEGIN /etc/grub\.d/30_os-prober ###$(.*?)^### END /etc/grub\.d/30_os-prober ###$', - re.MULTILINE | re.DOTALL) - - -def btrfs_members(output): - """Keep the complete-device check aligned with cybexos-login-prepare.""" - totals = re.findall(r'^\s*Total devices ([0-9]+) FS bytes used [0-9]+\s*$', output, re.MULTILINE) - devices = re.findall(r'^\s*devid\s+([0-9]+)\s+size\s+[0-9]+\s+used\s+[0-9]+\s+path\s+(/dev/\S+)\s*$', - output, re.MULTILINE) - headers = re.findall(r'^Label:.*\buuid: [0-9a-fA-F-]{36}\s*$', output, re.MULTILINE) - if (len(headers) != 1 or len(totals) != 1 or not devices or - len(devices) != int(totals[0]) or - len({device[0] for device in devices}) != len(devices) or - len({device[1] for device in devices}) != len(devices) or - 'missing' in output.lower()): - raise ValueError('The complete Btrfs device set could not be verified') - return [device[1] for device in devices] - - -def root_is_encrypted(root, run=subprocess.run): - mounts = json.loads(run(['findmnt', '--json', '--mountpoint', str(root), '--output', 'SOURCE,TARGET,FSTYPE'], - check=True, capture_output=True, text=True, timeout=15).stdout)['filesystems'] - if len(mounts) != 1: - raise RuntimeError('The Anaconda target mount could not be verified.') - mount = mounts[0] - if Path(mount['target']).resolve() != root.resolve(): - raise RuntimeError('The Anaconda target is not a separate mounted filesystem.') - source = mount['source'].split('[', 1)[0] - if not source.startswith('/dev/'): - raise RuntimeError('The Anaconda target has an unsupported root device.') - try: - members = (btrfs_members(run(['btrfs', 'filesystem', 'show', '--raw', str(root)], - check=True, capture_output=True, text=True, timeout=15).stdout) - if mount['fstype'] == 'btrfs' else [source]) - except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError): - return False - def encrypted(nodes): - # Every backing path must cross an encrypted mapper. An LVM/RAID root - # with one encrypted and one plaintext backing device is not protected. - return bool(nodes) and all( - item.get('type') == 'crypt' or encrypted(item.get('children', [])) - for item in nodes - ) - try: - return all(encrypted(json.loads(run(['lsblk', '--inverse', '--tree', '--json', '--output', 'TYPE', member], - check=True, capture_output=True, text=True, timeout=15).stdout)['blockdevices']) - for member in members) - except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError): - return False - - -def record_install_choices(root, username, autologin, passwordless_wheel): - """Keep the saved installation choices in step with the verified decision. - - Provisioning inside the target recorded config.yml before encryption could - be verified from outside it; update only the confirmed policy lines. - """ - path = root / 'etc/cybexos/config.yml' - text = path.read_text() - if not re.search(r"^primary_user: '" + re.escape(username) + "'$", text, re.MULTILINE): - raise RuntimeError('The installed configuration does not match the selected account.') - updated = text - for key, choice in (('desktop_autologin', autologin), ('passwordless_wheel', passwordless_wheel)): - pattern = rf'^{key}: (?:true|false)$' - if len(re.findall(pattern, updated, re.MULTILINE)) != 1: - raise RuntimeError(f'The installed configuration has no unique {key} choice.') - updated = re.sub(pattern, f'{key}: {str(choice).lower()}', updated, count=1, flags=re.MULTILINE) - temporary = path.with_name('.config.yml.cybexos-installer') - temporary.write_text(updated) - temporary.chmod(0o644) - temporary.replace(path) - - -def apply_sudo_policy(root, passwordless_wheel): - path = root / 'etc/sudoers.d/10-wheel-nopasswd' - if not passwordless_wheel: - path.unlink(missing_ok=True) - return - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name('.10-wheel-nopasswd.cybexos-installer') - temporary.write_text('%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n') - temporary.chmod(0o440) - temporary.replace(path) - - -def write_file(path, text, mode, directory_mode=0o755): - path.parent.mkdir(mode=directory_mode, parents=True, exist_ok=True) - temporary = path.with_name('.' + path.name + '.cybexos-installer') - descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, mode) - with os.fdopen(descriptor, 'w') as stream: - stream.write(text) - os.chmod(temporary, mode) - temporary.replace(path) - - -def stage(root, policy): - """Record a seed's explicit choices for the first account's config.yml. - - cybexos-config merges them through the shared installation policy when - the in-target hook (or the new-owner setup) records the configuration. - """ - config = policy.get('account', {}).get('config') or {} - if not isinstance(config, dict): - raise RuntimeError('Installation policy has invalid configuration choices.') - if config: - write_file(root / 'etc/cybexos/install-choices.json', - json.dumps({'version': 1, 'choices': config}, sort_keys=True) + '\n', 0o644) - - -def desktop_accounts(root): - rows = [line.split(':') for line in (root / 'etc/passwd').read_text().splitlines() if line.count(':') >= 6] - return [row for row in rows if 1000 <= int(row[2]) < 65534 and row[5].startswith('/home/')] - - -def install_authorized_keys(root, username, keys): - """Seed-provided public keys for the new account, owned by that account.""" - row = next(row for row in desktop_accounts(root) if row[0] == username) - uid, gid = int(row[2]), int(row[3]) - home = root / row[5].lstrip('/') - ssh = home / '.ssh' - if home.is_symlink() or ssh.is_symlink(): - raise RuntimeError('The new account has an unexpected SSH directory.') - ssh.mkdir(mode=0o700, exist_ok=True) - os.chown(ssh, uid, gid) - path = ssh / 'authorized_keys' - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, 'a') as stream: - stream.write(''.join(key + '\n' for key in keys)) - os.chown(path, uid, gid) - os.chmod(path, 0o600) - return ssh - - -def stage_first_boot_secrets(root, staged): - """Move a staged Tailscale key into the target for its single first-boot use.""" - source = staged / 'tailscale-authkey' - if not source.is_file(): - return False - write_file(root / FIRST_BOOT / 'tailscale-authkey', source.read_text(), 0o600, 0o700) - os.chmod(root / FIRST_BOOT, 0o700) - source.unlink() - return True - - -def other_systems_in_menu(root): - """Whether grub2-mkconfig's os-prober step added another system's entry.""" - try: - text = (root / 'boot/grub2/grub.cfg').read_text(errors='replace') - except OSError: - return False - block = OS_PROBER_BLOCK.search(text) - return bool(block and re.search(r'^\s*menuentry ', block.group(1), re.MULTILINE)) - - -def show_boot_menu(root, run=subprocess.run): - # A hidden menu would leave the other system reachable only through the - # firmware. Fedora's grubenv flag is per installation, so clear it here. - run(['chroot', str(root), 'grub2-editenv', '-', 'unset', 'menu_auto_hide'], check=False, - capture_output=True, timeout=60) - - -def finalize(root, policy, encrypted, staged=STAGED, run=subprocess.run): - account = policy.get('account', {}) - oem = account.get('oem', False) - if type(oem) is not bool: - raise RuntimeError('Installation policy does not name a valid new-owner choice.') - username = account.get('username', '') - if oem: - if username or desktop_accounts(root): - raise RuntimeError('A new-owner installation must not contain an account yet.') - elif not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', username): - raise RuntimeError('Installation policy does not name a valid account.') - if not isinstance(account.get('encrypted'), bool) or not isinstance(encrypted, bool): - raise RuntimeError('Installation policy does not name a valid encryption choice.') - if type(account.get('passwordless_wheel', False)) is not bool: - raise RuntimeError('Installation policy does not name a valid sudo choice.') - passwordless_wheel = account.get('passwordless_wheel', False) - if not oem and not any(row[0] == username for row in desktop_accounts(root)): - raise RuntimeError('The installed administrator account is missing.') - root_entry = next((line.split(':')[1] for line in (root / 'etc/shadow').read_text().splitlines() if line.startswith('root:')), '') - if not root_entry.startswith(('!', '*')): - raise RuntimeError('The root account was not locked by Anaconda.') - if account.get('encrypted') and not encrypted: - raise RuntimeError('The requested encrypted root could not be verified; automatic login was not enabled.') - autologin = account['encrypted'] and encrypted and not oem - path = root / 'etc/cybexos/login.json' - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({'version': 1, 'user': username, - 'autologin': autologin, 'live': False}) + '\n') - path.chmod(0o644) - if oem: - # The new-owner setup reads this before the first login; it creates the - # account, records config.yml and applies the sudo choice, then removes it. - write_file(root / 'etc/cybexos/oem.json', - json.dumps({'version': 1, 'passwordless_wheel': passwordless_wheel}) + '\n', 0o644) - apply_sudo_policy(root, False) - else: - record_install_choices(root, username, autologin, passwordless_wheel) - apply_sudo_policy(root, passwordless_wheel) - keys = account.get('ssh_authorized_keys') or [] - if keys: - install_authorized_keys(root, username, keys) - if account.get('ssh_server') is True: - run(['systemctl', f'--root={root}', 'enable', 'sshd.service'], check=True, - capture_output=True, timeout=60) - # The prepared live /etc/sddm.conf can be copied with the live filesystem. - # The installed boot must regenerate it from its own verified policy. - (root / 'etc/sddm.conf').unlink(missing_ok=True) - (root / 'run/cybexos-live-session').unlink(missing_ok=True) - (root / 'var/lib/sddm/state.conf').unlink(missing_ok=True) - tailscale = stage_first_boot_secrets(root, staged) - alongside = policy.get('layout', {}).get('mode') == 'alongside' - if alongside: - show_boot_menu(root, run) - # The first boot takes the factory recovery point that `cybex factory-reset` - # restores, before any login and after moving the key above out of the disk. - write_file(root / 'var/lib/cybexos/factory-point.pending', '{"version": 1}\n', 0o644) - record = root / 'etc/cybexos/installation.json' - record.parent.mkdir(parents=True, exist_ok=True) - details = {'username': username, 'encrypted': encrypted, - 'autologin': autologin, - 'passwordless_wheel': passwordless_wheel, - 'passwordsInitiallyShared': not oem and not account.get('unattended', False), - 'keyring': 'encrypted-boot-passphrase-or-prompt', - 'oem': oem, 'unattended': account.get('unattended', False) is True, - 'layout': 'alongside' if alongside else 'erase', 'tailscaleEnrollment': tailscale} - if alongside: - details['otherSystemsInBootMenu'] = other_systems_in_menu(root) - record.write_text(json.dumps(details) + '\n') - record.chmod(0o644) - - -def main(argv=None): - arguments = sys.argv[1:] if argv is None else argv - if arguments not in ([], ['--stage']): - raise SystemExit('Usage: cybexos-installer-target [--stage]') - if os.geteuid() != 0: - raise SystemExit('Target finalization requires root.') - root = Path('/mnt/sysroot') - if not STATE.exists(): - return # Stock Advanced installation retains the stock login policy. - policy = json.loads(STATE.read_text()) - if policy.get('phase') != 'installing': - return - if arguments == ['--stage']: - stage(root, policy) - return - finalize(root, policy, root_is_encrypted(root)) - username = policy.get('account', {}).get('username', '') - labelled = ['/etc/cybexos', '/var/lib/cybexos'] - if username and (root / 'home' / username / '.ssh').is_dir(): - labelled.append(f'/home/{username}/.ssh') - subprocess.run(['chroot', str(root), 'restorecon', '-RF', *labelled], check=True) - - -if __name__ == '__main__': - main() diff --git a/image/live-rootfs/usr/libexec/cybexos-live-setup b/image/live-rootfs/usr/libexec/cybexos-live-setup deleted file mode 100755 index 3e855456..00000000 --- a/image/live-rootfs/usr/libexec/cybexos-live-setup +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -# This runs in the writable live overlay, never during image construction. -grep -qw rd.live.image /proc/cmdline || exit 0 -touch /run/cybexos-live -# The "Install CybexOS" boot entry opens only the installer: the session -# starts cybexos-install-session.target instead of the full desktop. -if tr ' ' '\n' /dev/null 2>&1; then - useradd --create-home --uid 1000 --groups wheel,docker --shell /bin/bash liveuser - passwd --delete liveuser -fi -install -d /var/lib/AccountsService/users -cat > /var/lib/AccountsService/users/liveuser <<'ACCOUNT' -[User] -Session=hyprland-quickshell -XSession=hyprland-quickshell -SystemAccount=false -ACCOUNT -install -d -m 0755 /etc/cybexos -cat > /etc/cybexos/login.json <<'LOGIN' -{"version":1,"user":"liveuser","autologin":true,"live":true} -LOGIN -printf '%s\n' liveuser > /run/cybexos-live-session -chmod 0644 /etc/cybexos/login.json /run/cybexos-live-session -# The permission exists only in this boot's live overlay. It is explicitly -# removed by the Anaconda post-install script as an additional safeguard. -cat > /etc/sudoers.d/cybexos-live <<'SUDO' -liveuser ALL=(ALL) NOPASSWD: ALL -SUDO -chmod 0440 /etc/sudoers.d/cybexos-live -cat > /etc/polkit-1/rules.d/49-cybexos-live.rules <<'POLKIT' -polkit.addRule(function(action, subject) { - if (subject.user == "liveuser" && subject.local && subject.active && - action.id == "org.fedoraproject.pkexec.liveinst") { - return polkit.Result.YES; - } -}); -POLKIT -restorecon -RF /home/liveuser /etc/cybexos /var/lib/AccountsService/users /etc/sudoers.d diff --git a/image/live-rootfs/usr/share/anaconda/interactive-defaults.ks b/image/live-rootfs/usr/share/anaconda/interactive-defaults.ks deleted file mode 100644 index c86405a2..00000000 --- a/image/live-rootfs/usr/share/anaconda/interactive-defaults.ks +++ /dev/null @@ -1,4 +0,0 @@ -# Defaults for interactive installation only: no storage or account answers. -# Fedora 44 executes scripts through the Runtime DBus module, which reads -# this file before the legacy post-scripts loader updates its local ksdata. -%include /usr/share/anaconda/post-scripts/90-cybexos.ks diff --git a/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks b/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks deleted file mode 100644 index 7372355a..00000000 --- a/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks +++ /dev/null @@ -1,95 +0,0 @@ -%post --nochroot --erroronfail --log=/var/log/anaconda/cybexos-stage.log -set -eu -# Anaconda runs %post scripts in this file's order. This first one records an -# unattended seed's explicit choices in the target before the in-target hook -# below records config.yml from them. Nothing here is a credential. -/usr/libexec/cybexos-installer-target --stage -%end - -%post --erroronfail --log=/var/log/anaconda/cybexos-cleanup.log -set -eu -# These are exact live-image-owned paths, not user customization directories. -if getent passwd liveuser >/dev/null; then - userdel --remove liveuser -fi -rm -f /etc/sudoers.d/cybexos-live -# The live filesystem can contain the image build's wheel policy. Every fresh -# target starts with password-required sudo; the confirmed guided choice is -# applied by the target helper after offline provisioning. -rm -f /etc/sudoers.d/10-wheel-nopasswd -rm -f /etc/polkit-1/rules.d/49-cybexos-live.rules -rm -f /var/lib/AccountsService/users/liveuser -rm -f /etc/systemd/system/multi-user.target.wants/cybexos-live.service -rm -f /usr/lib/systemd/system/cybexos-live.service -rm -f /usr/libexec/cybexos-live-setup -rm -f /usr/lib/systemd/user/cybexos-install-session.target /usr/lib/systemd/user/cybexos-install-welcome.service -rm -f /usr/libexec/cybexos-installer-backend /usr/libexec/cybexos-installer-target -rm -f /usr/libexec/cybexos-installer-seed -rm -f /usr/bin/cybexos-installer-browser -rm -rf /usr/share/cockpit/cybexos-installer -rm -f /etc/anaconda/conf.d/20-cybexos.conf -rm -f /etc/dracut.conf.d/99-live.conf -rm -f /etc/dracut.conf.d/99-liveos.conf -rm -f /etc/ssh/ssh_host_* -# Never copy a live autologin decision to the installed system. The final -# target helper can opt in only after verifying the installed encrypted root. -rm -f /run/cybexos-live-session /etc/sddm.conf -rm -f /var/lib/sddm/state.conf -install -d -m 0755 /etc/cybexos -cat > /etc/cybexos/login.json <<'LOGIN' -{"version":1,"user":"","autologin":false,"live":false} -LOGIN -chmod 0644 /etc/cybexos/login.json -systemctl disable sshd.service -systemctl enable sddm.service NetworkManager.service firewalld.service -systemctl set-default graphical.target -# Anaconda adds SSH to its selected firewall zone unless explicitly disabled. -# Restore the shipped CybexOS policy after its configuration task has finished. -install -D -m 0644 /usr/lib/firewalld/zones/cybexos.xml /etc/firewalld/zones/cybexos.xml -# The live image already defaults to this zone, and firewalld reports setting -# the current default again as an error (ZONE_ALREADY_SET, exit 16). -if [ "$(firewall-offline-cmd --get-default-zone)" != cybexos ]; then - firewall-offline-cmd --set-default-zone=cybexos -fi -# Retain desktop metadata for account-aware applications. SDDM's prepared -# configuration selects the shared CybexOS session independently. -python3 - <<'PY' -import configparser -from pathlib import Path -import pwd -directory = Path('/var/lib/AccountsService/users') -directory.mkdir(parents=True, exist_ok=True) -for account in pwd.getpwall(): - if not (1000 <= account.pw_uid < 65534 and account.pw_dir.startswith('/home/')): - continue - path = directory / account.pw_name - settings = configparser.ConfigParser() - settings.optionxform = str - settings.read(path) - if not settings.has_section('User'): - settings.add_section('User') - settings['User']['Session'] = 'hyprland-quickshell' - settings['User']['XSession'] = 'hyprland-quickshell' - settings['User']['SystemAccount'] = 'false' - with path.open('w') as stream: - settings.write(stream) - path.chmod(0o600) -PY -restorecon -RF /etc/cybexos /var/lib/AccountsService/users -# Provisioning first records the account's installation choices, never a -# password, in /etc/cybexos/config.yml; the target helper below updates its -# autologin choice after verifying encryption. `cybex configure` changes them. -/usr/libexec/cybexos-configure-installed --offline --installer-target -restorecon -RF /etc/cybexos -/usr/libexec/cybexos-seed-installed-users -# Anaconda's initial initramfs was created before this post script removed the -# live-only dracut settings. Rebuild from the final installed configuration. -dracut --force --regenerate-all -%end - -%post --nochroot --erroronfail --log=/var/log/anaconda/cybexos-target-policy.log -set -eu -# Reads only the confirmed account/encryption policy from live /run. The helper -# verifies that the mounted target root is encrypted before enabling autologin. -/usr/libexec/cybexos-installer-target -%end diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/cybex-wordmark.svg b/image/live-rootfs/usr/share/cockpit/cybexos-installer/cybex-wordmark.svg deleted file mode 100644 index be3df348..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/cybex-wordmark.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Cybex wordmark — for dark backgrounds - - - - - - - - diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html b/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html deleted file mode 100644 index cfeac41f..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - Install CybexOS - - - - - - -
- -
Starting the installer…
- - - - - -
- Installation details -

Follow what the installer is doing. These logs contain no passwords.

- -

-    
-    
-

-
- -
- - diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css deleted file mode 100644 index 029581fa..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css +++ /dev/null @@ -1,194 +0,0 @@ -@font-face { - font-family: Figtree; - src: local(Figtree); -} - -:root { - /* Keep the palette aligned with the Cybex Welcome screen. */ - color-scheme: dark; - font: 16px Figtree, Inter, system-ui, sans-serif; - --background: #0e0e10; - --ink: #f4f4f5; - --muted: #b4b4bb; - --faint: #80808a; - --surface: #17171a; - --surface-hover: #202024; - --line: #2a2a30; - --accent: #dd0034; - --accent-hover: #f0164a; - --accent-down: #b8002b; - --accent-text: #ff6b86; - color: var(--ink); - background: var(--background); -} - -* { box-sizing: border-box; } -body { - margin: 0; - min-height: 100vh; - display: grid; - grid-template-columns: minmax(265px, 33%) 1fr; -} -aside { - padding: 40px; - display: flex; - flex-direction: column; - background: var(--surface); - border-right: 1px solid var(--line); -} -.wordmark { display: block; width: auto; height: 30px; align-self: flex-start; } -h1 { - font-size: clamp(34px, 3.8vw, 54px); - line-height: 1.05; - font-weight: 600; - letter-spacing: -.035em; - margin: 64px 0 18px; -} -aside p { line-height: 1.6; color: var(--muted); } -ol { padding: 0; list-style: none; margin: 30px 0 50px; } -li { margin: 17px 0; } -#steps li { color: var(--muted); font-size: 12px; letter-spacing: .08em; } -#steps span { font-size: 15px; letter-spacing: 0; margin-left: 12px; } -#steps [aria-current] { color: var(--accent-text); } -aside small { font-size: 10px; letter-spacing: .16em; margin-top: auto; color: var(--faint); } -main { - width: 100%; - min-width: 0; - max-width: 950px; - padding: 52px clamp(28px, 5vw, 84px); - align-self: center; -} -.eyebrow { letter-spacing: .1em; font-size: 12px; font-weight: 600; color: var(--faint); } -h2 { - font-size: clamp(30px, 3.4vw, 38px); - letter-spacing: -.035em; - line-height: 1.12; - font-weight: 600; - margin: 16px 0; -} -p { color: var(--muted); line-height: 1.55; } -label { display: block; margin: 20px 0 0; font-size: 14px; } -input, select { - display: block; - border: 1px solid var(--line); - background: var(--surface); - color: var(--ink); - border-radius: 12px; - width: 100%; - min-width: 0; - min-height: 48px; - padding: 10px 12px; - font: inherit; - margin-top: 8px; -} -input::placeholder { color: var(--faint); opacity: 1; } -input:hover, select:hover { border-color: var(--faint); } -input:focus, select:focus, button:focus-visible, a:focus-visible, summary:focus-visible { - outline: 2px solid var(--ink); - outline-offset: 3px; -} -.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.hint { font-size: 13px; color: var(--muted); } -.card { - background: var(--surface); - border: 1px solid var(--line); - border-radius: 16px; - padding: 23px; - margin: 24px 0; -} -.card p { margin-bottom: 0; font-size: 14px; } -#disk-details { white-space: pre-line; line-height: 1.6; overflow-wrap: anywhere; } -#rescan-disks { margin-top: 12px; } -#failure-help button { margin-top: 12px; } -details { border-top: 1px solid var(--line); margin: 24px 0; padding-top: 16px; } -summary { cursor: pointer; font-size: 14px; color: var(--muted); } -button, a { font: inherit; } -button { - cursor: pointer; - border: 0; - border-radius: 12px; - min-height: 48px; - padding: 12px 26px; - font-weight: 600; -} -.primary { background: var(--accent); color: #fff; } -.primary:hover { background: var(--accent-hover); } -.primary:active { background: var(--accent-down); } -.secondary { background: var(--surface); color: var(--ink); } -.secondary:hover { background: var(--surface-hover); } -button:disabled { opacity: .5; } -footer { - display: flex; - flex-wrap: wrap; - gap: 16px; - margin-top: 30px; - padding-top: 20px; - border-top: 1px solid var(--line); - justify-content: flex-end; -} -.check { display: flex; gap: 12px; align-items: flex-start; line-height: 1.5; } -.check input { - flex: 0 0 19px; - width: 19px; - height: 19px; - min-height: 0; - margin: 3px 0 0; - accent-color: var(--accent); -} -.erase { padding: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; } -dl { display: grid; grid-template-columns: 130px 1fr; gap: 14px; padding: 24px; background: var(--surface); border-radius: 16px; } -dt { color: var(--muted); font-size: 14px; } -dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; } -#error { padding: 17px 20px; border: 1px solid var(--accent-text); background: var(--surface); color: var(--accent-text); border-radius: 12px; margin-bottom: 22px; } -#busy { padding: 20px; color: var(--muted); } -#actions { padding-left: 18px; font-size: 13px; color: var(--muted); } -#warnings { font-size: 13px; color: var(--accent-text); } -progress { width: 100%; height: 10px; margin-top: 34px; accent-color: var(--accent); } -a { color: var(--accent-text); display: inline-block; margin: 18px; } -#startup-retry { margin-top: 20px; } -fieldset { min-width: 0; } -legend { padding: 0 6px; font-weight: 600; } -fieldset .check { margin-top: 14px; } -#alongside-details { margin-top: 14px; font-size: 14px; color: var(--muted); white-space: pre-line; } -#space { accent-color: var(--accent); padding: 0; min-height: 32px; border: 0; background: transparent; } -#seed-errors { color: var(--accent-text); padding-left: 18px; line-height: 1.5; } -#countdown { font-size: 22px; color: var(--ink); font-variant-numeric: tabular-nums; } -#log { - max-height: 45vh; - min-height: 8em; - overflow: auto; - margin: 12px 0 0; - padding: 14px 16px; - background: #09090b; - border: 1px solid var(--line); - border-radius: 12px; - color: var(--muted); - font: 12px/1.5 ui-monospace, "JetBrains Mono", monospace; - white-space: pre-wrap; - overflow-wrap: anywhere; -} -#log-panel footer { margin-top: 16px; padding-top: 0; border-top: 0; justify-content: flex-start; } -[hidden] { display: none !important; } -button:disabled, input:disabled, select:disabled { cursor: wait; } - -@media (max-width: 800px) { - body { grid-template-columns: 1fr; } - aside { padding: 24px; border-right: 0; border-bottom: 1px solid var(--line); } - aside h1, aside p, aside small { display: none; } - ol { display: flex; flex-wrap: wrap; gap: 12px 20px; margin: 24px 0 0; } - #steps li { margin: 0; } - #steps span { font-size: 12px; margin-left: 5px; } - main { padding: 30px 22px; } - .pair { grid-template-columns: 1fr; } -} -@media (max-width: 480px) { - dl { grid-template-columns: 1fr; gap: 8px; } - dd + dt { margin-top: 8px; } -} -@media (prefers-reduced-motion: no-preference) { - section { animation: arrive .22s ease-out; } - @keyframes arrive { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } - } -} diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js deleted file mode 100644 index 00427ecb..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js +++ /dev/null @@ -1,516 +0,0 @@ -import {Wizard, accountName, size, systemsLabel, clampSpace, shrinkFor, countdownText, reviewFields, confirmationCopy} from "./model.mjs"; -const wizard = new Wizard(); -const $ = id => document.getElementById(id); -const ids = ["setup", "location", "review", "progress", "unattended"]; -let initializing = true; -let polling; -let installationComplete = false; -let appliedKeyboard = null; -let timezoneChosen = false; -let availableDisks = []; -// The alongside analysis for the selected disk: {disk, options} or {disk, error}. -let alongside = null; -let countdown = null; -let seedDigest = null; -let logTimer; -let logPending = false; - -function fail(error) { - $("error").textContent = error.message || "The installer could not complete this operation."; - $("error").hidden = false; -} -function render() { - ids.forEach(id => { $(id).hidden = initializing || wizard.page !== id; }); - document.querySelectorAll("#steps li").forEach((item, index) => { - item.removeAttribute("aria-current"); - if (index === ids.indexOf(wizard.page)) item.setAttribute("aria-current", "step"); - }); - // The log panel stays usable while an operation runs; that is when it helps. - document.querySelectorAll("button, input, select").forEach(item => { item.disabled = wizard.busy && !item.closest("#log-panel"); }); - $("install").disabled = wizard.busy || !$("erase").checked; - ["password", "confirm", "keyboard-test"].forEach(id => { $(id).disabled = wizard.busy || appliedKeyboard !== $("keyboard").value; }); - // Cancelling a countdown must always be possible. - $("seed-cancel").disabled = !countdown && wizard.busy; - $("busy").hidden = !initializing && !wizard.busy; -} -function call(command, data = {}, onProgress = () => {}) { - return new Promise((resolve, reject) => { - let buffer = "", result; - const process = cockpit.spawn(["/usr/libexec/cybexos-installer-backend", command], - {superuser: "require", err: "message"}); - process.stream(chunk => { - buffer += chunk; - let index; - while ((index = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); - try { - const event = JSON.parse(line); - if (event.event === "result") result = event; - if (event.event === "progress") onProgress(event); - } catch { /* Ignore non-protocol diagnostics without displaying them. */ } - } - }); - const finish = () => result?.ok ? resolve(result.data) - : reject(new Error(result?.error || "The installer connection was interrupted. Refresh its status before continuing.")); - process.then(finish, finish); - process.input(JSON.stringify(data), false); - }); -} -async function run(message, work) { - $("error").hidden = true; - $("busy").textContent = message; - try { - const promise = wizard.operation(work); - render(); - await promise; - } catch (error) { fail(error); } - finally { render(); } -} -function options(id, values, selected) { - $(id).replaceChildren(...values.map(value => { - const option = document.createElement("option"); - option.value = typeof value === "object" ? value.id : value; - option.textContent = typeof value === "object" ? value.label : value; - return option; - })); - if (selected) $(id).value = selected; -} -function definitions(id, fields) { - $(id).replaceChildren(...fields.flatMap(([key, value]) => { - const term = document.createElement("dt"), detail = document.createElement("dd"); - term.textContent = key; detail.textContent = value; return [term, detail]; - })); -} -function partitionSummary(disk) { - if (!disk.partitions?.length) return "No existing partitions reported"; - return disk.partitions.map(partition => - `${partition.path || "Partition"} (${size(partition.size)}${partition.filesystem ? `, ${partition.filesystem}` : ""})`).join("; "); -} -function diskIdentity(disk) { - return [`${disk.model} · ${disk.path} · ${size(disk.size)}`, - `Serial: ${disk.serial || "Not reported"}`, - `WWN: ${disk.wwn || "Not reported"}`, - `Existing partitions: ${partitionSummary(disk)}`].join("\n"); -} -function selectedLayout() { - return !$("layout-choice").hidden && $("layout-alongside").checked && alongside?.options - && alongside.disk === $("disk").value ? "alongside" : "erase"; -} -function showLayout() { - const layout = selectedLayout(); - $("location-intro").textContent = layout === "alongside" - ? "CybexOS is installed next to the existing system. Everything else on the disk is kept." - : "Choose a disk for CybexOS. The entire selected disk will be erased."; - const choice = alongside?.options; - $("space-field").hidden = layout !== "alongside" || choice?.mode !== "shrink"; - if (alongside?.error) $("alongside-details").textContent = alongside.error; - else if (!choice) $("alongside-details").textContent = "Checking the free space on this disk…"; - else if (choice.mode === "free") - $("alongside-details").textContent = `CybexOS uses ${size(choice.space)} of free space. No partition is resized.`; - else { - const space = clampSpace(choice, $("space").value); - const shrink = shrinkFor(choice, space); - $("space-label").textContent = `Space for CybexOS: ${size(space)}`; - $("alongside-details").textContent = - `There is not enough free space, so ${choice.partition.path} is shrunk from ` + - `${size(choice.partition.size)} to ${size(shrink.size)}. Back up that system first.`; - } -} -function showSelectedDisk() { - const disk = availableDisks.find(item => item.name === $("disk").value); - $("disk-details").textContent = disk ? diskIdentity(disk) : "Choose a disk to see its identity and existing partitions."; - alongside = null; - const systems = disk?.systems || []; - $("layout-choice").hidden = !systems.length; - if (systems.length) { - const label = systemsLabel(systems); - $("layout-legend").textContent = `This disk already has ${label}`; - $("alongside-label").textContent = `Install alongside ${label}`; - $("layout-alongside").checked = true; - const name = disk.name; - run("Checking the free space on this disk…", async () => { - try { - const options = await call("alongside", {disk: name}); - alongside = {disk: name, options}; - if (options.mode === "shrink") { - $("space").min = options.min_space; $("space").max = options.max_space; - $("space").value = options.default_space; - } - } catch (error) { - // Installing alongside is unavailable on this disk; erasing still is. - alongside = {disk: name, error: error.message}; - $("layout-erase").checked = true; - } - showLayout(); - }); - } - showLayout(); -} -function showDisks(disks) { - availableDisks = disks; - options("disk", [{id: "", label: "Select a disk…"}, ...disks.map(disk => ({ - id: disk.name, label: `${disk.model} — ${size(disk.size)} — ${disk.path}${disk.removable ? " (removable)" : ""}` - }))]); - $("disk").value = ""; - showSelectedDisk(); -} -function timezoneOptions(zones, selected) { - const groups = new Map(); - for (const zone of zones) { - const [region, ...place] = zone.split("/"); - const option = document.createElement("option"); - option.value = zone; - option.textContent = place.length ? place.join(" / ").replaceAll("_", " ") : zone; - if (!place.length) { groups.set(zone, option); continue; } - if (!groups.has(region)) { - const group = document.createElement("optgroup"); - group.label = region; - groups.set(region, group); - } - groups.get(region).append(option); - } - $("timezone").replaceChildren(...groups.values()); - $("timezone").value = selected; -} -function showDetectedTimezone(zone) { - $("timezone").value = zone; - $("timezone-status").textContent = "Detected from your network location."; -} -function detectTimezone(zones) { - // Geolocation waits for the network; never hold setup up for it. - call("geolocate").then(result => { - if (!timezoneChosen && zones.includes(result.timezone)) showDetectedTimezone(result.timezone); - }, () => {}); -} -function waitForBackend() { - return new Promise((resolve, reject) => { - const ready = cockpit.file("/run/anaconda/backend_ready"); - const timeout = setTimeout(() => { - ready.close(); reject(new Error("Anaconda is taking longer than expected to start. Try again or open the full installer.")); - }, 120000); - ready.watch(content => { - if (content !== null) { clearTimeout(timeout); ready.close(); resolve(); } - }); - }); -} -function showOwnerMode() { - const oem = $("oem").checked; - $("username-field").hidden = oem; - $("username").required = !oem; - $("password-label").textContent = oem ? "Temporary disk passphrase" : "Password"; - $("confirm-label").textContent = oem ? "Confirm passphrase" : "Confirm password"; - $("password-hint").textContent = oem - ? "Give this passphrase to the new owner. They type it once at first start, then choose their own password. Without encryption it is not used." - : "Use at least 12 characters. Your chosen layout will also be used at the boot unlock screen."; -} -function account() { - return {...Object.fromEntries(["username", "password", "confirm", "keyboard", "locale", "timezone", "hostname"].map(id => [id, $(id).value])), - passwordless_wheel: $("passwordless-wheel").checked, oem: $("oem").checked}; -} -function showReview(plan) { - wizard.plan = plan; wizard.page = "review"; - $("erase").checked = false; - const copy = confirmationCopy(plan); - $("erase-label").textContent = copy.check; - $("install").textContent = copy.button; - definitions("summary", reviewFields(plan, diskIdentity)); - $("actions").replaceChildren(...plan.actions.map(action => { - const item = document.createElement("li"); - item.textContent = [action["action-description"], action["object-description"], action["device-name"]].filter(Boolean).join(" · "); - return item; - })); - $("warnings").textContent = plan.warnings.join("\n"); -} -function showProgress(data) { - wizard.page = "progress"; - $("progress-message").textContent = data.message || "Anaconda is preparing the installation…"; - if (data.total > 0) { $("meter").max = data.total; $("meter").value = data.step || 0; } - else $("meter").removeAttribute("value"); - if (data.phase === "complete") { - installationComplete = true; - clearInterval(polling); - $("progress-title").textContent = "Your workspace is ready."; - $("meter").max = 1; $("meter").value = 1; $("restart-help").hidden = false; - $("reboot").hidden = false; - } else if (data.phase === "failed-install") { - clearInterval(polling); - $("progress-title").textContent = "Installation needs attention."; - showLog(data.log_source || "steps"); - } - $("failure-help").hidden = data.phase !== "failed-install"; - render(); -} -// Diagnostics bypass run(): they never mark the wizard busy or block a step. -function refreshLog() { - if (logPending || !$("log-panel").open) return; - logPending = true; - call("log", {source: $("log-source").value}).then(data => { - const log = $("log"), following = log.scrollTop + log.clientHeight >= log.scrollHeight - 8; - log.textContent = data.available ? data.text : "This log has not been written yet."; - if (following) log.scrollTop = log.scrollHeight; - }, error => { $("log").textContent = error.message; }).finally(() => { logPending = false; }); -} -function showLog(source) { - if (!$("log-panel").open) { $("log-source").value = source; $("log-panel").open = true; } - refreshLog(); -} -function monitor() { - clearInterval(polling); - polling = setInterval(() => { - if (!wizard.busy) call("status").then(showProgress, fail); - }, 2000); -} -function seedFields(seed) { - const fields = []; - if (seed.disk) fields.push(["Disk", diskIdentity(seed.disk)], ["Disk changes", "Erase the entire disk"]); - fields.push(["Account", seed.oem ? "Created by the new owner at first start" - : `${seed.username}${seed.full_name ? ` (${seed.full_name})` : ""}`]); - fields.push(["Storage", seed.encrypted ? "Encrypted Btrfs · LUKS2" : "Btrfs · unencrypted"]); - fields.push(["Computer", `${seed.hostname} · ${seed.keyboard} · ${seed.locale} · ${seed.timezone}`]); - const extras = [seed.ssh_keys ? `${seed.ssh_keys} SSH key${seed.ssh_keys === 1 ? "" : "s"}` : "", - seed.ssh_server ? "SSH server on" : "", seed.tailscale ? "joins Tailscale at first start" : ""].filter(Boolean); - if (extras.length) fields.push(["Access", extras.join(" · ")]); - return fields; -} -function stopCountdown() { - clearInterval(countdown); - countdown = null; - $("countdown-meter").hidden = true; -} -function showSeedProblem(title, errors, summary) { - stopCountdown(); - wizard.page = "unattended"; - $("unattended-title").textContent = title; - $("unattended-intro").textContent = "Nothing was changed. Fix the seed volume and restart, or use the guided installer."; - definitions("seed-summary", summary ? seedFields(summary) : []); - $("seed-errors").replaceChildren(...errors.map(message => { - const item = document.createElement("li"); item.textContent = message; return item; - })); - $("countdown").textContent = ""; - $("seed-cancel").hidden = true; - $("seed-guided").hidden = false; - render(); -} -function startCountdown(plan) { - // The backend also refuses to install before this time has passed. - const ends = Date.now() + plan.countdown * 1000; - $("countdown-meter").max = plan.countdown; - $("countdown-meter").hidden = false; - const tick = () => { - const remaining = (ends - Date.now()) / 1000; - $("countdown").textContent = countdownText(remaining); - $("countdown-meter").value = Math.max(0, plan.countdown - remaining); - if (remaining > 0) return; - stopCountdown(); - run("Starting the installation…", async () => { - showProgress({phase: "installing"}); - try { - showProgress(await call("install", {token: plan.token, confirmed_disk: plan.disk.name, erase_confirmed: true}, showProgress)); - monitor(); - } catch (error) { - const state = await call("status").catch(() => null); - if (state && ["installing", "complete", "failed-install"].includes(state.phase)) { - showProgress(state); - if (state.phase === "installing") monitor(); - } else showSeedProblem("The installation did not start.", [error.message]); - throw error; - } - }); - }; - countdown = setInterval(tick, 250); - tick(); -} -async function unattended(found) { - wizard.page = "unattended"; - initializing = false; - seedDigest = found.seed?.digest || null; - if (found.cancelled) { - showSeedProblem("Unattended installation cancelled.", [], found.seed); - $("unattended-intro").textContent = "Nothing was changed. Remove the seed volume and restart, or use the guided installer."; - return; - } - if (!found.valid) { - showSeedProblem("The seed needs attention.", found.errors, found.seed); - return; - } - definitions("seed-summary", seedFields(found.seed)); - $("seed-errors").replaceChildren(); - render(); - $("busy").textContent = "Planning the installation with Anaconda…"; - const plan = await call("plan", {seed: found.seed.digest}); - wizard.plan = plan; - const changes = plan.actions.length; - definitions("seed-summary", [...seedFields(found.seed), - ["Anaconda plan", `${changes} planned disk change${changes === 1 ? "" : "s"}, validated`]]); - $("unattended-intro").textContent = "The matched disk will be erased when the countdown ends. Cancel to keep it unchanged."; - startCountdown(plan); -} -async function initialize() { - initializing = true; $("startup-retry").hidden = true; - await run("Starting Anaconda and finding your disks…", async () => { - const state = await call("status"); - if (["installing", "complete", "failed-install"].includes(state.phase)) { - initializing = false; showProgress(state); - if (state.phase === "installing") monitor(); - return; - } - await waitForBackend(); - const data = await call("inventory"); - options("keyboard", data.keyboards, data.keyboard); - options("locale", data.locales, data.locale); - timezoneOptions(data.timezones, data.timezone); - if (data.detected_timezone) showDetectedTimezone(data.detected_timezone); - else detectTimezone(data.timezones); - showDisks(data.disks); - // An attached seed volume replaces the questions with its countdown. - const found = await call("seed"); - if (found.found) { - await unattended(found); - return; - } - initializing = false; - const keyboard = await call("keyboard", {keyboard: $("keyboard").value}); - appliedKeyboard = keyboard.keyboard; - $("keyboard-status").textContent = `Keyboard applied. Boot unlock layout: ${keyboard.boot_keyboard}.`; - }); - if (initializing) { $("busy").hidden = true; $("startup-retry").hidden = false; } -} -$("keyboard").addEventListener("change", () => { - appliedKeyboard = null; - $("password").value = ""; $("confirm").value = ""; $("keyboard-test").value = ""; - run("Applying your keyboard layout…", async () => { - const result = await call("keyboard", {keyboard: $("keyboard").value}); - appliedKeyboard = result.keyboard; - $("keyboard-status").textContent = `Keyboard applied. Boot unlock layout: ${result.boot_keyboard}.`; - }); -}); -$("username").addEventListener("input", () => { - const field = $("username"), name = accountName(field.value); - if (name === field.value) return; - const {selectionStart, selectionEnd} = field; - field.value = name; field.setSelectionRange(selectionStart, selectionEnd); -}); -$("timezone").addEventListener("change", () => { - timezoneChosen = true; - $("timezone-status").textContent = ""; -}); -$("oem").addEventListener("change", showOwnerMode); -$("disk").addEventListener("change", showSelectedDisk); -$("layout-alongside").addEventListener("change", showLayout); -$("layout-erase").addEventListener("change", showLayout); -$("space").addEventListener("input", showLayout); -$("rescan-disks").addEventListener("click", () => run("Rescanning disks with Anaconda…", async () => { - const data = await call("rescan"); - showDisks(data.disks); -})); -$("account-form").addEventListener("submit", event => { - event.preventDefault(); - try { - if (appliedKeyboard !== $("keyboard").value) throw new Error("Apply your keyboard layout before entering your password."); - wizard.account(account()); wizard.move("location"); $("error").hidden = true; render(); - } - catch (error) { fail(error); } -}); -$("disk-form").addEventListener("submit", event => { - event.preventDefault(); - run("Planning your disk layout and checking it with Anaconda…", async () => { - const layout = selectedLayout(); - const data = {...wizard.account(account()), disk: $("disk").value, encrypted: $("encrypted").checked, layout}; - if (layout === "alongside") { - const shrink = shrinkFor(alongside.options, $("space").value); - if (shrink) data.shrink = shrink; - } - wizard.plan = null; - showReview(await call("plan", data)); - }); -}); -document.querySelectorAll(".back").forEach(button => button.addEventListener("click", () => { - try { wizard.move(button.dataset.page); render(); } catch (error) { fail(error); } -})); -$("erase").addEventListener("change", render); -$("install").addEventListener("click", () => { - let confirmation; - try { confirmation = wizard.confirmation($("erase").checked); } catch (error) { fail(error); return; } - run("Installing CybexOS…", async () => { - // Drop browser copies before the destructive transaction starts. - const entered = [$("password").value, $("confirm").value]; - $("password").value = ""; $("confirm").value = ""; - showProgress({phase: "installing"}); - try { showProgress(await call("install", confirmation, showProgress)); monitor(); } - catch (error) { - const state = await call("status").catch(() => null); - if (state && !["installing", "complete", "failed-install"].includes(state.phase)) { - // Rejected before Anaconda started: this review is no longer valid. - // Nothing was written, so keep the entered passwords for a new review. - [$("password").value, $("confirm").value] = entered; - wizard.plan = null; wizard.page = "location"; - } else if (state) { - showProgress(state); - if (state.phase === "installing") monitor(); - } - throw error; - } - }); -}); -$("seed-cancel").addEventListener("click", () => { - stopCountdown(); - run("Cancelling the unattended installation…", async () => { - await call("cancel-seed", {seed: seedDigest}); - showSeedProblem("Unattended installation cancelled.", []); - $("unattended-intro").textContent = "Nothing was changed. Remove the seed volume and restart, or use the guided installer."; - }); -}); -$("seed-guided").addEventListener("click", () => run("Opening the guided installer…", async () => { - stopCountdown(); - await call("reset"); - wizard.plan = null; - wizard.page = "setup"; - const keyboard = await call("keyboard", {keyboard: $("keyboard").value}); - appliedKeyboard = keyboard.keyboard; - $("keyboard-status").textContent = `Keyboard applied. Boot unlock layout: ${keyboard.boot_keyboard}.`; -})); -$("advanced").addEventListener("click", () => run("Opening the full installer…", async () => { - await call("advanced"); - $("password").value = ""; $("confirm").value = ""; - window.location.href = "../anaconda-webui/index.html"; -})); -$("refresh-status").addEventListener("click", () => run("Checking installation status…", async () => showProgress(await call("status")))); -$("save-diagnostics").addEventListener("click", () => run("Preparing redacted diagnostics…", async () => { - const report = await call("diagnostics"); - const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2) + "\n"], {type: "application/json"})); - const link = document.createElement("a"); - link.href = url; link.download = "cybexos-installer-diagnostics.json"; - document.body.append(link); link.click(); link.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); -})); -$("retry").addEventListener("click", initialize); -$("log-panel").addEventListener("toggle", () => { - clearInterval(logTimer); - if (!$("log-panel").open) return; - $("log").scrollTop = $("log").scrollHeight; - refreshLog(); - logTimer = setInterval(refreshLog, 2000); -}); -$("log-source").addEventListener("change", () => { $("log").textContent = ""; refreshLog(); }); -$("find-destinations").addEventListener("click", () => { - $("export-status").textContent = "Searching for USB drives…"; - call("log-destinations").then(data => { - options("log-destination", data.destinations.length - ? data.destinations.map(item => ({id: item.name, - label: `${item.label || item.name} — ${item.fstype} — ${(item.size / 1024**3).toFixed(1)} GiB — ${item.path}`})) - : [{id: "", label: "No writable USB drive found"}]); - $("export-status").textContent = ""; - }, error => { $("export-status").textContent = error.message; }); -}); -$("export-logs").addEventListener("click", () => { - if (!$("log-destination").value) { $("export-status").textContent = "Search for a USB drive first."; return; } - $("export-status").textContent = "Saving logs…"; - call("export-logs", {device: $("log-destination").value}).then( - data => { $("export-status").textContent = `Saved ${data.file} to ${data.label || data.device}. You can remove the drive.`; }, - error => { $("export-status").textContent = error.message; }); -}); -$("reboot").addEventListener("click", () => run("Restarting…", () => call("reboot"))); -window.addEventListener("beforeunload", event => { - if ((wizard.page === "progress" && !installationComplete) || wizard.busy || countdown) { event.preventDefault(); event.returnValue = ""; } -}); -initialize(); diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/manifest.json b/image/live-rootfs/usr/share/cockpit/cybexos-installer/manifest.json deleted file mode 100644 index 33d873bd..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "requires": {"cockpit": "300"}, - "content-security-policy": "default-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'; form-action 'self'" -} diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs b/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs deleted file mode 100644 index 8a0e3eae..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs +++ /dev/null @@ -1,109 +0,0 @@ -// Pure state and validation shared by the real UI and fixture tests. -const reserved = new Set(["root", "liveuser", "bin", "daemon", "adm", "mail", "ftp", "nobody", - "dbus", "systemd", "gdm", "sddm", "sshd", "polkitd", "chrony"]); -export const GiB = 1024 ** 3; -const MiB = 1024 ** 2; - -// Linux account names are lowercase; typing "John" creates the account "john". -export const accountName = value => value.toLowerCase(); -export class Wizard { - constructor() { this.page = "setup"; this.busy = false; this.plan = null; } - move(page) { - if (this.busy || this.page === "progress" || this.page === "unattended") throw new Error("An installation operation is still running."); - if (!["setup", "location", "review"].includes(page)) throw new Error("Unknown setup page."); - if (page === "review" && !this.plan) throw new Error("Review the disk before installing."); - if (page !== "review") this.plan = null; - this.page = page; - } - async operation(work) { - if (this.busy) throw new Error("An installation operation is still running."); - this.busy = true; - try { return await work(); } finally { this.busy = false; } - } - account(data) { - // A new-owner installation has no account yet; its passphrase is the - // temporary disk passphrase the new owner replaces at first start. - const what = data.oem ? "passphrase" : "password"; - if (!data.oem && (typeof data.username !== "string" || !/^[a-z_][a-z0-9_-]{0,30}$/.test(data.username) || reserved.has(data.username))) - throw new Error("Choose a username of 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter. System names are reserved."); - if (typeof data.password !== "string" || Array.from(data.password).length < 12 || Array.from(data.password).length > 512) - throw new Error(`Use a ${what} of 12–512 characters.`); - if (/[\x00-\x1f\x7f]/.test(data.password)) throw new Error(`The ${what} cannot contain control characters.`); - if (data.password !== data.confirm) throw new Error(`The ${what}s do not match.`); - return data; - } - confirmation(checked) { - if (this.page !== "review" || !this.plan || !checked) throw new Error("Confirm the selected disk before installing."); - const confirmed = this.plan.layout?.mode === "alongside" ? {alongside_confirmed: true} : {erase_confirmed: true}; - return {token: this.plan.token, confirmed_disk: this.plan.disk.name, ...confirmed}; - } -} - -export function size(bytes) { - return Number.isFinite(Number(bytes)) ? `${(Number(bytes) / GiB).toFixed(1)} GiB` : "Unknown size"; -} - -// "Windows", "Windows and Linux", "Windows, Linux and macOS". -export function systemsLabel(systems) { - const names = (systems || []).filter(name => typeof name === "string" && name); - if (names.length < 2) return names[0] || ""; - return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; -} - -// The CybexOS space the slider may choose, rounded to whole GiB inside the -// range the backend offered. -export function clampSpace(options, value) { - const low = options.min_space, high = options.max_space; - const chosen = Math.round(Number(value) / GiB) * GiB; - return Math.min(high, Math.max(low, Number.isFinite(chosen) ? chosen : low)); -} - -// The single shrink request for a chosen CybexOS size, or null when free -// space suffices. The partition keeps everything CybexOS does not take. -export function shrinkFor(options, space) { - if (!options || options.mode !== "shrink") return null; - const taken = clampSpace(options, space) - options.free; - const target = Math.floor((options.partition.size - taken) / MiB) * MiB; - return {device: options.partition.id, size: Math.max(options.floor, target)}; -} - -export function countdownText(seconds) { - const value = Math.max(0, Math.ceil(seconds)); - return `Installing in ${Math.floor(value / 60)}:${String(value % 60).padStart(2, "0")}`; -} - -// The review's summary rows; every value is plain text for textContent. -export function reviewFields(plan, diskIdentity) { - const account = plan.account, layout = plan.layout || {mode: "erase"}; - const fields = [["Disk", diskIdentity(plan.disk)]]; - if (layout.mode === "alongside") { - const others = systemsLabel(layout.systems) || "the existing systems"; - fields.push(["Install location", `Alongside ${others}. The boot menu lists every system.`]); - if (layout.resize) - fields.push(["Resized", `${layout.resize.path}${layout.resize.filesystem ? ` (${layout.resize.filesystem})` : ""} from ${size(layout.resize.from)} to ${size(layout.resize.to)}`]); - fields.push(["New for CybexOS", `${size(layout.space)} of ${layout.resize ? "freed" : "free"} space`]); - fields.push(["Unchanged", (layout.kept || []).length ? `${layout.kept.join(", ")}; the EFI system partition is shared` : "Every other partition"]); - } else { - fields.push(["Install location", "Erase the entire disk"]); - } - fields.push(["Account", account.oem ? "Created by the new owner at first start" : account.username]); - fields.push(["Storage", account.encrypted ? "Encrypted Btrfs · LUKS2" : "Btrfs · unencrypted"]); - fields.push(["At startup", account.oem - ? (account.encrypted ? "Temporary passphrase → new-owner setup" : "New-owner setup") - : (account.encrypted ? "Unlock your disk → automatic login" : "Sign in to your account")]); - fields.push(["Sudo", account.passwordless_wheel ? "Administrator commands do not ask for a password" - : account.oem ? "Administrator commands ask for the new owner's password" - : "Administrator commands ask for your account password"]); - fields.push(["Boot keyboard", plan.boot_keyboard], ["Language", account.locale], ["Timezone", account.timezone]); - return fields; -} - -// The review's confirmation copy for each layout. -export function confirmationCopy(plan) { - const layout = plan?.layout || {mode: "erase"}; - if (layout.mode !== "alongside") - return {check: "I understand that all data on the selected disk will be erased.", button: "Erase disk and install"}; - if (layout.resize) - return {check: `I have a backup, and I understand that ${layout.resize.path} will be resized.`, button: "Resize and install"}; - return {check: "I understand that CybexOS will be installed in the free space of this disk.", button: "Install alongside"}; -} diff --git a/image/live.ks b/image/live.ks deleted file mode 100644 index 7bce86c3..00000000 --- a/image/live.ks +++ /dev/null @@ -1,113 +0,0 @@ -# Image construction only. This kickstart is NOT passed to the GUI installer: -# disk selection and all destructive confirmations remain interactive. -lang en_US.UTF-8 -keyboard us -timezone UTC --utc -rootpw --lock -# with-mdns4 lets nss-mdns resolve .local names, matching the workstation. -# with-fingerprint offers fprintd beside the password (docs/security.md). -authselect select sssd with-silent-lastlog with-mdns4 with-fingerprint --force -selinux --enforcing -firewall --enabled -# Docker is socket-activated on demand by roles/base/tasks/docker-activation.yml, -# as on the workstation, rather than started at boot. -services --enabled=NetworkManager,sddm,firewalld,bluetooth,tailscaled --disabled=sshd -bootloader --timeout=5 --append="quiet rhgb" -part / --size=61440 --fstype=ext4 - -# Repository configuration, including signature verification, is supplied -# through image/compose's verified DNF adapter from image/build.repo. -# The library also requires a syntactically present Kickstart repository; -# the adapter replaces this entry with the signed DNF definitions. Like -# build.repo, it names no release: compose sets $releasever from inventory. -repo --name=fedora --baseurl=https://download.fedoraproject.org/pub/fedora/linux/releases/$releasever/Everything/x86_64/os/ - -%packages --excludedocs --exclude-weakdeps -@core -@anaconda-tools -kernel -kernel-modules -kernel-modules-extra -dracut-live -dracut-config-generic -grub2-efi-x64 -grub2-efi-x64-cdboot -grub2-pc -grub2-pc-modules -shim-x64 -syslinux -syslinux-extlinux -memtest86+ -fedora-release-identity-basic -fedora-release-common -fedora-repos -generic-logos -generic-release-notes -anaconda-live -anaconda-webui -libblockdev-plugins-all -lvm2 -mdadm -cryptsetup -sddm -cybexos-desktop -selinux-policy-targeted -policycoreutils -firewalld -NetworkManager-wifi -NetworkManager-bluetooth -iwlwifi-mvm-firmware -linux-firmware -# GPU firmware is split out of linux-firmware and must be explicit because -# weak dependencies are disabled above. Cover physical GPUs, not just QEMU. -amd-gpu-firmware -intel-gpu-firmware -nvidia-gpu-firmware -mesa-dri-drivers -mesa-vulkan-drivers -mesa-libEGL -pciutils -xorg-x11-server-Xwayland -at-spi2-core -adwaita-icon-theme -adw-gtk3-theme -# The shared base transaction selects these providers before desktop packages -# can pull Fedora alternatives. Keep compose's provider choice explicit too. -fdk-aac -jack-audio-connection-kit -openssh-server -rsync -btrfs-progs -dosfstools -e2fsprogs -chrony -plymouth -plymouth-system-theme -plymouth-plugin-script --dracut-config-rescue -%end - -%post --nochroot --erroronfail -set -eu -python3 /home/builder/source/image/install-live-rootfs "$INSTALL_ROOT" -install -Dm0644 /home/builder/build/hyprland.gpg "$INSTALL_ROOT/etc/pki/rpm-gpg/CYBEXOS-sdegler-hyprland-copr.gpg" -install -Dm0644 /home/builder/source/roles/desktop/templates/hyprland-copr.repo.j2 "$INSTALL_ROOT/etc/yum.repos.d/cybexos-hyprland.repo" -%end - -%post --erroronfail -set -eu -systemctl enable cybexos-live.service -systemctl set-default graphical.target -# Both live boot and the installed LUKS prompt use the shared Cybex artwork. -plymouth-set-default-theme cybex -firewall-offline-cmd --set-default-zone=cybexos -mkdir -p /etc/dracut.conf.d -cat > /etc/dracut.conf.d/99-live.conf <<'DRACUT' -add_dracutmodules+=" dmsquash-live livenet " -hostonly="no" -DRACUT -rm -f /etc/machine-id /var/lib/dbus/machine-id -touch /etc/machine-id -rm -f /etc/ssh/ssh_host_* -dnf clean all -%end diff --git a/image/login_qualification.py b/image/login_qualification.py deleted file mode 100644 index d84b7b2f..00000000 --- a/image/login_qualification.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Guest-only SDDM/keyring qualification on TestVM's disposable installation. - -No commands in this module run at import time. Secrets are synthetic fixture -values, sent over the guest's private SSH connection on stdin, never in argv. -""" -import json -import time - -from vm_testing import poweroff_guest, run - - -USER_ENV = ('export XDG_RUNTIME_DIR=/run/user/$(id -u); ' - 'export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus; ') - - -KEYRING_PROBE = r''' -from pathlib import Path -from gi.repository import Gio, GLib - -bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) -service = 'org.freedesktop.secrets' -base = '/org/freedesktop/secrets' -interface = 'org.freedesktop.Secret.' -def call(path, iface, method, signature=None, values=None): - parameters = GLib.Variant(signature, values) if signature else None - return bus.call_sync(service, path, iface, method, parameters, None, - Gio.DBusCallFlags.NONE, 10000, None).unpack() - -# ReadAlias and Locked inspect state; do not call Unlock or answer a prompt. -collection, = call(base, interface + 'Service', 'ReadAlias', '(s)', ('login',)) -assert collection != '/', 'PAM did not create the login keyring' -locked, = call(collection, 'org.freedesktop.DBus.Properties', 'Get', '(ss)', - (interface + 'Collection', 'Locked')) -assert bool(locked) == (ACTION == 'locked'), 'Unexpected login-keyring lock state' -files = list((Path.home() / '.local/share/keyrings').glob('*.keyring')) -assert files, 'Persistent login keyring missing' -assert any(path.read_bytes().startswith(b'GnomeKeyring\n\r\0\n') for path in files), \ - 'Encrypted binary keyring missing' -assert all(EXPECTED.encode() not in path.read_bytes() for path in files), \ - 'Synthetic secret persisted as plaintext' -if ACTION == 'locked': - print('Encrypted login keyring is locked; no unlock was attempted.') - raise SystemExit(0) -if ACTION == 'lock': - locked_paths, prompt = call(base, interface + 'Service', 'Lock', '(ao)', ([collection],)) - assert prompt == '/' and collection in locked_paths, 'Locking the synthetic collection requested interaction' - locked, = call(collection, 'org.freedesktop.DBus.Properties', 'Get', '(ss)', - (interface + 'Collection', 'Locked')) - assert locked, 'Synthetic login keyring did not lock' - print('Synthetic login keyring locked before session teardown.') - raise SystemExit(0) - -_, session = call(base, interface + 'Service', 'OpenSession', '(sv)', - ('plain', GLib.Variant('s', ''))) -attributes = {'cybexos-qualification': 'encrypted-login-roundtrip'} -try: - if ACTION == 'create': - properties = { - interface + 'Item.Label': GLib.Variant('s', 'CybexOS disposable qualification'), - interface + 'Item.Attributes': GLib.Variant('a{ss}', attributes), - } - _, prompt = call(collection, interface + 'Collection', 'CreateItem', - '(a{sv}(oayays)b)', - (properties, (session, b'', EXPECTED.encode(), 'text/plain'), True)) - assert prompt == '/', 'Creating a synthetic item requested interaction' - unlocked, locked_items = call(base, interface + 'Service', 'SearchItems', - '(a{ss})', (attributes,)) - assert len(unlocked) == 1 and not locked_items, 'Synthetic item is not unlocked' - secret, = call(unlocked[0], interface + 'Item', 'GetSecret', '(o)', (session,)) - assert bytes(secret[2]) == EXPECTED.encode(), 'Synthetic item changed across login' - if ACTION == 'delete': - prompt, = call(unlocked[0], interface + 'Item', 'Delete') - assert prompt == '/', 'Deleting a synthetic item requested interaction' -finally: - call(session, interface + 'Session', 'Close') -print('Encrypted login keyring and synthetic secret passed without a prompt.') -''' - - -SESSION_STATE = r''' -import json, subprocess -def output(*argv): - return subprocess.check_output(argv, text=True).strip() -sessions = [] -for line in output('loginctl', 'list-sessions', '--no-legend', '--no-pager').splitlines(): - identifier = line.split()[0] - try: - detail = output('loginctl', 'show-session', identifier, - '-p', 'User', '-p', 'Name', '-p', 'Class', '-p', 'Type', '-p', 'State') - except subprocess.CalledProcessError: - continue # A disappearing SSH session is expected during polling. - value = dict(row.split('=', 1) for row in detail.splitlines()) - sessions.append(value) -print(json.dumps({ - 'desktop': any(s.get('Name') == 'qualification' and s.get('Class') == 'user' - and s.get('Type') == 'wayland' and s.get('State') in ('active', 'online') - for s in sessions), - 'greeter': any(s.get('Class') == 'greeter' and s.get('State') in ('active', 'online') - for s in sessions), - 'sddm': subprocess.run(['systemctl', 'is-active', '--quiet', 'sddm.service']).returncode == 0, -})) -''' - - -LOGOUT = r''' -import os, re, subprocess -# SSH does not inherit the compositor environment. Import only the two values -# needed to address the actual graphical session, never execute shell exports. -raw = subprocess.check_output(['systemctl', '--user', 'show-environment'], text=True) -manager = dict(line.split('=', 1) for line in raw.splitlines() if '=' in line) -environment = dict(os.environ) -for name in ('HYPRLAND_INSTANCE_SIGNATURE', 'WAYLAND_DISPLAY'): - value = manager.get(name, '') - assert re.fullmatch(r'[A-Za-z0-9_.:/+-]+', value), 'Missing or invalid compositor environment' - environment[name] = value -subprocess.run(['/usr/libexec/cybexos-session-action', 'logout'], env=environment, check=True) -''' - - -DISABLE_BOOT_CACHE = r''' -python3 - <<'PY' -from pathlib import Path -path = Path('/etc/pam.d/sddm-autologin') -# The backup must survive reboot. /run is tmpfs and cannot be renamed into -# /root, so create this nonsecret fixture backup directly on the root disk. -backup = Path('/root/cybexos-qualification-pam-backup') -assert not backup.exists() -original = path.read_bytes() -assert b'pam_systemd_loadkey.so' in original -backup.write_bytes(original) -backup.chmod(0o600) -lines = original.decode().splitlines(keepends=True) -path.write_text(''.join('# qualification-cache-unavailable ' + line - if 'pam_systemd_loadkey.so' in line and not line.lstrip().startswith('#') - else line for line in lines)) -PY -''' - - -FAIL_FIRST_LAUNCH = r""" -python3 - <<'PY' -import os -from pathlib import Path -import tempfile -launcher = Path('/usr/bin/hyprland-quickshell') -backup = Path('/root/cybexos-qualification-launcher-backup') -marker = Path('/home/qualification/.local/state/cybexos/qualification-launch-failed') -assert launcher.is_file() and not launcher.is_symlink() -assert not backup.exists() and not marker.exists() -original = launcher.read_bytes() -assert original.startswith(b'#!/usr/bin/env bash\n') -backup.write_bytes(original) -backup.chmod(launcher.stat().st_mode & 0o777) -prefix = br'''#!/usr/bin/env bash -set -euo pipefail -# Disposable qualification fixture: fail before the real launcher once. -fixture_marker="${XDG_STATE_HOME:-$HOME/.local/state}/cybexos/qualification-launch-failed" -mkdir -p "$(dirname "$fixture_marker")" -if (set -C; printf '%s\n' first-autologin-failure > "$fixture_marker") 2>/dev/null; then - exit 1 -fi -''' -fd, name = tempfile.mkstemp(prefix='.cybexos-qualification-launcher-', dir=launcher.parent) -try: - with os.fdopen(fd, 'wb') as stream: - stream.write(prefix + original.split(b'\n', 1)[1]) - stream.flush() - os.fchmod(stream.fileno(), launcher.stat().st_mode & 0o777) - os.fsync(stream.fileno()) - os.replace(name, launcher) -finally: - Path(name).unlink(missing_ok=True) -PY -restorecon /usr/bin/hyprland-quickshell -""" - - -RESTORE_LAUNCHER = r''' -if test -f /root/cybexos-qualification-launcher-backup; then - cp -a /root/cybexos-qualification-launcher-backup /usr/bin/hyprland-quickshell.cybexos-qualification-restore - mv -f /usr/bin/hyprland-quickshell.cybexos-qualification-restore /usr/bin/hyprland-quickshell - restorecon /usr/bin/hyprland-quickshell - rm /root/cybexos-qualification-launcher-backup -fi -rm -f /home/qualification/.local/state/cybexos/qualification-launch-failed -''' - - -FAILED_LAUNCH_STATE = r''' -import configparser, json, pwd, stat -from pathlib import Path -marker = Path('/home/qualification/.local/state/cybexos/qualification-launch-failed') -info = marker.lstat() -assert stat.S_ISREG(info.st_mode) and info.st_uid == pwd.getpwnam('qualification').pw_uid -assert marker.read_text() == 'first-autologin-failure\n', 'The injected launch failure did not run' -consumed = Path('/run/cybexos-login/autologin-used').lstat() -assert stat.S_ISREG(consumed.st_mode) and consumed.st_uid == 0 and consumed.st_mode & 0o777 == 0o600, \ - 'The failed autologin did not consume its protected boot marker' -settings = configparser.ConfigParser() -settings.read('/etc/sddm.conf') -status = json.loads(Path('/run/cybexos-login/status.json').read_text()) -if RESTARTED: - assert settings.get('Autologin', 'User') == '', 'Manager restart re-enabled failed autologin' - assert status['autologin'] is False and status['reason'] == 'autologin-already-used' -else: - assert settings.get('Autologin', 'User') == 'qualification' - assert status['autologin'] is True and status['reason'] == 'encrypted-root', \ - 'The first login was not an encrypted-root autologin attempt' -''' - - -def user_script(vm, script, timeout=60): - return run([*vm.ssh, USER_ENV + 'python3 -'], input=script, text=True, - capture_output=True, timeout=timeout) - - -def keyring_probe(vm, action, secret): - if action not in ('create', 'read', 'lock', 'locked', 'delete'): - raise ValueError('Unknown keyring qualification action') - script = 'ACTION = ' + repr(action) + '\nEXPECTED = ' + repr(secret) + '\n' + KEYRING_PROBE - user_script(vm, script) - - -def wait_login_state(vm, desktop, timeout=90, stable_for=0): - deadline, stable_since = time.monotonic() + timeout, None - while time.monotonic() < deadline: - vm.alive() - state = json.loads(user_script(vm, SESSION_STATE).stdout) - matched = state['sddm'] and state['desktop'] == desktop and (desktop or state['greeter']) - if matched: - if stable_since is None: - stable_since = time.monotonic() - if time.monotonic() - stable_since >= stable_for: - return state - else: - stable_since = None - time.sleep(1) - raise RuntimeError('Installed login/session state did not settle as expected') - - -def reboot_installed(vm, password, root_script, expect_desktop=True): - poweroff_guest(vm, password, root_script, timeout=60) - vm.start(user='qualification') - vm.unlock_disk(password) - vm.wait_ssh(setup=False, redactions=(password,)) - if expect_desktop: - vm.wait_desktop() - wait_login_state(vm, desktop=expect_desktop, stable_for=0 if expect_desktop else 10) - - -def qualify_failed_autologin(vm, password, root_script, report, secret): - """A failed first session must leave the greeter usable without another auto attempt.""" - try: - root_script(vm, FAIL_FIRST_LAUNCH, password) - reboot_installed(vm, password, root_script, expect_desktop=False) - root_script(vm, "python3 - <<'PY'\nRESTARTED = False\n" + FAILED_LAUNCH_STATE + '\nPY\n', password) - report['checks'].append('first-autologin-launch-failure-consumes-boot-attempt') - root_script(vm, 'systemctl restart sddm.service\n', password) - wait_login_state(vm, desktop=False, stable_for=10) - root_script(vm, "python3 - <<'PY'\nRESTARTED = True\n" + FAILED_LAUNCH_STATE + '\nPY\n', password) - report['checks'].append('failed-first-autologin-manager-restart-requires-authentication') - time.sleep(2) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append('failed-first-autologin-password-login-recovers-desktop') - finally: - root_script(vm, RESTORE_LAUNCHER, password) - - -def qualify_login(vm, password, root_script, report, secret): - """Exercise the actual installed manager and vault; stop on the first failure.""" - root_script(vm, 'systemctl enable sshd.service\nfirewall-cmd --permanent --add-service=ssh\n', password) - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'create', secret) - report['checks'].append('initial-encrypted-keyring-unlocked-without-prompt') - reboot_installed(vm, password, root_script) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append('synthetic-keyring-secret-survives-cold-reboot') - - for action in ('logout', 'compositor-crash', 'manager-restart'): - # Run the shared mandatory audit before every deliberate session teardown. - vm.audit(applications=False) - # SSH can keep the user manager and an already-unlocked keyring alive. - # Lock explicitly so the subsequent successful read proves that the - # new password/PAM login unlocked it, rather than reusing old state. - keyring_probe(vm, 'lock', secret) - if action == 'manager-restart': - root_script(vm, 'systemctl restart sddm.service\n', password) - elif action == 'compositor-crash': - # Limit the kill to the synthetic user's actual compositor processes. - root_script(vm, 'pkill -KILL -u qualification -x Hyprland\n', password) - else: - user_script(vm, LOGOUT) - wait_login_state(vm, desktop=False, stable_for=10) - keyring_probe(vm, 'locked', secret) - report['checks'].append(action + '-requires-authentication') - # The embedded SDDM theme focuses the selected user's password input. - # Verify the greeter state first, type once, and never retry blindly. - time.sleep(2) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append(action + '-password-recovery-and-keyring') - - # Model an unavailable/expired boot-password cache without accessing its - # contents or changing LUKS slots: disable only loadkey in this task-owned VM. - root_script(vm, DISABLE_BOOT_CACHE, password) - try: - reboot_installed(vm, password, root_script) - keyring_probe(vm, 'locked', secret) - report['checks'].append('missing-cached-password-preserves-locked-encrypted-keyring') - finally: - root_script(vm, 'cp /root/cybexos-qualification-pam-backup /etc/pam.d/sddm-autologin\n' - 'restorecon /etc/pam.d/sddm-autologin\n' - 'rm /root/cybexos-qualification-pam-backup\n', password) - # Exercise the ordinary password-login fallback with the original PAM stack. - user_script(vm, LOGOUT) - wait_login_state(vm, desktop=False, stable_for=5) - keyring_probe(vm, 'locked', secret) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append('fallback-password-login-unlocks-existing-keyring') - qualify_failed_autologin(vm, password, root_script, report, secret) - keyring_probe(vm, 'delete', secret) diff --git a/image/notification_images.py b/image/notification_images.py deleted file mode 100644 index dfdba2ed..00000000 --- a/image/notification_images.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/python3 -"""Stage and admit the same local Qt image module used by checkout installs.""" -# SPDX-License-Identifier: MIT -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess - -ROOT = Path(__file__).resolve().parents[1] -RECIPE = Path('roles/desktop/files/notification-images') -SOURCES = ('CMakeLists.txt', 'LICENSE', 'build.py', 'imagefingerprints.cpp', - 'imagefingerprints.h', 'plugin.cpp', 'plugins.qmltypes', 'qmldir', 'tests.cpp') -MODULE = 'usr/lib64/qt6/qml/CybexOS/Images' -ARTIFACTS = tuple(f'{MODULE}/{name}' for name in - ('libcybexosnotificationimagesplugin.so', 'qmldir', 'plugins.qmltypes')) + ( - 'usr/share/licenses/cybexos-notification-images/LICENSE',) -MANIFEST = 'usr/share/cybexos/notification-images/build.json' - - -def sha256(path): - return hashlib.sha256(Path(path).read_bytes()).hexdigest() - - -def prepare_recipe(root, destination): - recipe = destination / 'usr/share/cybexos/notification-images' - recipe.mkdir(parents=True, exist_ok=True) - for name in SOURCES: - shutil.copyfile(root / RECIPE / name, recipe / name) - - -def prepare_notification_images(root, build, payload): - staged = build / 'notification-images-root' - manifest = json.loads((staged / MANIFEST).read_text()) - expected = {name: sha256(root / RECIPE / name) for name in SOURCES} - if (manifest.get('v') != 1 or manifest['inputs']['source_sha256'] != expected - or json.loads((build / 'notification-images.json').read_text()) != manifest - or set(manifest['artifacts']) != set(ARTIFACTS)): - raise RuntimeError('Native notification image build differs from this source revision') - for name in SOURCES: - if sha256(staged / 'usr/share/cybexos/notification-images' / name) != expected[name]: - raise RuntimeError('Staged notification image recipe differs: ' + name) - for name in ARTIFACTS: - if sha256(staged / name) != manifest['artifacts'][name]: - raise RuntimeError('Native notification image artifact differs: ' + name) - destination = payload / name - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(staged / name, destination) - destination.chmod(0o755 if name.endswith('.so') else 0o644) - prepare_recipe(root, payload) - shutil.copyfile(staged / MANIFEST, payload / MANIFEST) - return manifest - - -def main(): - if os.geteuid() != 0 or Path('/etc/hostname').read_text().strip() != 'image-builder': - raise RuntimeError('Native image builds require the disposable Fedora image builder') - import yaml - tasks = yaml.safe_load((ROOT / 'roles/desktop/tasks/notification-images.yml').read_text()) - packages = next(task['ansible.builtin.dnf']['name'] for task in tasks if 'ansible.builtin.dnf' in task) - subprocess.run(['dnf', 'install', '-y', *packages], check=True) - build = Path('/home/builder/build') - staged = build / 'notification-images-root' - staged.mkdir(parents=True, exist_ok=False) - prepare_recipe(ROOT, staged) - subprocess.run(['python3', str(staged / 'usr/share/cybexos/notification-images/build.py'), - '--root', str(staged)], check=True) - shutil.copyfile(staged / MANIFEST, build / 'notification-images.json') - - -if __name__ == '__main__': - main() diff --git a/image/offline-payload.json b/image/offline-payload.json deleted file mode 100644 index 8d56e844..00000000 --- a/image/offline-payload.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": "1.5.2", - "inputs_sha256": "300206dc08b2e072f5ff3cbbe6dc48270ac1a7034aaa6bd79b5fba9308b1389c" -} diff --git a/image/offline_payload.py b/image/offline_payload.py deleted file mode 100644 index 836870f1..00000000 --- a/image/offline_payload.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Independently version the large, offline application payload. - -Run `python3 image/offline_payload.py --version X.Y.Z` when changing its -reviewed inputs. Desktop-only changes do not advance this RPM's identity. -""" -import argparse -import hashlib -import json -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -PACKAGE = "cybexos-offline-apps" -PREFIXES = ( - "opt/cybexos-apps", "opt/cybexos-builds", "usr/local/bin", "usr/local/libexec", - "usr/local/share/fonts", "usr/local/share/applications/omawrite.desktop", - "usr/share/icons/hicolor/scalable/apps/omawrite.svg", - "usr/share/cybexos/user-seed", "usr/share/cybexos/final-seed", - "usr/share/cybexos/seed-groups.json", "usr/share/licenses/cybexos-fonts", - "usr/share/cybexos/offline-inputs.json", -) -INPUTS = ( - "inventory/group_vars/all.yml", "roles/apps", "roles/dotfiles", "image/applications", - "image/applications.yml", "image/application_payload.py", "image/offline_payload.py", "image/package", - "assets/EDM115-newline2.omp.json", "assets/nautilus-localsend.py", - "assets/desktop-contract.json", "image/desktop_payload.py", - "image/system_packages.py", "image/live.ks", "image/cybexos-desktop.spec", - "roles/base/defaults/main.yml", "roles/desktop/tasks/main.yml", - "roles/xps-2026/tasks/packages.yml", -) - - -def input_digest(root=ROOT): - records = [] - for relative in INPUTS: - source = root / relative - if not source.exists(): - raise ValueError(f"Missing offline application input: {relative}") - files = sorted(source.rglob("*")) if source.is_dir() else [source] - for path in files: - if path.is_file() and not path.is_symlink() and "__pycache__" not in path.parts and path.suffix != ".pyc": - records.append((path.relative_to(root).as_posix(), hashlib.sha256(path.read_bytes()).hexdigest())) - return hashlib.sha256(json.dumps(records, separators=(",", ":")).encode()).hexdigest() - - -def configuration(root=ROOT): - config = json.loads((root / "image/offline-payload.json").read_text()) - if set(config) != {"version", "inputs_sha256"} or not re.fullmatch(r"\d+\.\d+\.\d+", config["version"]): - raise ValueError("Offline payload requires an independent X.Y.Z version and input digest") - if config["inputs_sha256"] != input_digest(root): - raise ValueError("Offline application inputs changed: run python3 image/offline_payload.py --version NEW_VERSION") - return config - - -def file_lists(payload): - """Directory entries recurse in RPM; both packages partition one payload.""" - return ["/" + relative for relative in PREFIXES - if (payload / relative).exists() or (payload / relative).is_symlink()] - - -def inventory(payload): - result = {"bytes": 0, "files": 0, "groups": {}} - for relative in PREFIXES: - source = payload / relative - paths = list(source.rglob("*")) if source.is_dir() else [source] - sizes = [path.stat().st_size for path in paths if path.is_file() and not path.is_symlink()] - result["groups"][relative] = {"bytes": sum(sizes), "files": len(sizes)} - result["bytes"] += sum(sizes) - result["files"] += len(sizes) - return result - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--version", help="Advance the offline payload version and record its reviewed inputs") - args = parser.parse_args() - path = ROOT / "image/offline-payload.json" - if args.version: - if not re.fullmatch(r"\d+\.\d+\.\d+", args.version): - parser.error("version must be X.Y.Z") - if path.exists(): - previous = json.loads(path.read_text()) - if previous["inputs_sha256"] != input_digest() and tuple(map(int, args.version.split("."))) <= tuple(map(int, previous["version"].split("."))): - parser.error("Changed offline inputs require a newer version") - path.write_text(json.dumps({"version": args.version, "inputs_sha256": input_digest()}, indent=2) + "\n") - print(json.dumps(configuration(), indent=2)) - - -if __name__ == "__main__": - main() diff --git a/image/offline_upgrade_inputs.py b/image/offline_upgrade_inputs.py deleted file mode 100644 index 4aae82b7..00000000 --- a/image/offline_upgrade_inputs.py +++ /dev/null @@ -1,487 +0,0 @@ -"""Bounded, release-bound third-party RPM fixtures for offline upgrade qualification. - -Importing this module never downloads, installs, boots, or imports trust keys. -""" -import argparse -from contextlib import contextmanager -from dataclasses import dataclass -import hashlib -import inspect -import itertools -import json -import os -from pathlib import Path -import re -import shutil -import stat -import subprocess -import tempfile -from urllib.parse import urlsplit - -from build_support import atomic_json, digest - -MANIFEST = 'manifest.json' -MAX_MANIFEST_BYTES = 1024 * 1024 -MAX_RPMS = 256 -MAX_RPM_BYTES = 512 * 1024 * 1024 -MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024 -SIGNATURE_TAGS = ('RSAHEADER', 'DSAHEADER', 'SIGPGP', 'SIGGPG') -BINDINGS = ('baseline_iso_sha256', 'candidate_rpm_sha256', 'candidate_offline_rpm_sha256') -RPM_FIELDS = {'filename', 'name', 'nevra', 'architecture', 'sha256', 'size', 'signature', 'repository'} - - -@contextmanager -def regular_stream(path, maximum): - """Never open a symlink, device or FIFO, or read an unbounded input.""" - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - with os.fdopen(descriptor, 'rb') as stream: - info = os.fstat(stream.fileno()) - if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= maximum: - raise ValueError(f'Expected a bounded regular file: {path}') - yield stream, info.st_size - - -def file_sha256(path, maximum, expected_size=None): - with regular_stream(path, maximum) as (stream, size): - if expected_size is not None and size != expected_size: - raise ValueError(f'RPM size mismatch: {path}') - checksum = hashlib.sha256() - remaining = size - while remaining: - chunk = stream.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError(f'File changed while reading: {path}') - checksum.update(chunk) - remaining -= len(chunk) - if stream.read(1): - raise ValueError(f'File changed while reading: {path}') - return checksum.hexdigest() - - -def unique_object(pairs): - result = {} - for key, value in pairs: - if key in result: - raise ValueError(f'Duplicate manifest key: {key}') - result[key] = value - return result - - -def read_json(path): - with regular_stream(path, MAX_MANIFEST_BYTES) as (stream, _): - data = stream.read(MAX_MANIFEST_BYTES + 1) - if len(data) > MAX_MANIFEST_BYTES: - raise ValueError('Manifest exceeds size limit') - return json.loads(data, object_pairs_hook=unique_object), hashlib.sha256(data).hexdigest() - - -def exact_fields(value, fields): - if not isinstance(value, dict) or set(value) != set(fields): - raise ValueError('Malformed dependency manifest fields') - - -def valid_sha(value): - return isinstance(value, str) and re.fullmatch(r'[0-9a-f]{64}', value) - - -def validate_record(record): - exact_fields(record, RPM_FIELDS) - for field in ('filename', 'name', 'nevra', 'architecture'): - if not isinstance(record[field], str) or len(record[field]) > 255: - raise ValueError(f'Malformed RPM {field}') - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', record['filename']): - raise ValueError('Unsafe dependency RPM filename') - if (not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+-]*', record['name']) - or record['name'].startswith('cybexos-') or record['name'] == 'gpg-pubkey'): - raise ValueError('Dependency must be a third-party package, never first-party or a trust key') - if record['architecture'] not in ('x86_64', 'noarch'): - raise ValueError('Dependency architecture must be x86_64 or noarch') - evr = r'(?:0|[1-9][0-9]{0,9}):[A-Za-z0-9][A-Za-z0-9._+~^]*-[A-Za-z0-9][A-Za-z0-9._+~^]*' - if not re.fullmatch(re.escape(record['name']) + '-' + evr + r'\.' + record['architecture'], record['nevra']): - raise ValueError('Dependency NEVRA must include an explicit numeric epoch and matching architecture') - if not valid_sha(record['sha256']): - raise ValueError('Malformed RPM SHA-256') - if type(record['size']) is not int or not 0 < record['size'] <= MAX_RPM_BYTES: - raise ValueError('Dependency RPM exceeds size limit') - signature = record['signature'] - exact_fields(signature, ('tag', 'value')) - if (signature['tag'] not in SIGNATURE_TAGS or not isinstance(signature['value'], str) - or len(signature['value']) > 1024 - or not re.fullmatch(r'[\x20-\x7e]+ Key ID [0-9a-fA-F]{8,64}', signature['value'])): - raise ValueError('Missing or malformed RPM signature identity') - validate_repository(record['repository']) - - -def validate_repository(repository): - exact_fields(repository, ('id', 'url')) - if (not isinstance(repository['id'], str) - or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,127}', repository['id']) - or not isinstance(repository['url'], str) or len(repository['url']) > 2048 - or not re.fullmatch(r'[\x21-\x7e]+', repository['url'])): - raise ValueError('Missing or malformed repository provenance') - url = urlsplit(repository['url']) - # Accessing .port also rejects malformed/non-numeric/out-of-range ports. - if url.scheme != 'https' or not url.hostname or url.port == 0 or url.username or url.password or url.fragment: - raise ValueError('Repository provenance must be an HTTPS URL without credentials or a fragment') - - -@dataclass(frozen=True) -class DependencyBundle: - directory: Path - manifest: dict - manifest_sha256: str - - def evidence(self): - return {**self.manifest, 'manifest_sha256': self.manifest_sha256, - 'total_bytes': sum(item['size'] for item in self.manifest['rpms'])} - - def revalidate(self): - current = load_bundle(self.directory) - if current.manifest_sha256 != self.manifest_sha256: - raise ValueError('Dependency manifest changed during qualification') - return current - - -def load_bundle(directory, *, baseline_iso=None, candidate_rpm=None, candidate_offline_rpm=None): - directory = Path(directory).absolute() - if any(path.is_symlink() for path in (directory, *directory.parents)) or not directory.is_dir(): - raise ValueError('Dependency input must be a directory without symlinks') - manifest, sha = read_json(directory / MANIFEST) - exact_fields(manifest, ('schema', *BINDINGS, 'rpms')) - if type(manifest['schema']) is not int or manifest['schema'] != 1: - raise ValueError('Unsupported dependency manifest schema') - if any(not valid_sha(manifest[key]) for key in BINDINGS): - raise ValueError('Malformed dependency artifact bindings') - supplied = (baseline_iso, candidate_rpm, candidate_offline_rpm) - if any(path is not None for path in supplied): - if any(path is None for path in supplied): - raise ValueError('Dependency bundle requires the baseline ISO and both first-party candidates') - for key, path in zip(BINDINGS, supplied): - if digest(path) != manifest[key]: - raise ValueError(f'Dependency bundle artifact binding mismatch: {key}') - records = manifest['rpms'] - if not isinstance(records, list) or not 1 <= len(records) <= MAX_RPMS: - raise ValueError('Dependency RPM count must be between 1 and 256') - names, identities, total = set(), set(), 0 - for record in records: - validate_record(record) - identity = (record['name'], record['architecture']) - if record['filename'] in names or identity in identities: - raise ValueError('Duplicate dependency RPM filename or package architecture') - names.add(record['filename']) - identities.add(identity) - total += record['size'] - if total > MAX_TOTAL_BYTES: - raise ValueError('Dependency bundle exceeds total size limit') - entries = list(itertools.islice(directory.iterdir(), MAX_RPMS + 2)) - if {path.name for path in entries} != names | {MANIFEST} or len(entries) != len(names) + 1: - raise ValueError('Dependency directory has extra or missing files') - for record in records: - if file_sha256(directory / record['filename'], MAX_RPM_BYTES, record['size']) != record['sha256']: - raise ValueError(f'Dependency RPM checksum mismatch: {record["filename"]}') - return DependencyBundle(directory, manifest, sha) - - -def rpm_identity(path): - """Used only by the optional host manifest creator (requires rpm).""" - fields = '%{NAME}\n%{EPOCHNUM}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' - fields += ''.join('%{' + tag + ':pgpsig}\n' for tag in SIGNATURE_TAGS) - values = subprocess.run(['rpm', '-qp', '--qf', fields, str(path)], check=True, - capture_output=True, text=True, timeout=30, - env={**os.environ, 'LC_ALL': 'C', 'TZ': 'UTC'}).stdout.splitlines() - if len(values) != 5 + len(SIGNATURE_TAGS): - raise ValueError('Unexpected RPM identity output') - name, epoch, version, release, architecture = values[:5] - signatures = [(tag, value) for tag, value in zip(SIGNATURE_TAGS, values[5:]) if value != '(none)'] - if not signatures: - raise ValueError('Dependency RPM is unsigned') - tag, value = signatures[0] - return {'name': name, 'nevra': f'{name}-{epoch}:{version}-{release}.{architecture}', - 'architecture': architecture, 'signature': {'tag': tag, 'value': value}} - - -def create_manifest(directory, baseline_iso, candidate_rpm, candidate_offline_rpm, provenance): - """Describe already verified downloads; never fetch RPMs or trust new keys.""" - directory = Path(directory).absolute() - if any(path.is_symlink() for path in (directory, *directory.parents)) or not directory.is_dir(): - raise ValueError('Dependency input must be a directory without symlinks') - destination = directory / MANIFEST - if destination.exists() or destination.is_symlink(): - raise ValueError('Refusing to replace a dependency manifest') - entries = list(itertools.islice(directory.iterdir(), MAX_RPMS + 1)) - if not 1 <= len(entries) <= MAX_RPMS or not isinstance(provenance, dict) or set(provenance) != {p.name for p in entries}: - raise ValueError('Provenance must describe every RPM and no extra files') - records = [] - total = 0 - for path in sorted(entries): - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', path.name): - raise ValueError('Unsafe dependency RPM filename') - # Bound input before invoking the host RPM parser. - sha = file_sha256(path, MAX_RPM_BYTES) - size = path.stat().st_size - record = {'filename': path.name, **rpm_identity(path), 'size': size, - 'sha256': sha, 'repository': provenance[path.name]} - validate_record(record) - total += size - records.append(record) - if total > MAX_TOTAL_BYTES: - raise ValueError('Dependency bundle exceeds total size limit') - manifest = {'schema': 1, **dict(zip(BINDINGS, map(digest, (baseline_iso, candidate_rpm, candidate_offline_rpm)))), - 'rpms': records} - atomic_json(destination, manifest) - try: - return load_bundle(directory, baseline_iso=baseline_iso, candidate_rpm=candidate_rpm, - candidate_offline_rpm=candidate_offline_rpm) - except BaseException: - destination.unlink() - raise - - -@dataclass(frozen=True) -class RawDependencyFixture: - """Private pre-build snapshots, with a content identity but no artifact bindings.""" - directory: Path - records: list - provenance_sha256: str - - def evidence(self): - value = {'schema': 1, 'provenance_sha256': self.provenance_sha256, 'rpms': self.records} - sha = hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() - return {**value, 'fixture_sha256': sha, 'total_bytes': sum(record['size'] for record in self.records)} - - def bind(self, baseline_iso, candidate_rpm, candidate_offline_rpm): - bundle = create_manifest(self.directory, baseline_iso, candidate_rpm, candidate_offline_rpm, - {record['filename']: record['repository'] for record in self.records}) - if bundle.manifest['rpms'] != self.records: - (self.directory / MANIFEST).unlink() - raise ValueError('Raw dependency fixture changed after admission') - return bundle - - -def snapshot_raw_dependencies(directory, provenance_file, destination): - """Admit trusted raw RPMs before a build, without changing caller inputs.""" - directory, provenance_file, destination = (Path(path).absolute() for path in (directory, provenance_file, destination)) - if any(path.is_symlink() for path in (directory, *directory.parents)) or not directory.is_dir(): - raise ValueError('Raw dependency input must be a directory without symlinks') - if any(path.is_symlink() for path in (provenance_file, *provenance_file.parents)): - raise ValueError('Dependency provenance must not use symlinks') - if destination.resolve().is_relative_to(directory.resolve()): - raise ValueError('Dependency staging must be outside the caller input directory') - provenance, provenance_sha = read_json(provenance_file) - entries = list(itertools.islice(directory.iterdir(), MAX_RPMS + 1)) - if (not 1 <= len(entries) <= MAX_RPMS or not isinstance(provenance, dict) - or set(provenance) != {path.name for path in entries}): - raise ValueError('Provenance must describe every raw RPM and no extra files (1 to 256 RPMs)') - for path in entries: - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', path.name): - raise ValueError('Unsafe raw dependency RPM filename') - validate_repository(provenance[path.name]) - destination.mkdir(mode=0o700) - try: - records, identities, total = [], set(), 0 - for path in sorted(entries): - copied = destination / path.name - with regular_stream(path, MAX_RPM_BYTES) as (source, size): - total += size - if total > MAX_TOTAL_BYTES: - raise ValueError('Raw dependency fixture exceeds total size limit') - sha, remaining = hashlib.sha256(), size - with copied.open('xb') as output: - while remaining: - chunk = source.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError('Raw dependency RPM changed while copying') - output.write(chunk) - sha.update(chunk) - remaining -= len(chunk) - if source.read(1): - raise ValueError('Raw dependency RPM grew while copying') - os.fchmod(output.fileno(), 0o400) - record = {'filename': path.name, **rpm_identity(copied), 'size': size, - 'sha256': sha.hexdigest(), 'repository': provenance[path.name]} - validate_record(record) - identity = (record['name'], record['architecture']) - if identity in identities: - raise ValueError('Duplicate raw dependency package architecture') - identities.add(identity) - records.append(record) - # Artifact hashes have fixed length, so reject a fixture that cannot - # fit the bound manifest before spending time on the candidate build. - preview = {'schema': 1, **{key: '0' * 64 for key in BINDINGS}, 'rpms': records} - if len(json.dumps(preview, indent=2, sort_keys=True).encode()) + 1 > MAX_MANIFEST_BYTES: - raise ValueError('Raw dependency manifest exceeds size limit') - return RawDependencyFixture(destination, records, provenance_sha) - except BaseException: - shutil.rmtree(destination) - raise - - -@contextmanager -def transfer_stream(bundle, record): - """Snapshot bounded bytes so a file growing during SSH cannot extend transfer.""" - with regular_stream(bundle.directory / record['filename'], MAX_RPM_BYTES) as (source, size), tempfile.TemporaryFile() as snapshot: - if size != record['size']: - raise ValueError('Dependency RPM size changed before transfer') - checksum = hashlib.sha256() - remaining = size - while remaining: - chunk = source.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError('Dependency RPM changed before transfer') - snapshot.write(chunk) - checksum.update(chunk) - remaining -= len(chunk) - if source.read(1) or checksum.hexdigest() != record['sha256']: - raise ValueError('Dependency RPM checksum changed before transfer') - snapshot.seek(0) - yield snapshot - - -def verify_guest(directory, records): - """Self-contained verifier executed as root with the guest's existing RPM DB.""" - import hashlib - import os - from pathlib import Path - import re - import stat - import subprocess - - directory = Path(directory) - if directory.is_symlink() or not directory.is_dir(): - raise ValueError('Unsafe guest dependency directory') - if {path.name for path in directory.iterdir()} != {item['filename'] for item in records}: - raise ValueError('Extra or missing guest dependency RPMs') - environment = {**os.environ, 'LC_ALL': 'C', 'TZ': 'UTC'} - for record in records: - path = directory / record['filename'] - info = path.lstat() - if not stat.S_ISREG(info.st_mode) or info.st_size != record['size']: - raise ValueError('Guest dependency RPM size or file type mismatch') - with path.open('rb') as stream: - if hashlib.file_digest(stream, 'sha256').hexdigest() != record['sha256']: - raise ValueError('Guest dependency RPM checksum mismatch') - identity = '%{NAME}-%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n%{ARCH}\n' - identity += '%{' + record['signature']['tag'] + ':pgpsig}\n' - result = subprocess.run(['rpm', '-qp', '--qf', identity, str(path)], check=True, - capture_output=True, text=True, timeout=30, env=environment) - if result.stdout.splitlines() != [record['nevra'], record['architecture'], record['signature']['value']]: - raise ValueError('Guest dependency RPM identity or signature header mismatch') - # No --import or alternate database: use only installed guest trust. - result = subprocess.run(['rpm', '--define', '_pkgverify_level all', '--define', '_pkgverify_flags 0x0', - '--checksig', '--verbose', str(path)], check=True, - capture_output=True, text=True, timeout=30, env=environment) - if not re.search(r'(?im)^\s*.*\b(?:signature|openpgp)\b.*:\s*OK\s*$', result.stdout): - raise ValueError('Guest dependency RPM has no verified trusted signature') - - -def guest_verification_script(directory, records): - return "python3 - <<'PY'\n" + inspect.getsource(verify_guest) + f'\nverify_guest({str(directory)!r}, {records!r})\nPY\n' - - -def seal_guest_inputs(source_directory, records, firstparty): - """Copy account-owned transfers into new root-only inodes before verification.""" - import hashlib - import os - from pathlib import Path - import shutil - import stat - import tempfile - - if os.geteuid() != 0: - raise PermissionError('Upgrade staging requires root') - parent = Path('/tmp') - info = parent.lstat() - if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 - or (info.st_mode & 0o022 and not info.st_mode & stat.S_ISVTX)): - raise PermissionError('Upgrade staging parent must be root-owned and protected against renames') - if set(firstparty) != {'candidate', 'offline_candidate'}: - raise ValueError('Sealing requires both first-party candidates') - directory = Path(tempfile.mkdtemp(prefix='cybexos-qualification-sealed-', dir=parent)) - try: - os.chmod(directory, 0o700) - (directory / 'candidates').mkdir(mode=0o700) - (directory / 'dependencies').mkdir(mode=0o700) - - def copy_file(source, destination, expected, *, source_fd=None): - # Never follow a swapped symlink or block on an account-owned FIFO. - descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=source_fd) - with os.fdopen(descriptor, 'rb') as original: - info = os.fstat(original.fileno()) - size = expected['size'] - if (type(size) is not int or size <= 0 or not stat.S_ISREG(info.st_mode) - or info.st_size != size): - raise ValueError('Transferred upgrade RPM size or file type mismatch') - output_fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) - with os.fdopen(output_fd, 'wb') as output: - remaining = size - while remaining: - chunk = original.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError('Transferred upgrade RPM changed during sealing') - output.write(chunk) - remaining -= len(chunk) - if original.read(1): - raise ValueError('Transferred upgrade RPM grew during sealing') - output.flush() - os.fchmod(output.fileno(), 0o400) - # Verify the new inode, never the account-owned source pathname. - with destination.open('rb') as copied: - if hashlib.file_digest(copied, 'sha256').hexdigest() != expected['sha256']: - raise ValueError('Sealed upgrade RPM checksum mismatch') - - result = {'directory': str(directory), 'dependencies': []} - for key, expected in firstparty.items(): - destination = directory / 'candidates' / (key + '.rpm') - copy_file(expected['source'], destination, expected) - result[key] = str(destination) - # Pin the original directory inode too; a parent-path swap cannot turn - # an RPM open into traversal through an account-controlled symlink. - source_fd = os.open(source_directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) - try: - for record in records: - destination = directory / 'dependencies' / record['filename'] - copy_file(record['filename'], destination, record, source_fd=source_fd) - result['dependencies'].append(str(destination)) - finally: - os.close(source_fd) - verify_guest(directory / 'dependencies', records) - return result - except BaseException: - shutil.rmtree(directory) - raise - - -def guest_sealing_script(directory, records, firstparty): - return ("python3 - <<'PY'\nimport json\n" + inspect.getsource(verify_guest) + '\n' - + inspect.getsource(seal_guest_inputs) - + f'\nprint(json.dumps(seal_guest_inputs({str(directory)!r}, {records!r}, {firstparty!r})))\nPY\n') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('action', choices=('create', 'verify')) - parser.add_argument('directory', type=Path) - parser.add_argument('--baseline-iso', type=Path, required=True) - parser.add_argument('--candidate-rpm', type=Path, required=True) - parser.add_argument('--candidate-offline-rpm', type=Path, required=True) - parser.add_argument('--provenance', type=Path, help='Create only: JSON mapping RPM filenames to {id, url} repositories') - args = parser.parse_args() - try: - if args.action == 'create': - if not args.provenance: - parser.error('create requires --provenance') - provenance, _ = read_json(args.provenance) - bundle = create_manifest(args.directory, args.baseline_iso, args.candidate_rpm, - args.candidate_offline_rpm, provenance) - else: - if args.provenance: - parser.error('--provenance is only valid for create') - bundle = load_bundle(args.directory, baseline_iso=args.baseline_iso, candidate_rpm=args.candidate_rpm, - candidate_offline_rpm=args.candidate_offline_rpm) - except (OSError, ValueError, subprocess.SubprocessError) as error: - parser.error(str(error)) - print(json.dumps(bundle.evidence(), indent=2, sort_keys=True)) - - -if __name__ == '__main__': - main() diff --git a/image/package b/image/package deleted file mode 100755 index 293dcd97..00000000 --- a/image/package +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -"""Assemble the RPM payload from the existing desktop and image integration.""" - -import argparse -import hashlib -import json -from pathlib import Path -import shutil -import shlex -import subprocess -import tarfile - -import jinja2 -import yaml -from application_payload import relativize_seed_links, include_applications -from desktop_payload import prepare_defaults, prepare_session, split_seed, prepare_managed_defaults, prepare_app_launchers, prepare_quickshell, prepare_font_licenses -from provision_payload import prepare_provision -from release_metadata import install_update_channel, render_spec -from titlebars_payload import prepare_titlebars -from notification_images import prepare_notification_images -from offline_payload import configuration as offline_configuration, file_lists, inventory as offline_inventory - -ROOT = Path(__file__).resolve().parents[1] - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - build = args.output.resolve() - payload = build / "payload" - payload.mkdir(parents=True, exist_ok=False) - applications = json.loads((build / "applications.json").read_text()) - # Resolve the starter's plugin graph in the builder, so opening Neovim - # offline does not need to bootstrap Lazy or fetch the default plugins. - seed_home = Path(applications["user_seed"]) - subprocess.run(["runuser", "-u", "cybex-seed", "--", "env", - f"HOME={seed_home}", f"PATH={seed_home}/.cargo/bin:{seed_home}/.local/bin:/usr/local/bin:/usr/bin", - "nvim", "--headless", "+Lazy! install", "+qa"], check=True, timeout=600) - seed_root = build / "applications-root/usr/share/cybexos/user-seed" - nvim_payload = seed_root / ".local/share/nvim" - if nvim_payload.exists(): - shutil.rmtree(nvim_payload) - shutil.copytree(seed_home / ".local/share/nvim", nvim_payload, symlinks=True) - shutil.copy2(seed_home / ".config/nvim/lazy-lock.json", seed_root / ".config/nvim/lazy-lock.json") - shutil.copytree(build / "applications-root", payload, dirs_exist_ok=True, symlinks=True) - provenance = install_update_channel(ROOT / "image", payload) - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no, - to_json=json.dumps, quote=shlex.quote) - values = {"features": inventory["features"], "cybexos_xps_2026": False, - "machine_keyboard_layout": "us", "machine_keyboard_variant": "", - "cybexos_dynamic_hardware": True} - - def write(relative, content, executable=False): - target = payload / str(relative).lstrip("/") - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content) - target.chmod(0o755 if executable else 0o644) - - def copy(source, relative, executable=False): - target = payload / str(relative).lstrip("/") - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(ROOT / source, target) - target.chmod(0o755 if executable else 0o644) - - runtime = "usr/share/cybexos/runtime" - copy("LICENSE", "usr/share/licenses/cybexos-desktop/LICENSE") - copy("release-manifest.json", "usr/share/cybexos/release-manifest.json") - prepare_quickshell(ROOT, payload) - prepare_titlebars(ROOT, build, payload) - prepare_notification_images(ROOT, build, payload) - for name in ("hyprland.lua", "bindings.lua", "autostart.lua", "displays.lua", "input_preferences.lua", - "workspace_state.lua", "workspace_model.lua", "workspace_layouts.lua", "workspaces.lua"): - content = (ROOT / "roles/desktop/files" / name).read_text() - content = content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-") - write(f"{runtime}/hypr/{name}", content) - for name in ("features.lua", "monitors.lua", "input.lua", "looknfeel.lua", "hypridle.conf", "hyprlock.conf"): - content = environment.from_string((ROOT / "roles/desktop/templates" / (name + ".j2")).read_text()).render(**values) - content = content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-") - if name == "input.lua": - content = content.replace('kb_layout = "us"', 'kb_layout = os.getenv("CYBEXOS_KEYBOARD_LAYOUT") or "us"') - content = content.replace('kb_variant = ""', 'kb_variant = os.getenv("CYBEXOS_KEYBOARD_VARIANT") or ""') - write(f"{runtime}/hypr/{name}", content) - contract = json.loads((ROOT / "assets/desktop-contract.json").read_text()) - helpers = contract["userHelpers"] + ["cybexos-runtime", "cybexos-update-run"] - for name in helpers: - copy(f"assets/scripts/{name}", f"usr/share/cybexos/bin/{name}", True) - # The earlier browser-profile export, now a mode of cybexos-backup; the - # dotfiles role links the same names on checkout installations. - for name in ("app-backup", "browser-backup"): - (payload / "usr/share/cybexos/bin" / name).symlink_to("cybexos-backup") - copy("roles/dotfiles/files/dev-distrobox-init", "usr/share/cybexos/bin/dev-distrobox-init", True) - updater = payload / "usr/share/cybexos/bin/cybexos-update-run" - updater.write_text(updater.read_text().replace("snapshot_helper_root=/usr/local/libexec", "snapshot_helper_root=/usr/libexec")) - copy("roles/dotfiles/templates/brightness-control.j2", "usr/share/cybexos/bin/brightness-control", True) - for name in ("fastfetch-link-speed", "t3code-update", "t3code-desktop", "spotify"): - copy(f"roles/dotfiles/templates/{name}.j2", f"usr/share/cybexos/bin/{name}", True) - seed = "usr/share/cybexos/user-seed" - # Managed updater scripts follow the RPM; application binaries and their - # version stores remain writable per-user after offline initialization. - copy_target = payload / seed / ".local/bin/update-user-tools" - shutil.move(copy_target, payload / "usr/share/cybexos/bin/update-user-tools") - (payload / seed / ".local/bin/t3code-update").unlink() - for source, destination in ( - ("fastfetch.jsonc", ".config/fastfetch/config.jsonc"), - ("fish-config.fish", ".config/fish/conf.d/50-cybexos.fish"), - ): - copy(f"roles/dotfiles/files/{source}", f"{seed}/{destination}") - copy("assets/EDM115-newline2.omp.json", f"{seed}/.config/oh-my-posh/EDM115-newline2.omp.json") - copy("assets/nautilus-localsend.py", f"{seed}/.local/share/nautilus-python/extensions/localsend.py") - for name in ("localsend-share-clipboard", "localsend-share-file", "localsend-share-folder"): - content = (ROOT / "roles/dotfiles/templates" / (name + ".desktop.j2")).read_text() - write(f"{seed}/.local/share/applications/{name}.desktop", - content.replace("{{ primary_home }}/.local/bin/", "")) - prepare_app_launchers(ROOT, payload) - t3_desktop = (ROOT / "roles/dotfiles/templates/t3code-nightly.desktop.j2").read_text() - write(f"{seed}/.local/share/applications/t3code-nightly.desktop", - t3_desktop.replace("{{ primary_home }}/.local/bin/", "")) - for box in inventory["distrobox_images"]: - launcher = environment.from_string((ROOT / "roles/dotfiles/templates/distrobox-shell.j2").read_text()).render(item=box) - write(f"usr/share/cybexos/bin/dev-{box['name']}-shell", launcher, True) - desktop = (ROOT / f"roles/dotfiles/templates/dev-{box['name']}.desktop.j2").read_text() - write(f"{seed}/.local/share/applications/dev-{box['name']}.desktop", - desktop.replace("{{ primary_home }}/.local/bin/", "")) - # Systemd's %h resolves at service start, after account creation. - hermes = environment.from_string((ROOT / "roles/desktop/templates/hermes-menubar-bridge.service.j2").read_text()).render(primary_home="%h", hermes_bridge_executable="/usr/libexec/cybexos-hermes-menubar-bridge") - write("usr/lib/systemd/user/hermes-menubar-bridge.service", hermes) - copy("roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py", "usr/libexec/cybexos-hermes-menubar-bridge", True) - for source in sorted((ROOT / "roles/desktop/files/hermes-menubar-bridge/cybex_hermes").glob("*.py")): - copy(source.relative_to(ROOT), f"usr/libexec/cybex_hermes/{source.name}") - # Voxtype's RPM owns its system user unit. The session target starts that - # unit; do not collide with it by packaging our per-user Ansible template. - write(f"{seed}/.npmrc", "prefix=${HOME}/.npm-global\n") - write("usr/share/cybexos/applications.json", json.dumps(applications, indent=2) + "\n") - copy("roles/base/files/cybexos-system-snapshot", "usr/libexec/cybexos-system-snapshot", True) - # Settings runs this through pkexec; provisioning applies it from the role. - copy("roles/base/files/cybexos-auth-policy", "usr/libexec/cybexos-auth-policy", True) - for name in ("cybexos-update-transaction", "cybexos-update-bootstrap", "cybexos-update-recover", "cybexos-vendor-paths.json", "cybexos-major-upgrade"): - copy(f"roles/base/files/{name}", f"usr/libexec/{name}", not name.endswith('.json')) - for name in ("cybexos-update-recover.service", "cybexos-update-recover-login.conf"): - copy(f"roles/base/files/{name}", f"usr/libexec/{name}") - recover = (ROOT / "roles/base/files/cybexos-update-recover.service").read_text() - write("usr/lib/systemd/system/cybexos-update-recover.service", - recover.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - for unit in ("systemd-user-sessions.service", "sddm.service"): - copy("roles/base/files/cybexos-update-recover-login.conf", - f"usr/lib/systemd/system/{unit}.d/60-cybexos-update-recover.conf") - for name in ("cybexos-major-upgrade-validate.service", "cybexos-major-upgrade-validate.timer"): - content = (ROOT / "roles/base/files" / name).read_text() - write(f"usr/lib/systemd/system/{name}", - content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - # Background update downloads, as the base role installs them. Shipped - # off: %posttrans never enables the timer, so systemd keeps the person's - # choice across package updates. - copy("roles/base/files/cybexos-update-download", "usr/libexec/cybexos-update-download", True) - for name in ("cybexos-update-download.service", "cybexos-update-download.timer"): - content = (ROOT / "roles/base/files" / name).read_text() - write(f"usr/lib/systemd/system/{name}", - content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - # `sudo cybexos-system-snapshot ...`, as documented; libexec is not on PATH. - (payload / "usr/bin").mkdir(parents=True, exist_ok=True) - (payload / "usr/bin/cybexos-system-snapshot").symlink_to("../libexec/cybexos-system-snapshot") - # Recovery booting: vendor locations for dracut and kernel-install; GRUB - # reads /etc/grub.d only. The installer's grub2-mkconfig and dracut runs - # pick these up for the installed system. - recovery = "roles/base/files/recovery" - copy(f"{recovery}/42_cybexos_recovery", "etc/grub.d/42_cybexos_recovery", True) - copy(f"{recovery}/95-cybexos-recovery.install", "usr/lib/kernel/install.d/95-cybexos-recovery.install", True) - copy(f"{recovery}/90-cybexos-recovery.conf", "usr/lib/dracut/dracut.conf.d/90-cybexos-recovery.conf") - for name in ("module-setup.sh", "cybexos-recovery-overlay.sh"): - copy(f"{recovery}/90cybexos-recovery/{name}", f"usr/lib/dracut/modules.d/90cybexos-recovery/{name}", True) - refresh = (ROOT / recovery / "cybexos-recovery-refresh.service").read_text() - write("usr/lib/systemd/system/cybexos-recovery-refresh.service", - refresh.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - for name in ("cybexos-session-action", "hyprland-session-start"): - output_name = name if name.startswith("cybexos") else "cybexos-" + name - copy(f"roles/desktop/files/{name}", f"usr/libexec/{output_name}", True) - # Login policy and authentication use the same sources as workstation - # deployment. SDDM owns its PAM file; our post-transaction helper installs - # this template with a backup instead of creating an RPM file conflict. - copy("roles/desktop/files/login/cybexos-login-prepare", "usr/libexec/cybexos-login-prepare", True) - copy("roles/desktop/files/login/sddm-autologin", "usr/share/cybexos/login/sddm-autologin") - copy("roles/desktop/files/login/50-cybexos.conf", "usr/lib/systemd/system/sddm.service.d/50-cybexos.conf") - for source in sorted((ROOT / "image/rootfs").rglob("*")): - if source.is_file() and "__pycache__" not in source.parts and source.suffix != ".pyc": - relative = source.relative_to(ROOT / "image/rootfs") - copy(source.relative_to(ROOT), relative, relative.parts[1] in ("bin", "libexec")) - prepare_defaults(ROOT, payload, environment, inventory) - prepare_session(ROOT, payload, inventory) - prepare_provision(ROOT, payload) - copy("image/library/cybexos_managed_file.py", "usr/share/cybexos/lib/managed_files.py") - copy("image/release_metadata.py", "usr/share/cybexos/lib/release_metadata.py") - copy("image/installation_policy.py", "usr/share/cybexos/lib/installation_policy.py") - copy("roles/apps/files/cybexos-repository-policy", "usr/libexec/cybexos-repository-policy", True) - # The workstation's font family mappings; roles/apps/tasks/fonts.yml - # installs the same file on checkout deployments. - copy("roles/apps/files/49-cybexos-defaults.conf", "etc/fonts/conf.d/49-cybexos-defaults.conf") - # The same IBus unit and shortcut default as workstation deployment. Fedora's - # glib2 file trigger compiles the schema override when the RPM installs. - copy("roles/desktop/files/cybexos-input-method.service", "usr/lib/systemd/user/cybexos-input-method.service") - copy("roles/desktop/files/90-cybexos-ibus.gschema.override", - "usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override") - # Share the Files/GVfs default with checkout installations. - copy("roles/desktop/files/90-cybexos-smb.gschema.override", - "usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override") - for name in ("brave-browser", "1password", "chatgpt", "tailscale"): - copy(f"roles/apps/files/{name}.repo", f"usr/share/cybexos/repository-policy/{name}.repo") - prepare_font_licenses(payload, inventory) - relativize_seed_links(payload / seed, seed_home) - split_seed(payload / "usr/share/cybexos", contract) - prepare_managed_defaults(payload / "usr/share/cybexos") - offline = offline_configuration(ROOT) - write("usr/share/cybexos/offline-inputs.json", json.dumps(offline, indent=2) + "\n") - manifest = {} - for file in sorted(payload.rglob("*")): - if file.is_symlink(): - manifest[str(file.relative_to(payload))] = {"link": str(file.readlink())} - elif file.is_file(): - file.chmod(0o755 if file.stat().st_mode & 0o111 else 0o644) - with file.open("rb") as stream: - manifest[str(file.relative_to(payload))] = hashlib.file_digest(stream, "sha256").hexdigest() - elif file.is_dir(): - file.chmod(0o755) - # The complete payload identity changes on every effective package change. - identity = hashlib.sha256(json.dumps(manifest, sort_keys=True).encode()).hexdigest() - write("usr/share/cybexos/reconcile-version", identity + "\n") - manifest["usr/share/cybexos/reconcile-version"] = hashlib.sha256((identity + "\n").encode()).hexdigest() - (build / "package-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") - sources = build / "rpmbuild/SOURCES" - sources.mkdir(parents=True) - (sources / "offline.files").write_text("\n".join(file_lists(payload)) + "\n") - (build / "offline-payload.json").write_text(json.dumps({**offline, **offline_inventory(payload)}, indent=2) + "\n") - spec = render_spec((ROOT / "image/cybexos-desktop.spec").read_text(), ROOT / "VERSION", provenance) - native = json.loads((build / "titlebars.json").read_text()) - hyprland_rpm = native["inputs"]["hyprland"]["rpm"].rsplit(".", 1)[0] - spec = spec.replace("# Native compositor EVR requirement inserted by image/package", - "Requires: hyprland = " + hyprland_rpm) - spec = spec.replace("%global offline_version 1.0.0", "%global offline_version " + offline["version"]) - spec = spec.replace("%global offline_inputs unconfigured", "%global offline_inputs " + offline["inputs_sha256"]) - (build / "desktop.spec").write_text(spec) - (build / "live.ks").write_text(include_applications( - (ROOT / "image/live.ks").read_text(), applications["packages"], - )) - with tarfile.open(sources / "desktop.tar", "w") as archive: - archive.add(payload / "usr", arcname="usr") - archive.add(payload / "opt", arcname="opt") - archive.add(payload / "etc", arcname="etc") - - -if __name__ == "__main__": - main() diff --git a/image/parity-exceptions.json b/image/parity-exceptions.json deleted file mode 100644 index 0f4110d1..00000000 --- a/image/parity-exceptions.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "iso": { - "cybexos-desktop.x86_64": "The ISO delivers the shared desktop payload as an RPM; checkout installs the same source through Ansible.", - "cybexos-offline-apps.x86_64": "The ISO delivers the shared application seed as an independently versioned RPM; checkout uses the same application roles." - }, - "checkout": { - "gpg-pubkey.(none)=56f49901ab19baf099a95a76c3de1dd4f661cdcb-63ab09ad": "Exact Brave prerelease signing key shipped by signed brave-keyring 1.19-1 and imported by its background postinstall script on running Fedora. The ISO's initial chroot lacks timeout during that transaction, so its importer does not run. Both paths retain the same signed keyring package and pinned stable repository; no prerelease repository is enabled.", - "gpg-pubkey.(none)=8c1f16ab24df8f75c1cf56595929a141e0e87f1f-67d85d48": "Exact Brave prerelease signing key shipped by signed brave-keyring 1.19-1 and imported by its background postinstall script on running Fedora. The ISO's initial chroot lacks timeout during that transaction, so its importer does not run. Both paths retain the same signed keyring package and pinned stable repository; no prerelease repository is enabled.", - "gpg-pubkey.(none)=b721e073b7ef8e56acc6b23ecbc67d2399225ccf-68908a61": "Exact Brave prerelease signing key shipped by signed brave-keyring 1.19-1 and imported by its background postinstall script on running Fedora. The ISO's initial chroot lacks timeout during that transaction, so its importer does not run. Both paths retain the same signed keyring package and pinned stable repository; no prerelease repository is enabled.", - "cloud-init.noarch": "The pinned Fedora Cloud base uses cloud-init only to provision this disposable qualification guest.", - "cloud-utils-growpart.noarch": "The pinned Fedora Cloud base expands only its disposable virtual disk during first boot." - } -} diff --git a/image/parity_qualification.py b/image/parity_qualification.py deleted file mode 100644 index 1214644c..00000000 --- a/image/parity_qualification.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Shared real-desktop and saved-preference checks for both installer paths.""" -from pathlib import Path -import subprocess - -ROOT = Path(__file__).resolve().parents[1] - - -def desktop_lifecycle(vm): - helper = (ROOT / 'tests/lib/quickshell-live').read_text() - test = (ROOT / 'tests/system-settings-live').read_text().split('qs_live_begin\n', 1)[1] - script = 'set -euo pipefail\nexport XDG_RUNTIME_DIR=/run/user/$(id -u)\n' + helper + '\nqs_live_begin\n' + test - try: - subprocess.run([*vm.ssh, 'bash -s'], input=script, text=True, capture_output=True, check=True, timeout=300) - except subprocess.CalledProcessError as error: - detail = ((error.stdout or '') + (error.stderr or ''))[-4000:] - raise RuntimeError('Managed Settings lifecycle failed: ' + detail) from error - - -SAVED_CHOICES = r''' -import json, os, pwd -from pathlib import Path -account=pwd.getpwnam('qualification') -home=Path(account.pw_dir) -path=home / '.config/cybexos/shell.json' -data=json.loads(path.read_text()) -data['position']='bottom' -data['qualificationFuture']={'preserve':[False,0,'user-owned']} -path.write_text(json.dumps(data,indent=2)+'\n') -files={ - '.config/cybexos/input.json': json.dumps({'v':1,'keyboard':{'layouts':[{'layout':'us','variant':''},{'layout':'nl','variant':''}], 'shortcut':'grp:alt_shift_toggle'}, 'touchpad':{'tap':False,'naturalScroll':False,'sensitivity':0.25},'qualificationFuture':True})+'\n', - '.config/cybexos/hypr/user.lua': 'hl.config({ input = { repeat_rate = 37 } })\n', - 'qualification-personal-marker': 'qualification-preserve\n', -} -for relative, value in files.items(): - target=home / relative - target.parent.mkdir(parents=True,exist_ok=True) - target.write_text(value) - os.chown(target,account.pw_uid,account.pw_gid) -os.chown(path,account.pw_uid,account.pw_gid) -''' - - -def prepare_saved_choices(vm, password, root_script): - root_script(vm, "python3 - <<'PY'\n" + SAVED_CHOICES + '\nPY\n', password) - - -def verify_saved_choices(vm, password, root_script): - root_script(vm, r'''python3 - <<'PY' -import json -from pathlib import Path -home=Path('/home/qualification') -data=json.loads((home / '.config/cybexos/shell.json').read_text()) -assert data['position']=='bottom' -assert data['qualificationFuture']=={'preserve':[False,0,'user-owned']} -assert (home / '.config/cybexos/hypr/user.lua').read_text()=='hl.config({ input = { repeat_rate = 37 } })\n' -assert json.loads((home / '.config/cybexos/input.json').read_text())['qualificationFuture'] is True -assert (home / 'qualification-personal-marker').read_text()=='qualification-preserve\n' -PY -''', password) diff --git a/image/post_install_check.py b/image/post_install_check.py deleted file mode 100644 index af70e3a3..00000000 --- a/image/post_install_check.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Run the installer's post-install scripts against the composed image. - -Anaconda installs a live image by copying its root filesystem, then runs -live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks in the copy. A -failing command there aborts every installation after the boot loader step, -which source fixtures cannot reveal. The builder reproduces that step before -packaging: a throwaway overlay of the image root receives an administrator -account like the one Anaconda creates, the Live /run shape Anaconda binds into -the target, and no network. The image root itself is never modified. -""" -import json -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import types - -HOOK = 'usr/share/anaconda/post-scripts/90-cybexos.ks' -TARGET_HELPER = 'usr/libexec/cybexos-installer-target' -ACCOUNT = 'cybexcheck' - - -def chroot_post_script(text): - """Return the body of the single chroot %post section Anaconda runs.""" - sections = re.findall(r'^%post\b([^\n]*)\n(.*?)^%end\s*$', text, re.MULTILINE | re.DOTALL) - bodies = [body for options, body in sections if '--nochroot' not in options.split()] - if len(bodies) != 1: - raise ValueError('Expected exactly one chroot %post section in the installer hook') - return bodies[0] - - -def load_target_helper(root): - # Compile in memory: an import would write __pycache__ into the image root. - path = root / TARGET_HELPER - module = types.ModuleType('cybexos_installer_target') - exec(compile(path.read_text(), str(path), 'exec'), module.__dict__) - return module - - -def verify_target(root, username=ACCOUNT): - """The installed-system state the post-install scripts must leave behind.""" - problems = [] - for path in ('etc/sudoers.d/cybexos-live', 'usr/lib/systemd/system/cybexos-live.service', - 'usr/lib/systemd/user/cybexos-install-session.target', - 'usr/libexec/cybexos-installer-backend', 'usr/share/cockpit/cybexos-installer', - 'etc/dracut.conf.d/99-live.conf', 'etc/anaconda/conf.d/20-cybexos.conf'): - if (root / path).exists(): - problems.append(f'live-only /{path} remains') - if not (root / 'var/lib/systemd/linger' / username).is_file(): - problems.append('user lingering was not enabled') - try: - login = json.loads((root / 'etc/cybexos/login.json').read_text()) - if login != {'version': 1, 'user': username, 'autologin': True, 'live': False}: - problems.append(f'unexpected login policy {login}') - except (OSError, ValueError): - problems.append('the login policy is missing or invalid') - config = root / 'etc/cybexos/config.yml' - text = config.read_text() if config.is_file() else '' - if f"primary_user: '{username}'" not in text or 'desktop_autologin: true' not in text: - problems.append('the installation choices were not recorded') - if not (root / 'etc/cybexos/hardware.json').is_file(): - problems.append('hardware detection did not run') - if problems: - raise RuntimeError('Post-install check: ' + '; '.join(problems)) - - -def check_post_install(image_root, work_parent, run=subprocess.run): - image_root = Path(image_root) - script = chroot_post_script((image_root / HOOK).read_text()) - work = Path(tempfile.mkdtemp(prefix='post-install-check-', dir=work_parent)) - merged = work / 'root' - mounts = [] - - def mount(*arguments, target): - run(['mount', *arguments, str(target)], check=True) - mounts.append(target) - - try: - for name in ('upper', 'work', 'root'): - (work / name).mkdir() - mount('-t', 'overlay', 'overlay', '-o', - f'lowerdir={image_root},upperdir={work / "upper"},workdir={work / "work"}', target=merged) - for name in ('proc', 'sys', 'dev', 'dev/pts'): - mount('--bind', '/' + name, target=merged / name) - # Anaconda binds the live /run, which has /run/lock, into the target. - for name in ('run', 'tmp'): - mount('-t', 'tmpfs', '-o', 'mode=0755' if name == 'run' else 'mode=1777', 'tmpfs', target=merged / name) - (merged / 'run/lock').mkdir(mode=0o1777) - (merged / 'run/lock').chmod(0o1777) - run(['chroot', str(merged), 'useradd', '--create-home', '--groups', 'wheel', - '--comment', 'Post-install check', ACCOUNT], check=True) - (merged / 'tmp/cybexos-post.sh').write_text(script) - log = work / 'post-install.log' - with log.open('w') as stream: - result = run(['unshare', '--net', 'chroot', str(merged), '/bin/bash', '-eux', '/tmp/cybexos-post.sh'], - stdout=stream, stderr=subprocess.STDOUT, check=False) - if result.returncode: - tail = log.read_text(errors='replace').splitlines()[-80:] - raise RuntimeError('The installer post-install script failed in the composed image ' - f'(exit {result.returncode}):\n' + '\n'.join(tail)) - # The --nochroot hook finalizes the target from outside; exercise the - # same decision for a verified encrypted installation. - load_target_helper(image_root).finalize( - merged, {'phase': 'installing', 'account': {'username': ACCOUNT, 'encrypted': True, 'passwordless_wheel': False}}, True) - verify_target(merged) - finally: - stuck = [target for target in reversed(mounts) - if run(['umount', str(target)], check=False).returncode - and run(['umount', '--lazy', str(target)], check=False).returncode] - # Never recurse into a directory that may still reach the builder's - # /dev, /proc or /sys through a bind mount. - if not stuck: - shutil.rmtree(work, ignore_errors=True) diff --git a/image/prepare-github-release b/image/prepare-github-release deleted file mode 100755 index 84d7911a..00000000 --- a/image/prepare-github-release +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 -from github_release import main - -if __name__ == '__main__': - main() diff --git a/image/provision.cfg b/image/provision.cfg deleted file mode 100644 index e88a2447..00000000 --- a/image/provision.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[defaults] -stdout_callback = default -retry_files_enabled = False diff --git a/image/provision.yml b/image/provision.yml deleted file mode 100644 index b6cefcf2..00000000 --- a/image/provision.yml +++ /dev/null @@ -1,276 +0,0 @@ ---- -- name: Configure an installed CybexOS account with the workstation policy - hosts: localhost - connection: local - gather_facts: true - module_defaults: - ansible.builtin.dnf: - install_weak_deps: false - vars_files: - - ../inventory/group_vars/all.yml - tasks: - - name: Require the supported installation platform - ansible.builtin.assert: - that: - - ansible_facts.distribution == 'Fedora' - - ansible_facts.distribution_major_version == fedora_release - - ansible_facts.architecture == 'x86_64' - - primary_user != 'root' - - primary_user != 'liveuser' - - - name: Apply the common installed-system policy - ansible.builtin.include_role: - name: base - tasks_from: "{{ (cybexos_reconcile | default(false) | bool) | ternary('reconcile', 'image') }}" - when: - - not cybexos_hardware_only | bool - - - name: Apply the shared Fish configuration - ansible.builtin.include_role: - name: dotfiles - tasks_from: shell-defaults - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Prefer reliable hardware video decoding in mpv - ansible.builtin.include_role: - name: apps - tasks_from: mpv - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - # The workstation installer manages personal defaults for accounts that - # have none of their own; every image account starts from the seed. A - # saved configuration's choice arrives as extra vars, which always win. - - name: Manage personal defaults on image installations by default - ansible.builtin.set_fact: - manage_personal_dotfiles: true - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Apply the shared session environment - ansible.builtin.include_role: - name: dotfiles - tasks_from: environment - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - # Runs before the account is seeded: the Kitty include and fragment it - # writes are what the seed would provide, so the seed leaves them alone. - - name: Apply the shared personal defaults - ansible.builtin.include_role: - name: dotfiles - tasks_from: personal - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Link the packaged CybexOS agent skill for coding agents - ansible.builtin.include_role: - name: dotfiles - tasks_from: agent-skills - vars: - dotfiles_agent_skill_canonical: /usr/share/cybexos/agent-skills/cybexos - when: not cybexos_hardware_only | bool - - - name: Apply the shared portal preferences - ansible.builtin.include_role: - name: desktop - tasks_from: portals - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - # The compose installs the CPU-default selector; the image builder's own - # selection never reaches the image. A GPU-less machine keeps the CPU. - - name: Select the Voxtype GPU backend - ansible.builtin.include_role: - name: apps - tasks_from: voxtype-backend - vars: - apps_voxtype_gpu_optional: true - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - features.developer_tools | bool - - # The package's user units carry the inventory's feature defaults. The - # saved configuration can differ; system-wide user-unit configuration - # applies it to every account without editing a packaged file. - - name: Apply the configured desktop features to every account - when: not cybexos_hardware_only | bool - block: - - name: Create the Quickshell feature drop-in directory - ansible.builtin.file: - path: /etc/systemd/user/quickshell.service.d - state: directory - mode: "0755" - - - name: Record the configured shell features - ansible.builtin.copy: - dest: /etc/systemd/user/quickshell.service.d/50-cybexos-features.conf - mode: "0644" - content: | - [Service] - Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} - Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} - register: image_shell_features - when: not cybexos_reconcile | default(false) | bool - - - name: Reconcile the configured shell features while preserving local edits - cybexos_managed_file: - dest: /etc/systemd/user/quickshell.service.d/50-cybexos-features.conf - mode: "0644" - content: | - [Service] - Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} - Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} - register: image_reconcile_shell_features - when: cybexos_reconcile | default(false) | bool - - - name: Inspect the Hermes bridge mask - ansible.builtin.stat: - path: /etc/systemd/user/hermes-menubar-bridge.service - follow: false - register: image_hermes_mask - - # The workstation installs the bridge only with connected widgets. - # The session target wants it, so a mask is how an image omits it. - - name: Mask the Hermes bridge when connected widgets are deselected - ansible.builtin.file: - src: /dev/null - dest: /etc/systemd/user/hermes-menubar-bridge.service - state: link - register: image_hermes_masked - when: - - not features.connected_widgets | bool - - not image_hermes_mask.stat.exists - - - name: Unmask the Hermes bridge when connected widgets are selected - ansible.builtin.file: - path: /etc/systemd/user/hermes-menubar-bridge.service - state: absent - register: image_hermes_unmasked - when: - - features.connected_widgets | bool - - not cybexos_reconcile | default(false) | bool - - image_hermes_mask.stat.islnk | default(false) - - image_hermes_mask.stat.lnk_source | default('') == '/dev/null' - - # The running shell keeps its environment; the next login applies it. - # Use the local account bus, without a transient --machine bridge. - - name: Inspect the account's runtime bus - ansible.builtin.stat: - path: "/run/user/{{ ansible_facts.getent_passwd[primary_user][1] }}/bus" - register: image_account_bus - when: not cybexos_offline | bool - - - name: Reload the account's user manager after a feature change - ansible.builtin.command: - argv: - - /usr/bin/timeout - - --kill-after=5s - - 30s - - /usr/sbin/runuser - - -u - - "{{ primary_user }}" - - -- - - /usr/bin/env - - -i - - PATH=/usr/sbin:/usr/bin:/sbin:/bin - - "HOME={{ primary_home }}" - - LANG=C.UTF-8 - - PYTHONDONTWRITEBYTECODE=1 - - "XDG_RUNTIME_DIR=/run/user/{{ ansible_facts.getent_passwd[primary_user][1] }}" - - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ ansible_facts.getent_passwd[primary_user][1] }}/bus" - - /usr/bin/systemctl - - --user - - daemon-reload - changed_when: true - when: - - not cybexos_offline | bool - - image_account_bus.stat.exists | default(false) - - image_shell_features is changed or image_reconcile_shell_features is changed or image_hermes_masked is changed or image_hermes_unmasked is changed - - - name: Detect hardware during offline installation as well as repair - ansible.builtin.include_role: - name: xps-2026 - tasks_from: detect - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Detect an NVIDIA GPU during offline installation as well as repair - ansible.builtin.include_role: - name: nvidia - tasks_from: detect - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Create the machine policy directory - ansible.builtin.file: - path: /etc/cybexos - state: directory - mode: "0755" - - # The welcome window and doctor read this before the first hardware - # setup has run; the setup itself detects again after boot. - - name: Record the detected desktop hardware policy - ansible.builtin.copy: - dest: /etc/cybexos/hardware.json - content: "{{ {'xps_2026': xps_2026_is_supported | bool, 'nvidia': nvidia_is_present | bool} | to_json }}\n" - mode: "0644" - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Read the hardware account identity - ansible.builtin.getent: - database: passwd - key: "{{ primary_user }}" - when: cybexos_hardware_only | bool - - - name: Apply the detected workstation hardware role - ansible.builtin.include_role: - name: xps-2026 - when: cybexos_hardware_only | bool - - # The same role as the checkout installation. Its outcome joins the - # hardware result below; a failed driver never fails this play. - - name: Apply the NVIDIA driver role on detected NVIDIA hardware - ansible.builtin.include_role: - name: nvidia - vars: - nvidia_retry_command: cybex repair --hardware - nvidia_setup_automatic: "{{ cybexos_hardware_automatic | default(false) | bool }}" - when: cybexos_hardware_only | bool - - - name: Require a working or reboot-pending camera on matching hardware - ansible.builtin.assert: - that: - - xps_2026_camera_ready | default(false) | bool or xps_2026_camera_reboot_required | default(false) | bool or xps_2026_camera_preserve_previous | default(false) | bool - fail_msg: "The detected camera setup did not complete. Inspect the preceding hardware diagnostics and retry cybex repair --hardware." - when: - - cybexos_hardware_only | bool - - xps_2026_is_supported | bool - - xps_2026_camera_enabled | bool - - xps_2026_has_ovti08f4 | bool - - xps_2026_has_ipu7 | bool - - - name: Record the hardware role outcome for the boot-aware setup wrapper - ansible.builtin.copy: - dest: /var/lib/cybexos/hardware-result.json - mode: "0644" - content: >- - {{ {'reboot_required': xps_2026_camera_reboot_required | default(false) | bool, - 'camera_ready': xps_2026_camera_ready | default(false) | bool, - 'preserved_previous': xps_2026_camera_preserve_previous | default(false) | bool, - 'nvidia': {'state': (nvidia_status | default({'state': 'absent'})).state, - 'key': (nvidia_status | default({})).key | default(''), - 'reason': (nvidia_status | default({})).reason | default('')}} | to_json }} - when: cybexos_hardware_only | bool diff --git a/image/provision_payload.py b/image/provision_payload.py deleted file mode 100644 index 33157b92..00000000 --- a/image/provision_payload.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Install the reviewed shared provisioning sources, excluding personal state.""" -import shutil - - -def prepare_provision(root, payload): - destination = payload / 'usr/share/cybexos/provision' - for relative in ('image/provision.yml', 'image/provision.cfg', 'image/library', - 'image/system_packages.py', 'image/live.ks', 'image/cybexos-desktop.spec', - 'inventory/group_vars/all.yml', - 'roles/base', 'roles/xps-2026', 'roles/nvidia', 'roles/apps/tasks/mpv.yml', - # The NVIDIA role enables RPM Fusion with the applications role's pinned keys. - 'roles/apps/tasks/rpmfusion.yml', 'roles/apps/files/rpmfusion-free-fedora-2020.asc', - 'roles/apps/files/rpmfusion-nonfree-fedora-2020.asc', - 'roles/apps/tasks/voxtype-backend.yml', 'roles/apps/handlers/main.yml', - 'roles/dotfiles/tasks/shell-defaults.yml', - 'roles/dotfiles/files/fish-config.fish', - 'roles/dotfiles/tasks/environment.yml', 'roles/dotfiles/templates/environment.conf.j2', - 'roles/dotfiles/tasks/personal.yml', 'roles/dotfiles/files/kitty.conf', - 'roles/dotfiles/files/manage-firefox-policy', 'roles/dotfiles/files/ssh.conf', - 'roles/dotfiles/files/gitconfig', - 'roles/dotfiles/tasks/agent-skills.yml', 'scripts/manage-agent-skills', - 'roles/desktop/tasks/portals.yml'): - source = root / relative - paths = sorted(source.rglob('*')) if source.is_dir() else [source] - for path in paths: - if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts or path.suffix == '.pyc': - continue - target = destination / path.relative_to(root) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(path, target) - target.chmod(0o755 if path.stat().st_mode & 0o111 else 0o644) diff --git a/image/public_update_qualification.py b/image/public_update_qualification.py deleted file mode 100644 index 926db97e..00000000 --- a/image/public_update_qualification.py +++ /dev/null @@ -1,1064 +0,0 @@ -"""Public updater crash/recovery qualification in the existing encrypted guest. - -The guest-only DNF adapter selects the already checksum/signature-verified -local candidate transaction, with repositories disabled. It executes real DNF; -no updater, health, storage, authorization or recovery code is substituted. -""" -import base64 -import inspect -import json -import os -from pathlib import Path -import re -import selectors -import shlex -import signal -import subprocess -import sys -import time - -from build_support import digest -from vm_testing import run - -ROOT = Path(__file__).resolve().parents[1] -WORK = '/run/cybexos-qualification-public' -SOURCE = WORK + '/source' -UPDATER = SOURCE + '/assets/scripts/cybexos-update-run' -OBSERVER = '/usr/local/libexec/cybexos-qualification-recovery-observer' -UNIT = 'cybexos-qualification-recovery-observer.service' -EVIDENCE = '/home/qualification/qualification-public-recovery.json' -# Use an actual vendor-manifest member whose original bytes are recorded. -VENDOR = '/home/qualification/.local/share/applications/localsend-share-folder.desktop' - -# One root process, authenticated before launch, owns the control protocol for -# exactly one boot/cycle. Opening another SSH session after DNF can block in -# pam_systemd while PID 1 reconnects its public bus. This channel is control -# only: PUBLIC_HEALTH must still run through fresh ordinary SSH after it closes. -CONTROL_SERVER = r''' -import base64, ctypes, json, os, pathlib, selectors, signal, subprocess, sys, time -boot_path = pathlib.Path('/proc/sys/kernel/random/boot_id') -boot = boot_path.read_text().strip() -deadline = time.monotonic() + 1500 -# Adopt escaped command descendants so EOF, cancellation and timeouts can -# terminate/reap them too, including children which called setsid(). -if ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) != 0: - raise RuntimeError('Cannot own control command descendants') -def interrupted(*_args): - raise RuntimeError('Control process interrupted') -for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): - signal.signal(sig, interrupted) -def emit(event, **values): - print(json.dumps(dict(event=event, boot=boot_path.read_text().strip(), **values)), flush=True) -def reap(): - # Each request's children are private to this process. Kill by parentage, - # then reap adopted descendants; never signal an unrelated guest process. - for _ in range(100): - children = [] - for entry in pathlib.Path('/proc').glob('[0-9]*/stat'): - try: - fields = entry.read_text().rsplit(')', 1)[1].split() - if int(fields[1]) == os.getpid(): - children.append(int(entry.parent.name)) - except (FileNotFoundError, ProcessLookupError): - pass - for pid in children: - try: os.kill(pid, signal.SIGKILL) - except ProcessLookupError: pass - while True: - try: pid, _ = os.waitpid(-1, os.WNOHANG) - except ChildProcessError: return - if not pid: break - time.sleep(.01) - raise RuntimeError('Control command descendants did not drain') -buffer = b'' -emit('ready', protocol=1, uid=os.geteuid()) -sequence = 0 -try: - while True: - with selectors.DefaultSelector() as poll: - poll.register(0, selectors.EVENT_READ) - while b'\n' not in buffer: - remaining = deadline - time.monotonic() - if remaining <= 0 or not poll.select(remaining): - raise RuntimeError('Control cycle deadline expired') - chunk = os.read(0, 65536) - if not chunk: raise EOFError('Control input closed') - buffer += chunk - if len(buffer) > 4194304: raise ValueError('Excessive control request') - line, buffer = buffer.split(b'\n', 1) - request = json.loads(line) - sequence += 1 - if type(request.get('id')) is not int or request['id'] != sequence or boot_path.read_text().strip() != boot: - raise ValueError('Control sequence or boot changed') - if request == {'id': sequence, 'op': 'close'}: - emit('closed', id=sequence) - break - if (set(request) != {'id', 'op', 'script', 'timeout'} or request['op'] != 'run' - or not isinstance(request['script'], str) - or not 0 < request['timeout'] <= 120): - raise ValueError('Invalid control request') - end = min(deadline, time.monotonic() + request['timeout']) - process = subprocess.Popen(['bash', '-e', '-s'], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - start_new_session=True) - try: - # Scripts are bounded; nonblocking input also handles a command - # which exits without reading its entire script. - pending = memoryview(request['script'].encode()) - with selectors.DefaultSelector() as poll: - poll.register(0, selectors.EVENT_READ, 'control') - for stream, name in ((process.stdout, 'stdout'), (process.stderr, 'stderr')): - os.set_blocking(stream.fileno(), False) - poll.register(stream, selectors.EVENT_READ, name) - os.set_blocking(process.stdin.fileno(), False) - poll.register(process.stdin, selectors.EVENT_WRITE, 'script') - while len(poll.get_map()) > 1: - remaining = end - time.monotonic() - if remaining <= 0: raise TimeoutError('Control request expired') - for key, _ in poll.select(remaining): - if key.data == 'control': - # The protocol is strictly request/response. Extra - # input or EOF during execution invalidates it. - raise EOFError('Control input changed during request') - if key.data == 'script': - try: pending = pending[os.write(key.fd, pending):] - except BrokenPipeError: pending = memoryview(b'') - if not pending: - poll.unregister(key.fileobj) - key.fileobj.close() - else: - data = os.read(key.fd, 32768) - if data: - emit('output', id=sequence, stream=key.data, - data=base64.b64encode(data).decode('ascii')) - else: - poll.unregister(key.fileobj) - key.fileobj.close() - code = process.wait(timeout=max(.001, end - time.monotonic())) - finally: - if process.poll() is None: - try: os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: pass - # Reap the direct child through Popen, then all adopted children. - process.wait() - for stream in (process.stdin, process.stdout, process.stderr): - if not stream.closed: stream.close() - reap() - emit('result', id=sequence, returncode=code) -finally: - reap() -''' - - -class PublicControl: - """Bounded, non-reconnecting root control; never qualifies public health.""" - - def __init__(self, vm, password): - self.vm, self.password = vm, password - self.process = None - self.broken = False - self.boot = None - self.sequence = 0 - self.buffer = b'' - self.launch_attempted = False - self.identifier = None - self.drained = False - self.drain_attempted = False - self.keep_fixture = False - - @property - def available(self): - return self.process is not None and not self.broken and self.process.poll() is None - - def remaining(self, bound): - remaining = min(bound, self.deadline - time.monotonic()) - if remaining <= 0: - self.broken = True - raise RuntimeError('Public control cycle exceeded the qualification deadline') - return remaining - - def __enter__(self): - self.deadline = time.monotonic() + 1500 - try: - command = "sudo -k -S -p '' python3 -u -c " + shlex.quote(CONTROL_SERVER) - self.process = subprocess.Popen([*self.vm.ssh, command], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - start_new_session=True) - for stream in (self.process.stdin, self.process.stdout, self.process.stderr): - os.set_blocking(stream.fileno(), False) - end = time.monotonic() + self.remaining(15) - self._send((self.password + '\n').encode(), end) - ready = self._receive(end) - if (set(ready) != {'event', 'boot', 'protocol', 'uid'} or ready['event'] != 'ready' - or type(ready['protocol']) is not int or ready['protocol'] != 1 - or type(ready['uid']) is not int or ready['uid'] != 0 - or not isinstance(ready['boot'], str) - or not re.fullmatch(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', ready['boot'])): - raise RuntimeError('Invalid public control ready handshake') - self.boot = ready['boot'] - return self - except BaseException as error: - self.broken = True - self.close(failed=True) - if isinstance(error, (EOFError, TimeoutError, ValueError, OSError)): - raise RuntimeError('Public control failed before ready: ' + type(error).__name__) from None - raise - - def _send(self, data, end): - with selectors.DefaultSelector() as poll: - poll.register(self.process.stdin, selectors.EVENT_WRITE) - while data: - if time.monotonic() >= end or not poll.select(max(0, end - time.monotonic())): - raise TimeoutError('Public control request timed out') - data = data[os.write(self.process.stdin.fileno(), data):] - - def _receive(self, end): - with selectors.DefaultSelector() as poll: - poll.register(self.process.stdout, selectors.EVENT_READ, 'protocol') - poll.register(self.process.stderr, selectors.EVENT_READ, 'diagnostic') - while b'\n' not in self.buffer: - if time.monotonic() >= end: - raise TimeoutError('Public control request timed out') - events = poll.select(max(0, end - time.monotonic())) - if not events: - raise TimeoutError('Public control request timed out') - for key, _ in events: - data = os.read(key.fd, 65536) - if key.data == 'diagnostic': - # Authentication diagnostics are never protocol or - # health evidence, and may contain sensitive text. - if not data: - poll.unregister(key.fileobj) - continue - if not data: - raise EOFError('Public control SSH closed') - self.buffer += data - if len(self.buffer) > 4194304: - raise ValueError('Excessive public control response') - line, self.buffer = self.buffer.split(b'\n', 1) - value = json.loads(line) - if not isinstance(value, dict): - raise ValueError('Invalid public control response') - return value - - def __call__(self, _vm, script, _password, timeout=120, *, shutdown=False): - if not self.available: - raise RuntimeError('Public control is unavailable; reconnect is forbidden') - output = {'stdout': bytearray(), 'stderr': bytearray()} - try: - bound = self.remaining(min(timeout, 120)) - end = time.monotonic() + bound - self.sequence += 1 - request = {'id': self.sequence, 'op': 'run', 'script': script, 'timeout': bound} - self._send((json.dumps(request) + '\n').encode(), end) - while True: - value = self._receive(end) - if (value.get('boot') != self.boot or type(value.get('id')) is not int - or value['id'] != self.sequence): - raise ValueError('Public control boot or sequence changed') - if (set(value) == {'event', 'boot', 'id', 'stream', 'data'} - and value['event'] == 'output' and value['stream'] in output): - output[value['stream']] += base64.b64decode(value['data'], validate=True) - if sum(map(len, output.values())) > 8388608: - raise ValueError('Excessive public control output') - elif (set(value) == {'event', 'boot', 'id', 'returncode'} - and value['event'] == 'result' and type(value['returncode']) is int): - break - else: - raise ValueError('Malformed public control response') - except BaseException as error: - self.broken = True - # Only poweroff_guest may accept the expected shutdown disconnect, - # after seeing its unpredictable preparation marker and QEMU exit. - if shutdown and isinstance(error, EOFError): - raise subprocess.CalledProcessError(255, ['public-control-shutdown'], - output=output['stdout'].decode(errors='replace'), - stderr=output['stderr'].decode(errors='replace')) from None - if isinstance(error, (EOFError, TimeoutError, ValueError, OSError)): - raise RuntimeError('Public control failed: ' + type(error).__name__) from None - raise - result = subprocess.CompletedProcess(['public-control'], value['returncode'], - output['stdout'].decode(errors='replace'), output['stderr'].decode(errors='replace')) - if result.returncode: - raise subprocess.CalledProcessError(result.returncode, result.args, - output=result.stdout.replace(self.password, '[redacted]'), - stderr=result.stderr.replace(self.password, '[redacted]')) - return result - - def start(self): - self.launch_attempted = True - self.identifier = start_public(self.vm, self.password, timeout=self.remaining(90)) - # Detect a changed boot even when the ordinary launcher returned a - # plausible run record. No evidence is awarded on this channel alone. - self(self.vm, 'true\n', self.password) - return self.identifier - - def ensure_drained(self): - if self.drained: - return True - if self.drain_attempted or not self.available: - return False - if self.launch_attempted and not self.identifier: - return False - self.drain_attempted = True - if self.identifier: - stop_interrupted(self.vm, self.password, self, self.identifier, {}, strict=False) - self.drained = True - return True - - def cleanup_staging(self, vm, script, password, timeout=30): - if self.ensure_drained(): - self(vm, script, password, timeout=timeout) - # Lost control leaves guest files/workers to the owned VM teardown. - - def poweroff(self): - from vm_testing import poweroff_guest - def execute(vm, script, password): - return self(vm, script, password, shutdown='systemctl poweroff --no-block\n' in script) - process = self.vm.process - control = self - class BoundedProcess: - def wait(self, timeout): - # Preparation consumed part of the cycle too. Recompute at - # the actual QEMU wait, retaining the existing 90s upper bound. - return process.wait(timeout=control.remaining(timeout)) - def __getattr__(self, name): - return getattr(process, name) - self.vm.process = BoundedProcess() - try: - poweroff_guest(self.vm, self.password, execute, timeout=self.remaining(90)) - finally: - self.vm.process = process - self.keep_fixture = True # The pre-login observer must survive reboot. - - def close(self, *, failed=False): - if self.process is None: - return - error = None - try: - if self.available and not self.keep_fixture: - end = time.monotonic() + self.remaining(5) - self.sequence += 1 - self._send((json.dumps({'id': self.sequence, 'op': 'close'}) + '\n').encode(), end) - reply = self._receive(end) - if (type(reply.get('id')) is not int - or reply != {'event': 'closed', 'boot': self.boot, 'id': self.sequence}): - raise RuntimeError('Invalid public control close acknowledgement') - elif not failed and not self.keep_fixture: - raise RuntimeError('Public control was lost before close') - except BaseException as caught: - error = caught - finally: - self.broken = True - self.process.stdin.close() - try: - code = self.process.wait(timeout=3) - expected = (0, 255) if self.keep_fixture else (0,) - if code not in expected and not failed and error is None: - error = RuntimeError('Public control SSH exited unsuccessfully') - except subprocess.TimeoutExpired: - try: - os.killpg(self.process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - self.process.wait(timeout=3) - except subprocess.TimeoutExpired: - try: - os.killpg(self.process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - self.process.wait() - if not failed and error is None: - error = RuntimeError('Public control SSH did not close') - self.process.stdout.close() - self.process.stderr.close() - if error is not None and not failed: - raise error - - def __exit__(self, kind, _value, _traceback): - failed = kind is not None - try: - if not self.keep_fixture and self.ensure_drained(): - cleanup_fixture(self.vm, self.password, self) - except BaseException: - if not failed: - raise - finally: - self.close(failed=failed or sys.exc_info()[0] is not None) - -# This process runs before the recovery service, independently of logins, and -# writes outside root so the first recovery boot's evidence survives root swap. -OBSERVER_SCRIPT = r'''#!/usr/bin/python3 -import glob, json, pathlib, signal, subprocess, time -# Reboot may be queued immediately after the durable terminal write. Finish -# observing that write before our unit stops; mixed kill mode keeps the probe alive. -signal.signal(signal.SIGTERM, lambda *_args: None) -home = pathlib.Path('/home/qualification') -output = home / 'qualification-public-recovery.json' -identifier = (home / 'qualification-public-run-id').read_text().strip() -boot = pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip() -if output.exists() and json.loads(output.read_text()).get('state') == 'rolled-back': - pathlib.Path('/run/cybexos-qualification-observer-ready').touch() - raise SystemExit(0) -seen = [] -for _ in range(12000): - candidates = ['/usr/libexec/cybexos-update-transaction'] + glob.glob( - '/usr/local/libexec/cybexos-update-bootstrap.d/*/cybexos-update-transaction') - helper = next((p for p in candidates if pathlib.Path(p).is_file()), None) - if helper: - result = subprocess.run([helper, 'status', identifier], capture_output=True, text=True, timeout=30) - if result.returncode == 0: - record = json.loads(result.stdout) - if record['state'] in ('applying', 'rolling-back', 'rolled-back'): - # Check the real systemd ordering barrier while recovery runs. - for unit in ('sddm.service', 'systemd-user-sessions.service'): - state = subprocess.check_output(['systemctl', 'show', unit, '-p', 'ActiveState', '--value'], text=True).strip() - assert state not in ('active', 'activating', 'reloading'), 'Login started before recovery settled' - assert pathlib.Path('/run/nologin').exists(), 'Login barrier is absent' - if record['state'] not in seen: - seen.append(record['state']) - value = {'bootId': boot, 'transaction': identifier, 'states': seen, - 'preLogin': True, 'state': record['state']} - temporary = output.with_suffix('.tmp') - temporary.write_text(json.dumps(value) + '\n') - temporary.replace(output) - pathlib.Path('/run/cybexos-qualification-observer-ready').touch() - if record['state'] == 'rolled-back': - break - elif record['state'] in ('committed', 'aborted'): - raise RuntimeError('The interrupted transaction did not recover') - time.sleep(0.1) -else: - raise RuntimeError('Boot recovery did not settle within its deadline') -''' - -DNF_ADAPTER = r'''#!/usr/bin/python3 -import hashlib, json, os, pathlib, subprocess, sys, time -work = pathlib.Path('/run/cybexos-qualification-public') -config = json.loads((work / 'dnf.json').read_text()) -args = sys.argv[1:] -expected = ['-y', 'upgrade', '--refresh', '--no-best'] -assert args in (expected, expected + ['--downloadonly']), 'Unexpected qualification DNF invocation' -for record in config['rpms']: - path = pathlib.Path(record['path']) - assert path.is_file() and not path.is_symlink() and path.stat().st_uid == 0 - assert hashlib.sha256(path.read_bytes()).hexdigest() == record['sha256'], 'Staged candidate changed' -command = ['/usr/bin/dnf', '-y', '--disablerepo=*', 'install'] -if '--downloadonly' in args: - command.append('--downloadonly') -subprocess.run(command + [r['path'] for r in config['rpms']], check=True, timeout=1200) -if '--downloadonly' not in args and config['interrupt']: - vendor = pathlib.Path(config['vendor']) - vendor.unlink() - vendor.write_bytes(b'qualification candidate vendor bytes\n') - pathlib.Path('/etc/cybexos/qualification-public-root').write_text('applied\n') - # A personal edit made AFTER the checkpoint must also survive rollback. - pathlib.Path('/home/qualification/qualification-public-personal').write_text('after-checkpoint\n') - heartbeat = work / 'heartbeat' - code = 'import pathlib,sys,time\np=pathlib.Path(sys.argv[1])\nwhile True:\n p.open("a").write("worker\\n"); time.sleep(.05)\n' - child = subprocess.Popen(['/usr/bin/python3', '-c', code, str(heartbeat)], start_new_session=True) - (work / 'applied.json').write_text(json.dumps({'pid': os.getpid(), 'detachedPid': child.pid})) - # The host kills the real updater unit here. Expiry fails the qualification - # and exercises normal updater error handling, never an unbounded fixture. - for _ in range(3000): - time.sleep(.1) - raise RuntimeError('Qualification interruption was not delivered') -''' - - -# Run only inside the already-owned candidate guest. Local fixtures substitute -# the manager boundary; this release check exercises actual systemd runtime -# limits, setsid and lock lifetime against the installed helper. -PROVISION_TIMEOUT = r'''python3 - <<'PY' -import fcntl, importlib.machinery, importlib.util, pathlib, subprocess, tempfile, time -loader = importlib.machinery.SourceFileLoader('qualification_provision', '/usr/libexec/cybexos-configure-installed') -spec = importlib.util.spec_from_loader(loader.name, loader) -module = importlib.util.module_from_spec(spec) -loader.exec_module(module) -with tempfile.TemporaryDirectory(prefix='cybexos-qualification-provision-', dir='/run') as temporary: - directory = pathlib.Path(temporary) - heartbeat = directory / 'heartbeat' - overlap = directory / 'overlap' - worker = directory / 'worker.py' - code = 'import fcntl,pathlib,sys,time\nheartbeat,overlap=map(pathlib.Path,sys.argv[1:])\nwhile True:\n with open("/run/lock/cybexos/provision.lock","a") as lock:\n try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)\n except BlockingIOError: pass\n else: overlap.touch()\n with heartbeat.open("a") as stream: stream.write("descendant\\n")\n time.sleep(.02)\n' - worker.write_text('import subprocess,sys,time\nsubprocess.Popen([sys.executable,"-c",' + repr(code) + ',sys.argv[1],sys.argv[2]],start_new_session=True)\ntime.sleep(120)\n') - environment = {'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','HOME':'/root', - 'LANG':'C.UTF-8','PYTHONDONTWRITEBYTECODE':'1'} - lockdir = pathlib.Path('/run/lock/cybexos') - lockdir.mkdir(exist_ok=True) - with (lockdir / 'provision.lock').open('a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - try: - module.contained(['/usr/bin/python3', str(worker), str(heartbeat), str(overlap)], env=environment, timeout=2) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - pass - else: - raise RuntimeError('The deliberately timed-out provisioning workload succeeded') - assert heartbeat.is_file() and heartbeat.stat().st_size > 0, 'Detached writer never ran' - settled = heartbeat.read_bytes() - assert not overlap.exists(), 'Provisioning serialization was released early' - assert not module.populated(), 'Provisioning cgroup was still populated after timeout' - time.sleep(.2) - assert heartbeat.read_bytes() == settled, 'Detached worker wrote after timeout' - retry = 'import pathlib,sys,time; p=pathlib.Path(sys.argv[1]); before=p.read_bytes(); time.sleep(.2); assert p.read_bytes()==before' - module.contained(['/usr/bin/python3','-c',retry,str(heartbeat)], env=environment, timeout=10) - assert heartbeat.read_bytes() == settled and not overlap.exists(), 'Retry overlapped a timed-out worker' -PY -''' - - -PUBLIC_HEALTH = r'''import json, os, pwd, shlex, subprocess, time -deadline = time.monotonic() + 45 -# Pin public inspection to the ordinary system bus, even if SSH inherited a -# bus-address environment variable. Never fall back to /run/systemd/private. -os.environ['DBUS_SYSTEM_BUS_ADDRESS'] = 'unix:path=/run/dbus/system_bus_socket' -def command(args): - remaining = deadline - time.monotonic() - if remaining <= 0: - raise RuntimeError('Public manager health inspection exceeded its deadline') - try: - return subprocess.run(args, check=True, capture_output=True, text=True, - timeout=min(10, remaining)).stdout - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - stderr = error.stderr or '' - if isinstance(stderr, bytes): - stderr = stderr.decode(errors='replace') - if len(stderr) > 2000: - stderr = '[stderr omitted: ' + str(len(stderr)) + ' characters exceeds 2000-character limit]' - reason = ('timed out' if isinstance(error, subprocess.TimeoutExpired) - else 'exited ' + str(error.returncode)) - raise RuntimeError('Public health command ' + os.path.basename(args[0]) + ' ' + reason - + ': ' + stderr) from None -version = shlex.split(command(['/usr/bin/busctl', '--system', '--timeout=10', - 'get-property', 'org.freedesktop.systemd1', '/org/freedesktop/systemd1', - 'org.freedesktop.systemd1.Manager', 'Version'])) -if len(version) != 2 or version[0] != 's' or not version[1].strip(): - raise RuntimeError('Public system bus did not return Manager.Version') -account = pwd.getpwuid(os.getuid()) -if account.pw_name != 'qualification' or account.pw_uid < 1000: - raise RuntimeError('Public health inspection requires the ordinary qualification SSH account') -# Read the existing user's session directly. The Fedora RPM trigger's root -# --user -M bridge is precisely the path that may have missed marked work. -os.environ['XDG_RUNTIME_DIR'] = '/run/user/' + str(account.pw_uid) -os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=' + os.environ['XDG_RUNTIME_DIR'] + '/bus' -evidence = {'public_manager_version': version[1], 'ssh_user': account.pw_name, - 'ssh_uid': account.pw_uid} -for scope in ('system', 'user'): - client = ['/usr/bin/systemctl', '--' + scope, '--no-pager'] - listing = json.loads(command(client + ['list-units', '--all', '--output=json'])) - if not isinstance(listing, list) or not listing or len(listing) > 2048: - raise RuntimeError('Invalid or excessive ' + scope + ' unit inventory') - names = sorted({row['unit'] for row in listing} | set(required[scope])) - properties = ('Id', 'Markers', 'LoadState', 'ActiveState', 'SubState', 'UnitFileState', 'Result') - units = {} - for offset in range(0, len(names), 64): - output = command(client + ['show', '--all', - '--property=' + ','.join(properties), '--', *names[offset:offset + 64]]) - for block in output.strip().split('\n\n'): - values = dict(line.split('=', 1) for line in block.splitlines()) - name = values['Id'] - if name in units or not set(('Id', 'Markers', 'LoadState', 'ActiveState', 'SubState')) <= values.keys(): - raise RuntimeError('Incomplete or duplicate ' + scope + ' unit state: ' + name) - values['Markers'] = values['Markers'].split() - units[name] = values - if set(units) != set(names): - raise RuntimeError('Incomplete ' + scope + ' unit inventory') - evidence[scope] = units -print(json.dumps(evidence)) -''' - - -def required_service_states(): - # Read the shared workstation service policy, rather than inventing a - # separate baseline. RPM posttrans enables the additional packaged units; - # only its --now watchers must be active. Enabled oneshots may be inactive. - import yaml - defaults = yaml.safe_load((ROOT / 'roles/base/defaults/main.yml').read_text()) - system = {name: {'enabled': True, 'active': True} for name in defaults['base_core_services']} - system['sddm.service'] = {'active': True} - for name in ('cybexos-titlebars-build.path', 'cybexos-reconcile.timer'): - system[name] = {'enabled': True, 'active': True} - for name in ('cybexos-titlebars-build.service', 'cybexos-recovery-refresh.service', - 'cybexos-update-recover.service', 'cybexos-major-upgrade-validate.timer', - 'cybexos-hardware-setup.timer', 'cybexos-first-boot.service', 'cybexos-new-owner.service'): - system[name] = {'enabled': True} - # These are the running desktop services already required by TestVM.audit. - user = {name + '.service': {'active': True} for name in ('quickshell', 'hypridle', 'voxtype')} - return {'system': system, 'user': user} - - -def validate_public_health(evidence, required): - if (not isinstance(evidence.get('public_manager_version'), str) - or not evidence['public_manager_version'].strip() - or evidence.get('ssh_user') != 'qualification' - or type(evidence.get('ssh_uid')) is not int or evidence['ssh_uid'] < 1000): - raise RuntimeError('Public manager or ordinary SSH health evidence is missing') - for scope in ('system', 'user'): - units = evidence.get(scope) - if not isinstance(units, dict) or not units: - raise RuntimeError('Missing ' + scope + ' unit health evidence') - for name, state in units.items(): - markers = state.get('Markers') - if not isinstance(markers, list) or not all(isinstance(item, str) for item in markers): - raise RuntimeError('Missing ' + scope + ' unit markers: ' + name) - pending = sorted(set(markers) & {'needs-restart', 'needs-reload'}) - if pending: - raise RuntimeError('Pending RPM-trigger work in ' + scope + ' unit ' + name + ': ' + ', '.join(pending)) - # Other marker types are retained verbatim, not cleared or treated - # as a request to restart anything during read-only qualification. - for name, policy in required[scope].items(): - state = units.get(name, {}) - if (state.get('LoadState') != 'loaded' - or state.get('ActiveState') not in ('active', 'inactive') - or state.get('Result', 'success') != 'success' - or (name.endswith('.service') and state.get('Result') != 'success') - or (policy.get('active') and state.get('ActiveState') != 'active') - or (policy.get('enabled') and state.get('UnitFileState') != 'enabled')): - raise RuntimeError('Required ' + scope + ' unit is unhealthy: ' + name) - - -def verify_public_health(vm, password, root_script): - required = required_service_states() - script = 'required = ' + repr(required) + '\n' + PUBLIC_HEALTH - try: - # This query uses the actual public system bus from ordinary SSH. A - # root-private daemon-reload cannot stand in for this health evidence. - result = run([*vm.ssh, 'python3 -'], input=script, text=True, capture_output=True, timeout=55) - evidence = json.loads(result.stdout) - validate_public_health(evidence, required) - sudo = subprocess.run([*vm.ssh, 'sudo -k -n true'], capture_output=True, text=True, timeout=15) - if sudo.returncode != 1: - raise RuntimeError('Post-update sudo no longer requires ordinary password authentication') - authenticated = root_script(vm, 'id -u\n', password, timeout=15) - if authenticated.stdout.strip() != '0': - raise RuntimeError('Post-update password-authenticated sudo is not functional') - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - reason = ('timed out' if isinstance(error, subprocess.TimeoutExpired) - else f'exited {error.returncode}') - details = [] - for value in (error.stdout, error.stderr): - if isinstance(value, bytes): - value = value.decode(errors='replace') - if value: - details.append(value.replace(password, '[redacted]')) - raise RuntimeError('Post-update public health inspection failed (' + reason + '): ' + - '\n'.join(details)[-5000:]) from None - evidence['password_authenticated_sudo'] = True - evidence['service_requirements'] = required - return evidence - - -def transfer_source(vm, password, root_script): - files = ['assets/scripts/cybexos-update-run', 'site.yml', 'tests/run'] - files += ['roles/base/files/' + name for name in ( - 'cybexos-system-snapshot', 'cybexos-update-transaction', 'cybexos-update-recover', - 'cybexos-major-upgrade', 'cybexos-update-bootstrap', 'cybexos-vendor-paths.json', - 'cybexos-update-recover.service', 'cybexos-update-recover-login.conf')] - # root_script's authenticated channel writes reviewed source as data; it - # grants neither a new sudo policy nor a production qualification switch. - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport time\n" - script += f'root = Path({SOURCE!r})\nroot.mkdir(parents=True, exist_ok=True, mode=0o755)\n' - for relative in files: - content = (ROOT / relative).read_text() - script += (f'p = root / {relative!r}\np.parent.mkdir(parents=True, exist_ok=True)\n' - f'p.write_text({content!r})\np.chmod(0o755)\n') - script += 'PY\n' - root_script(vm, script, password) - return digest(ROOT / 'assets/scripts/cybexos-update-run') - - -def provision_fixture(vm, password, root_script, rpms, interrupt): - records = [{'path': path, 'sha256': sha} for path, sha in rpms] - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport json\n" - script += f'work = Path({WORK!r})\nwork.mkdir(parents=True, exist_ok=True, mode=0o755)\n' - script += "adapter = Path('/usr/local/bin/dnf')\nassert not adapter.exists() and not adapter.is_symlink(), 'Refusing to replace an existing DNF command'\n" - script += f'adapter.write_text({DNF_ADAPTER!r})\nadapter.chmod(0o755)\n' - config = {'rpms': records, 'interrupt': interrupt, 'vendor': VENDOR} - script += f'(work / "dnf.json").write_text({json.dumps(config)!r})\n' - script += "Path('/etc/cybexos/qualification-public-root').write_text('before\\n')\n" - script += f"vendor = Path({VENDOR!r})\nif not vendor.exists() and not vendor.is_symlink():\n vendor.parent.mkdir(parents=True, exist_ok=True)\n vendor.write_text('[Desktop Entry]\\nType=Application\\nName=Qualification sentinel\\nExec=/usr/bin/true\\n')\n" - if interrupt: - script += f'Path({OBSERVER!r}).write_text({OBSERVER_SCRIPT!r})\nPath({OBSERVER!r}).chmod(0o755)\n' - unit = ('[Unit]\nDescription=Observe disposable qualification recovery before login\n' - 'After=local-fs.target\nRequiresMountsFor=/home /var\n' - 'Before=cybexos-update-recover.service systemd-user-sessions.service sddm.service\n' - '[Service]\nType=simple\nExecStart=' + OBSERVER + '\n' - 'ExecStartPost=/usr/bin/timeout 30 /usr/bin/bash -c "until test -e /run/cybexos-qualification-observer-ready; do sleep .1; done"\n' - 'KillMode=mixed\nTimeoutStopSec=30\n[Install]\nWantedBy=multi-user.target\n') - script += f'Path("/etc/systemd/system/{UNIT}").write_text({unit!r})\n' - script += 'PY\n' - script += 'restorecon /usr/local/bin/dnf\n' - if interrupt: - script += f'restorecon {OBSERVER} /etc/systemd/system/{UNIT}\n' - script += f'systemctl daemon-reload\nsystemctl enable {UNIT}\n' - root_script(vm, script, password) - - -def start_public(vm, password, *, timeout=90): - # Authenticate through the existing sudo policy. The client's cached - # authorization launches the normal root worker; no testing environment - # override or passwordless rule is installed. - # With -v, sudo's -k deliberately prevents retaining the validated ticket. - # Invalidate separately so the following ordinary validation caches it. - def diagnostics(stdout, stderr): - values = [] - for label, value in (('stdout', stdout), ('stderr', stderr)): - if isinstance(value, bytes): - value = value.decode(errors='replace') - if value: - values.append(label + ': ' + value.replace(password, '[redacted]')) - return '\n'.join(values)[-5000:] - - try: - result = run([*vm.ssh, "sudo -k && sudo -S -p '' -v && exec " + shlex.quote(UPDATER) + - ' start --json --no-flatpak --system-unit'], input=password + '\n', text=True, - capture_output=True, timeout=timeout) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - reason = ('timed out' if isinstance(error, subprocess.TimeoutExpired) - else f'exited {error.returncode}') - raise RuntimeError('Public updater launch ' + reason + ': ' + - diagnostics(error.stdout, error.stderr)) from None - try: - status = json.loads(result.stdout) - except ValueError: - raise RuntimeError('Public updater did not return a run record: ' + - diagnostics(result.stdout, result.stderr)) from None - if (not re.fullmatch(r'\d{8}-\d{6}-\d+-\d+', str(status.get('id', ''))) - or status.get('systemUnit') is not True or status.get('state') not in ('queued', 'running')): - raise RuntimeError('Public updater did not launch its durable system worker') - return status['id'] - - -def guest_record(vm, password, root_script, identifier): - script = "python3 - <<'PY'\nimport json\nfrom pathlib import Path\n" - script += f"root = Path('/home/qualification/.local/state/cybexos/update/logs') / {identifier!r}\n" - script += "print((root / 'status.json').read_text())\nPY\n" - return json.loads(root_script(vm, script, password).stdout) - - -def transaction(vm, password, root_script, identifier): - script = "python3 - <<'PY'\nimport glob, pathlib, subprocess\n" - script += "candidates = ['/usr/libexec/cybexos-update-transaction'] + glob.glob('/usr/local/libexec/cybexos-update-bootstrap.d/*/cybexos-update-transaction')\n" - script += "helper = next(p for p in candidates if pathlib.Path(p).is_file())\n" - script += f"subprocess.run([helper, 'status', {identifier!r}], check=True)\nPY\n" - return json.loads(root_script(vm, script, password).stdout) - - -def wait_public(vm, password, root_script, identifier, *, interrupted=False): - deadline = root_script.deadline if isinstance(root_script, PublicControl) else time.monotonic() + 1500 - while time.monotonic() < deadline: - status = guest_record(vm, password, root_script, identifier) - if interrupted: - result = root_script(vm, f'if test -f {WORK}/applied.json; then cat {WORK}/applied.json; fi\n', password) - if result.stdout.strip(): - if transaction(vm, password, root_script, identifier)['state'] != 'applying': - raise RuntimeError('Interruption boundary is outside the durable applying state') - return status, json.loads(result.stdout) - if status['state'] in ('done', 'failed', 'cancelled'): - if interrupted or status['state'] != 'done' or status.get('exitCode') != 0: - raise RuntimeError('Public updater did not reach the expected boundary: ' + status['state']) - record = transaction(vm, password, root_script, identifier) - if record['state'] != 'committed': - raise RuntimeError('Public updater returned success without committing its journal') - return status, record - time.sleep(1) - raise RuntimeError('Public updater exceeded the qualification deadline') - - -def stop_interrupted(vm, password, root_script, identifier, workers, *, strict=True): - unit = 'cybexos-update-' + identifier + '.service' - script = f"systemctl kill --kill-whom=all --signal=SIGKILL {shlex.quote(unit)}\n" - if not strict: - # A terminal/failed launch may already have unloaded its unit. The - # cgroup, heartbeat and recorded PID checks below remain mandatory. - script = script.rstrip() + ' || true\n' - script += "python3 - <<'PY'\nimport pathlib,time\n" - script += f"group = pathlib.Path('/sys/fs/cgroup/system.slice/{unit}')\n" - script += f"heartbeat = pathlib.Path('{WORK}/heartbeat')\n" - script += "for _ in range(300):\n if not group.exists() or 'populated 1' not in (group / 'cgroup.events').read_text().splitlines(): break\n time.sleep(.1)\nelse: raise RuntimeError('Updater workers survived SIGKILL')\n" - script += "before = heartbeat.read_bytes() if heartbeat.exists() else b''\ntime.sleep(.3)\nassert (heartbeat.read_bytes() if heartbeat.exists() else b'') == before, 'Detached worker kept writing'\n" - for pid in workers.values(): - if type(pid) is not int or pid <= 1: - raise ValueError('Invalid qualification worker PID') - script += f"assert not pathlib.Path('/proc/{pid}').exists() or pathlib.Path('/proc/{pid}/stat').read_text().split()[2] == 'Z', 'Worker survived'\n" - script += f"applied = pathlib.Path('{WORK}/applied.json')\n" - script += "if applied.exists():\n import json\n for pid in json.loads(applied.read_text()).values():\n assert type(pid) is int and pid > 1, 'Invalid worker PID'\n process = pathlib.Path('/proc') / str(pid) / 'stat'\n assert not process.exists() or process.read_text().rsplit(')', 1)[1].split()[0] == 'Z', 'Recorded worker survived'\n" - script += 'PY\n' - script += 'systemctl stop cybexos-reconcile.service\n/usr/libexec/cybexos-configure-installed --drain\n' - root_script(vm, script, password) - verify_no_workers(vm, password, root_script, [identifier]) - - -def cleanup_fixture(vm, password, root_script): - # A failed admission must not remove a pre-existing command. Byte identity - # also recognizes our own adapter after root rollback erased /run state. - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport shutil,subprocess\n" - script += f"adapter = Path('/usr/local/bin/dnf')\nexpected = {DNF_ADAPTER!r}\n" - script += "if not adapter.is_symlink() and adapter.is_file() and adapter.read_text() == expected:\n adapter.unlink()\n" - script += f"observer = Path({OBSERVER!r})\nunit = Path('/etc/systemd/system/{UNIT}')\n" - script += f"if not observer.is_symlink() and observer.is_file() and observer.read_text() == {OBSERVER_SCRIPT!r}:\n" - script += f" subprocess.run(['systemctl','disable','--now',{UNIT!r}],check=False,timeout=60)\n observer.unlink()\n unit.unlink(missing_ok=True)\n" - script += f"work = Path({WORK!r})\nif work.is_dir() and not work.is_symlink(): shutil.rmtree(work)\n" - script += "subprocess.run(['systemctl','daemon-reload'],check=True,timeout=30)\nPY\n" - root_script(vm, script, password) - - -def verify_recovered(vm, password, root_script, identifier, before): - record = transaction(vm, password, root_script, identifier) - # bootId identifies the interrupted boot, not the recovery boot. - if record['state'] != 'rolled-back' or record['bootId'] != before['boot_id']: - raise RuntimeError('Interrupted journal did not settle as rolled-back') - script = "python3 - <<'PY'\nimport glob,hashlib,importlib.machinery,importlib.util,json,pathlib,subprocess\n" - script += f"evidence = json.loads(pathlib.Path({EVIDENCE!r}).read_text())\n" - script += f"assert evidence['transaction'] == {identifier!r} and evidence['preLogin'] is True\n" - script += "assert evidence['state'] == 'rolled-back' and 'applying' in evidence['states']\n" - script += f"assert evidence['bootId'] != {before['boot_id']!r}\n" - script += "boot = pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip()\nassert boot != evidence['bootId'], 'Recovery did not complete its automatic restart'\n" - script += "assert pathlib.Path('/etc/cybexos/qualification-public-root').read_text() == 'before\\n'\n" - script += f"assert hashlib.sha256(pathlib.Path({VENDOR!r}).read_bytes()).hexdigest() == {before['vendor_sha256']!r}, 'Vendor checkpoint was not restored'\n" - script += "assert pathlib.Path('/home/qualification/qualification-public-personal').read_text() == 'after-checkpoint\\n', 'Personal post-checkpoint edit was lost'\n" - script += "packages = subprocess.check_output(['rpm','-q','--qf','%{NEVRA}\\n','cybexos-desktop','cybexos-offline-apps'],text=True)\n" - script += f"assert packages == {before['packages']!r}, 'Package identity did not roll back'\n" - script += "helpers = ['/usr/libexec/cybexos-system-snapshot'] + glob.glob('/usr/local/libexec/cybexos-update-bootstrap.d/*/cybexos-system-snapshot')\npath = next(p for p in helpers if pathlib.Path(p).is_file())\nloader = importlib.machinery.SourceFileLoader('qualification_snapshot', path)\nspec = importlib.util.spec_from_loader(loader.name, loader)\nmodule = importlib.util.module_from_spec(spec)\nloader.exec_module(module)\nlayout = module.detect_layout()\nassert layout.running == '/root' and not layout.pending_reboot\n" - script += f"point = {record['snapshot']!r}\n" - script += "with module.opened_store(layout, create=False) as store:\n records = [module.read_record(store, p.stem) for p in store.replaced.glob('*.json')]\n restored = [r for r in records if r.get('point') == point and r.get('state') == 'complete']\n assert len(restored) == 1, 'Recovery exchanged the root more than once'\n assert module.subvolume_uuid(store.top / 'root') == restored[0]['newRoot'], 'Running root differs from restored root'\n assert not (store.metadata / (point + '.pin')).exists(), 'Terminal journal still pins recovery point'\n assert not (store.path / 'transactions' / " + repr(identifier) + " / 'vendor').exists(), 'Vendor checkpoint did not settle'\n print(json.dumps({'rootUuid': restored[0]['newRoot'], 'bootId': boot, 'recoveryBootId': evidence['bootId']}))\nPY\n" - return json.loads(root_script(vm, script, password).stdout) - - -def verify_no_workers(vm, password, root_script, identifiers): - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport time\n" - groups = ['cybexos-update-' + identifier + '.service' for identifier in identifiers] - groups += ['cybexos-provision-work.service', 'cybexos-reconcile.service'] - script += f'groups = {groups!r}\n' - script += "for unit in groups:\n path = Path('/sys/fs/cgroup/system.slice') / unit / 'cgroup.events'\n for _ in range(300):\n if not path.exists() or 'populated 1' not in path.read_text().splitlines(): break\n time.sleep(.1)\n else: raise RuntimeError('A transaction worker survived')\nPY\n" - root_script(vm, script, password) - - -def installed_updater_identity(candidate, candidate_sha256, candidate_size): - """Check installed bytes against the exact candidate's packaged manifest.""" - import hashlib - import os - import re - import stat - import subprocess - import time - - deadline = time.monotonic() + 30 - installed = '/usr/share/cybexos/bin/cybexos-update-run' - - def remaining(): - value = deadline - time.monotonic() - if value <= 0: - raise RuntimeError('Installed updater identity exceeded its deadline') - return value - - def opened(path): - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - stream = os.fdopen(descriptor, 'rb') - info = os.fstat(stream.fileno()) - if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_gid != 0 - or info.st_mode & 0o022): - stream.close() - raise RuntimeError('Updater identity requires protected root-owned regular files') - return stream - - def fingerprint(stream, size): - before = os.fstat(stream.fileno()) - if before.st_size != size or size <= 0: - raise RuntimeError('Updater identity file size mismatch') - checksum = hashlib.sha256() - unread = size - while unread: - remaining() - data = stream.read(min(unread, 1024 * 1024)) - if not data: - raise RuntimeError('Updater identity file changed while hashing') - checksum.update(data) - unread -= len(data) - after = os.fstat(stream.fileno()) - if stream.read(1) or (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( - after.st_size, after.st_mtime_ns, after.st_ctime_ns): - raise RuntimeError('Updater identity file changed while hashing') - return checksum.hexdigest() - - def query(arguments, *, pass_fds=()): - result = subprocess.run(['/usr/bin/rpm', *arguments], check=True, capture_output=True, - text=True, timeout=min(10, remaining()), pass_fds=pass_fds) - if len(result.stdout) > 4 * 1024 * 1024: - raise RuntimeError('Excessive updater RPM header output') - return result.stdout.splitlines() - - if (not re.fullmatch(r'[0-9a-f]{64}', candidate_sha256) - or type(candidate_size) is not int or candidate_size <= 0): - raise ValueError('Invalid updater candidate binding') - with opened(candidate) as stream: - if fingerprint(stream, candidate_size) != candidate_sha256: - raise RuntimeError('Updater candidate RPM checksum mismatch') - # RPM's documented parallel file arrays preserve the canonical package - # rendering. Query the pinned, already-hashed inode, never raw source - # or a second mutable candidate pathname. OpenPGP SHA256 algorithm=8. - queryformat = ('%{NEVRA}\n%{FILEDIGESTALGO}\n' - '[%{FILENAMES}\t%{FILEDIGESTS}\t%{FILEMODES}\t' - '%{FILEUSERNAME}\t%{FILEGROUPNAME}\n]') - header = query(['-qp', '--qf', queryformat, '--', f'/proc/self/fd/{stream.fileno()}'], - pass_fds=(stream.fileno(),)) - if (len(header) < 3 or not re.fullmatch(r'cybexos-desktop-[^\s/]{1,240}', header[0]) - or header[1] != '8'): - raise RuntimeError('Invalid updater RPM identity or non-SHA256 file digests') - rows = [line.split('\t') for line in header[2:]] - if any(len(row) != 5 for row in rows): - raise RuntimeError('Malformed updater RPM file manifest') - matches = [row for row in rows if row[0] == installed] - if len(matches) != 1: - raise RuntimeError('Updater RPM must contain exactly one installed helper entry') - _path, expected, mode, owner, group = matches[0] - if (not re.fullmatch(r'[0-9a-f]{64}', expected) or not re.fullmatch(r'[0-9]{1,5}', mode) - or not stat.S_ISREG(int(mode)) or int(mode) & 0o022 or (owner, group) != ('root', 'root')): - raise RuntimeError('Invalid updater RPM helper digest, type or ownership') - if query(['-q', '--qf', '%{NEVRA}\n', 'cybexos-desktop']) != [header[0]]: - raise RuntimeError('Installed updater package differs from the bound candidate NEVRA') - with opened(installed) as stream: - size = os.fstat(stream.fileno()).st_size - if size > 4 * 1024 * 1024: - raise RuntimeError('Excessive installed updater size') - actual = fingerprint(stream, size) - if actual != expected: - raise RuntimeError('Installed updater differs from the bound candidate file digest') - remaining() - return {'path': installed, 'expected_sha256': expected, 'actual_sha256': actual, - 'candidate_rpm_sha256': candidate_sha256, 'candidate_nevra': header[0]} - - -def verify_installed_updater(vm, password, root_script, candidate, candidate_sha256, candidate_size): - script = "python3 - <<'PY'\nimport json\n" + inspect.getsource(installed_updater_identity) - script += (f'\nprint(json.dumps(installed_updater_identity({candidate!r}, ' - f'{candidate_sha256!r}, {candidate_size!r})))\nPY\n') - return json.loads(root_script(vm, script, password, timeout=30).stdout) - - -def boot_recovered(vm, password): - """Let both pre-login recovery boots settle before restoring test access.""" - vm.start(user='qualification') - vm.unlock_disk(password) - from vm_testing import is_disk_prompt - deadline = time.monotonic() + 60 - while is_disk_prompt(vm.screen_text()): - if time.monotonic() >= deadline: - raise RuntimeError('The first recovery boot did not leave its unlock prompt') - time.sleep(1) - # This second prompt is reached only after automatic recovery has settled - # and requested its restart. Never bootstrap SSH on the recovery boot. - vm.unlock_disk(password, timeout=900) - try: - vm.wait_ssh(timeout=12, setup=False, redactions=(password,)) - except RuntimeError: - # The restored baseline disables sshd and closes SSH in the firewall. - # Authenticate at the normal console after pam_nologin releases it; - # start SSH/open the runtime firewall only, as boot_installed does. - vm.bootstrap_installed_ssh(password) - from qualification import focus_installed_desktop - focus_installed_desktop(vm, password, True) - - -def qualify_public_updater(vm, candidate, offline_candidate, password, root_script, - *, candidate_dependencies, boot_installed, poweroff_installed, - verify_user_choices, preference, report): - # Import here to retain the existing upgrade module's public input helpers. - from upgrade_qualification import (CANDIDATE, OFFLINE_CANDIDATE, copy_candidate, - copy_dependencies, inspect_version) - if not offline_candidate: - raise ValueError('Public updater qualification requires both candidate RPMs') - sha = copy_candidate(vm, candidate) - offline_sha = copy_candidate(vm, offline_candidate, OFFLINE_CANDIDATE) - if sha != report['candidate_rpm_sha256'] or offline_sha != report['candidate_offline_rpm_sha256']: - raise RuntimeError('Public updater candidates differ from the qualified artifacts') - firstparty = {'candidate': {'source': CANDIDATE, 'sha256': sha, 'size': Path(candidate).stat().st_size}, - 'offline_candidate': {'source': OFFLINE_CANDIDATE, 'sha256': offline_sha, - 'size': Path(offline_candidate).stat().st_size}} - identifiers = [] - # The controller outlives the staging context: its authenticated process - # handles both root-owned and user-owned cleanup without another login. - with PublicControl(vm, password) as control: - with copy_dependencies(vm, candidate_dependencies, password, root_script, - firstparty=firstparty, cleanup=control.cleanup_staging) as staged: - rpms = [(staged['candidate'], sha), (staged['offline_candidate'], offline_sha)] - if candidate_dependencies: - rpms += [(path, item['sha256']) for path, item in - zip(staged['dependencies'], candidate_dependencies.manifest['rpms'], strict=True)] - transfer_source(vm, password, control) - provision_fixture(vm, password, control, rpms, True) - script = "python3 - <<'PY'\nimport hashlib,json,pathlib,subprocess\n" - script += f"vendor = pathlib.Path({VENDOR!r})\nassert vendor.is_file()\n" - script += "print(json.dumps({'boot_id': pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip(), 'vendor_sha256': hashlib.sha256(vendor.read_bytes()).hexdigest(), 'packages': subprocess.check_output(['rpm','-q','--qf','%{NEVRA}\\n','cybexos-desktop','cybexos-offline-apps'],text=True)}))\nPY\n" - before = json.loads(control(vm, script, password).stdout) - identifier = control.start() - identifiers.append(identifier) - control(vm, f"printf '%s\\n' {shlex.quote(identifier)} > /home/qualification/qualification-public-run-id\n", password) - _status, workers = wait_public(vm, password, control, identifier, interrupted=True) - versions = inspect_version(vm, candidate=staged['candidate'], root_script=control, password=password) - if versions['installed'] != versions['candidate']: - raise RuntimeError('Interruption occurred before the candidate package was applied') - control.drain_attempted = True - stop_interrupted(vm, password, control, identifier, workers) - control.drained = True - # Shutdown preparation also uses the same process. The observer is - # retained only after marked shutdown and a clean QEMU exit. - control.poweroff() - report['checks'].append('public-updater-interrupted-after-apply') - # No manual recovery call occurs here: both pre-login recovery and its - # restart must run through the installed systemd dependency. - boot_recovered(vm, password) - restored = verify_recovered(vm, password, root_script, identifier, before) - verify_user_choices(vm, password, root_script, preference) - report['checks'] += ['public-updater-pre-login-recovery', - 'public-updater-root-package-vendor-restored', - 'public-updater-personal-data-preserved'] - cleanup_fixture(vm, password, root_script) - poweroff_installed(vm, password) - boot_installed(vm, password, True) - again = verify_recovered(vm, password, root_script, identifier, before) - if again['rootUuid'] != restored['rootUuid']: - raise RuntimeError('A terminal recovery exchanged the root on another boot') - report['checks'].append('public-updater-second-boot-settled') - copy_candidate(vm, candidate) - copy_candidate(vm, offline_candidate, OFFLINE_CANDIDATE) - with PublicControl(vm, password) as control: - with copy_dependencies(vm, candidate_dependencies, password, root_script, - firstparty=firstparty, cleanup=control.cleanup_staging) as staged: - rpms = [(staged['candidate'], sha), (staged['offline_candidate'], offline_sha)] - if candidate_dependencies: - rpms += [(path, item['sha256']) for path, item in - zip(staged['dependencies'], candidate_dependencies.manifest['rpms'], strict=True)] - updater_sha = transfer_source(vm, password, control) - provision_fixture(vm, password, control, rpms, False) - committed = control.start() - identifiers.append(committed) - wait_public(vm, password, control, committed) - # Observe the product's terminal state before the timeout fixture - # reuses/drains provisioning, or cleanup can stop leaked workers. - # A surviving worker must fail qualification even if teardown can - # subsequently remove it safely. - verify_no_workers(vm, password, control, identifiers) - versions = inspect_version(vm, candidate=staged['candidate'], root_script=control, password=password) - if versions['installed'] != versions['candidate']: - raise RuntimeError('Committed public update differs from the candidate package') - control(vm, "test \"$(rpm -q --qf '%{NEVRA}' cybexos-offline-apps)\" = " - f"\"$(rpm -qp --qf '%{{NEVRA}}' {shlex.quote(staged['offline_candidate'])})\"\n", password) - installed_helper = verify_installed_updater(vm, password, control, staged['candidate'], sha, - firstparty['candidate']['size']) - build = json.loads(control(vm, 'cat /usr/share/cybexos/build.json\n', password).stdout) - verify_user_choices(vm, password, control, preference) - control(vm, PROVISION_TIMEOUT, password, timeout=120) - control.ensure_drained() - verify_no_workers(vm, password, control, identifiers) - # Control success grants no health credit. Close and reap it (including - # fixture/staging cleanup) before the exact fresh ordinary SSH inspection. - health = verify_public_health(vm, password, root_script) - report.update({key: build[key] for key in ('source_revision', 'source_content_sha256')}) - report['public_updater'] = {'post_commit_health': health, 'interrupted': identifier, - 'committed': committed, 'updater_sha256': updater_sha, - 'installed_updater': installed_helper, **restored} - report['checks'] += ['public-updater-committed', - 'provisioning-cgroup-timeout-drains-detached-workers', - 'public-updater-no-surviving-workers'] diff --git a/image/publish-pxe b/image/publish-pxe deleted file mode 100755 index 9a956792..00000000 --- a/image/publish-pxe +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 -from pxe_publish import main - -if __name__ == '__main__': - main() diff --git a/image/pxe_publish.py b/image/pxe_publish.py deleted file mode 100644 index 9d9c2d0d..00000000 --- a/image/pxe_publish.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Checksum-first, no-replace publication to a local iVentoy installation.""" -import argparse -import json -import os -from pathlib import Path -import shutil -import subprocess -import tempfile -import time -import urllib.error -import urllib.request -from urllib.parse import urlsplit - -from build_support import SAFE_NAME, checksum_entries, digest - - -# Statically inspected in the official 1.0.41 Linux release (no binaries run): -# query_status -> {"status":"running"|"refreshing"|...}; get_img_tree -> list. -# https://github.com/ventoy/PXE/releases/tag/v1.0.41 -DEFAULT_CONTRACT = {"pxe_status_pointer": "/status", "pxe_running_value": "running", - "refresh_busy_pointer": "/status", "refresh_idle_value": "running", - "refresh_busy_value": "refreshing"} - - -def pointer(value, path): - if not path.startswith("/"): - raise ValueError("API contract paths must be JSON pointers") - for part in path[1:].split("/"): - part = part.replace("~1", "/").replace("~0", "~") - value = value[int(part)] if isinstance(value, list) else value[part] - return value - - -def validate_status(response, contract): - if not isinstance(response, dict) or response.get("result", "success") != "success": - raise RuntimeError("iVentoy status request did not succeed") - try: - running = pointer(response, contract["pxe_status_pointer"]) - busy = pointer(response, contract["refresh_busy_pointer"]) - except (KeyError, TypeError, IndexError, ValueError) as error: - raise RuntimeError("iVentoy status shape differs from the reviewed API contract; inspect installed vtoy_image.html") from error - if busy == contract["refresh_busy_value"]: - return True - if running != contract["pxe_running_value"]: - raise RuntimeError(f"iVentoy PXE is not running: {running!r}") - if busy not in (contract["refresh_idle_value"], contract["refresh_busy_value"]): - raise RuntimeError("Unknown iVentoy refresh state") - return busy == contract["refresh_busy_value"] - - -def tree_contains(value, filename): - if isinstance(value, str): - return value == filename or value.replace("\\", "/").rsplit("/", 1)[-1] == filename - if isinstance(value, dict): - return any(tree_contains(item, filename) for item in value.values()) - if isinstance(value, list): - return any(tree_contains(item, filename) for item in value) - return False - - -class IVentoy: - def __init__(self, url, contract, timeout=180, request=None, sleep=time.sleep, clock=time.monotonic): - parsed = urlsplit(url) - if parsed.scheme != "http" or parsed.hostname not in ("127.0.0.1", "localhost", "::1"): - raise ValueError("Run this tool on the PXE server and use its loopback iVentoy URL") - self.url, self.contract, self.timeout = url, contract, timeout - self.request = request - self.sleep, self.clock = sleep, clock - required = {"pxe_status_pointer", "pxe_running_value", "refresh_busy_pointer", "refresh_idle_value", "refresh_busy_value"} - if not required.issubset(contract) or contract["refresh_idle_value"] == contract["refresh_busy_value"]: - raise ValueError("Incomplete iVentoy API contract") - - def _request(self, method, timeout): - request = urllib.request.Request(self.url, json.dumps({"method": method}).encode(), {"Content-Type": "application/json"}) - with urllib.request.urlopen(request, timeout=timeout) as response: - return json.load(response) - - def _query(self, method, deadline): - remaining = deadline - self.clock() - if remaining <= 0: - raise RuntimeError("iVentoy refresh verification deadline exceeded") - response = (self.request(method) if self.request is not None else - self._request(method, min(30, remaining))) - if self.clock() >= deadline: - raise RuntimeError("iVentoy refresh verification deadline exceeded") - return response - - def _pause(self, deadline): - self.sleep(max(0, min(2, deadline - self.clock()))) - - def wait_idle(self, deadline=None, retry_timeouts=False): - deadline = self.clock() + self.timeout if deadline is None else deadline - while self.clock() < deadline: - try: - status = self._query("query_status", deadline) - except (TimeoutError, urllib.error.URLError) as error: - # The accepted scan can temporarily block iVentoy's HTTP - # thread. Retry only this read, never an ambiguous refresh - # mutation, malformed JSON, or another transport error. - timed_out = isinstance(error, TimeoutError) or isinstance(error.reason, TimeoutError) - if not retry_timeouts or not timed_out: - raise - self._pause(deadline) - continue - if not validate_status(status, self.contract): - return - self._pause(deadline) - raise RuntimeError("iVentoy did not confirm an idle, running PXE service before the deadline; published files are retained") - - def refresh(self, filename): - deadline = self.clock() + self.timeout - while self.clock() < deadline: - self.wait_idle(deadline) - result = self._query("refresh_img_list", deadline) - if result.get("result") == "success": - break - if result.get("result") != "busy": - raise RuntimeError(f"iVentoy rejected refresh: {result.get('result')!r}") - self._pause(deadline) - else: - raise RuntimeError("iVentoy refresh request remained busy") - self.wait_idle(deadline, retry_timeouts=True) - tree = self._query("get_img_tree", deadline) - if not isinstance(tree, list) or not tree_contains(tree, filename): - raise RuntimeError("iVentoy did not list the published filename") - - -def staging_parent(served, requested=None): - """Writable directory outside the served tree on the same filesystem. - - Publication hardlinks complete files into place, so staging must share the - served directory's filesystem. Without a request, use its nearest writable - ancestor; the served parent may be root-owned on a PXE host. - """ - if not Path(served).is_dir(): - raise ValueError("Served ISO directory must exist") - served = Path(served).resolve() - candidates = [Path(requested).resolve()] if requested is not None else list(served.parents) - for candidate in candidates: - if candidate == served or served in candidate.parents: - raise ValueError("Staging must be outside the served ISO directory") - if (candidate.is_dir() and candidate.stat().st_dev == served.stat().st_dev - and os.access(candidate, os.W_OK | os.X_OK)): - return candidate - if requested is not None: - raise ValueError("Staging directory must be writable and on the served ISO filesystem") - raise ValueError("No writable ancestor shares the served ISO filesystem; pass --staging") - - -def publish(iso, served, filename, expected=None, staging=None): - """Caller has validated iVentoy. Existing files are never replaced.""" - iso, served = Path(iso), Path(served) - if not SAFE_NAME.fullmatch(filename) or not filename.endswith(".iso"): - raise ValueError("ISO filename must be ASCII without spaces and end with .iso") - if served.is_symlink() or not served.is_dir(): - raise ValueError("Served ISO directory must exist and must not be a symlink") - staging = staging_parent(served, staging) - destination, checksum = served / filename, served / (filename + ".sha256") - actual = digest(iso) - if expected is not None and actual != expected: - raise ValueError("Source ISO changed after manifest verification") - expected = actual - if destination.exists() or checksum.exists() or destination.is_symlink() or checksum.is_symlink(): - if (destination.is_file() and checksum.is_file() and not destination.is_symlink() and not checksum.is_symlink() - and digest(destination) == expected and checksum.read_text() == f"{expected} {filename}\n"): - return destination, expected # Retry a failed refresh without replacing verified files. - raise FileExistsError("Refusing to replace a different or incomplete existing ISO/checksum") - with tempfile.TemporaryDirectory(prefix=".cybexos-publish-", dir=staging) as temporary: - stage = Path(temporary) - image = stage / filename - shutil.copyfile(iso, image) - image.chmod(0o644) - if digest(image) != expected: - raise RuntimeError("Staged ISO checksum mismatch; nothing published") - sums = stage / checksum.name - sums.write_text(f"{expected} {filename}\n") - sums.chmod(0o644) - for path in (image, sums): - with path.open("rb") as stream: - os.fsync(stream.fileno()) - # Hardlinks publish already-complete bytes atomically and fail if a - # competing process created either destination. Staging is same FS. - os.link(sums, checksum) - try: - os.link(image, destination) - except BaseException: - checksum.unlink() - raise - descriptor = os.open(served, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - return destination, expected - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("artifacts", type=Path, help="Verified build artifacts directory containing SHA256SUMS") - parser.add_argument("--served", type=Path, default=Path("/data/pxe/iso")) - parser.add_argument("--staging", type=Path, help="Writable directory on the served filesystem, outside it (default: nearest writable ancestor)") - parser.add_argument("--api-contract", type=Path, help="JSON pointers/values reviewed against the installed iVentoy UI") - parser.add_argument("--url", default="http://127.0.0.1:26000/iventoy/json") - parser.add_argument("--execute", action="store_true", help="Explicitly publish and refresh iVentoy; default only checks artifacts") - args = parser.parse_args() - entries = checksum_entries(args.artifacts) - isos = [name for name in entries if name.endswith(".iso")] - if len(isos) != 1: - parser.error("artifacts must contain exactly one checksummed ISO") - name = isos[0] - staging = staging_parent(args.served, args.staging) - if not args.execute: - print(json.dumps({"action": "publish-and-refresh", "filename": name, "sha256": entries[name], "destination": str(args.served / name), - "staging": str(staging), "executed": False}, indent=2)) - return - contract = json.loads(args.api_contract.read_text()) if args.api_contract else DEFAULT_CONTRACT - client = IVentoy(args.url, contract) - subprocess.run(["systemctl", "is-active", "--quiet", "iventoy.service"], check=True) - client.wait_idle() - destination, checksum = publish(args.artifacts / name, args.served, name, entries[name], staging) - try: - client.refresh(name) - subprocess.run(["systemctl", "is-active", "--quiet", "iventoy.service"], check=True) - except BaseException as error: - raise RuntimeError(f"Published verified ISO retained at {destination}; refresh verification failed: {error}. Inspect status before retrying; no service restart was attempted.") from error - print(f"Published {destination}\nSHA256 {checksum}\niVentoy refresh/list/PXE/service checks passed.") diff --git a/image/qmp_control.py b/image/qmp_control.py deleted file mode 100644 index 229c9f82..00000000 --- a/image/qmp_control.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python3 -"""Inspect only the disposable VM described by image/test-live's vm.json.""" -import argparse -import json -from pathlib import Path -import socket -import subprocess -import time -import sys - - -class Qmp: - def __init__(self, path): - self.socket = socket.socket(socket.AF_UNIX) - self.socket.settimeout(10) - self.socket.connect(path) - self.stream = self.socket.makefile("rwb", buffering=0) - self.stream.readline() - self.call("qmp_capabilities") - - def call(self, command, arguments=None): - self.stream.write((json.dumps({"execute": command, "arguments": arguments or {}}) + "\n").encode()) - while line := self.stream.readline(): - response = json.loads(line) - if "error" in response: - raise RuntimeError(response["error"]) - if "return" in response: - return response["return"] - raise RuntimeError("QEMU disconnected") - - -def type_text(qmp, text): - if any(ord(character) > 127 for character in text): - raise ValueError("QMP typing supports ASCII only") - punctuation = {" ": "spc", "\n": "ret", "/": "slash", ".": "dot", "-": "minus", - "=": "equal", "[": "bracket_left", "]": "bracket_right", "'": "apostrophe", - ";": "semicolon", "\\": "backslash", ",": "comma", "`": "grave_accent"} - shifted = dict(zip('!@#$%^&*()_+{}:"|<>?~', '1234567890-=[];\'\\,./`')) - for character in text: - shift = character.isupper() or character in shifted - base = shifted.get(character, character.lower()) - code = punctuation.get(base, base) - codes = ["shift", code] if shift else [code] - events = [{"type": "key", "data": {"down": True, "key": {"type": "qcode", "data": item}}} for item in codes] - events += [{"type": "key", "data": {"down": False, "key": {"type": "qcode", "data": item}}} for item in reversed(codes)] - qmp.call("input-send-event", {"events": events}) - time.sleep(0.015) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - subparsers = parser.add_subparsers(dest="command", required=True) - screenshot = subparsers.add_parser("screenshot") - screenshot.add_argument("filename", type=Path) - key = subparsers.add_parser("key") - key.add_argument("keys", help="QEMU key names joined with +, e.g. ctrl+alt+f2") - typing = subparsers.add_parser("type") - typing.add_argument("text", nargs="?", default="", help="ASCII text to type into the focused VM window") - typing.add_argument("--stdin", action="store_true", help="Read private text from stdin instead of process arguments") - click = subparsers.add_parser("click") - click.add_argument("x", type=int) - click.add_argument("y", type=int) - click.add_argument("--width", type=int, default=1280) - click.add_argument("--height", type=int, default=800) - ssh = subparsers.add_parser("ssh") - ssh.add_argument("arguments", nargs=argparse.REMAINDER) - args = parser.parse_args() - state = json.loads((args.directory / "vm.json").read_text()) - if args.command == "ssh": - raise SystemExit(subprocess.run([*state["ssh"], *args.arguments]).returncode) - qmp = Qmp(state["qmp"]) - if args.command == "screenshot": - qmp.call("screendump", {"filename": str(args.filename.resolve()), "format": "png"}) - elif args.command == "key": - qmp.call("send-key", {"keys": [{"type": "qcode", "data": key} for key in args.keys.split("+")]}) - elif args.command == "type": - type_text(qmp, sys.stdin.read() if args.stdin else args.text) - elif args.command == "click": - qmp.call("input-send-event", {"events": [ - {"type": "abs", "data": {"axis": "x", "value": int(args.x / args.width * 32767)}}, - {"type": "abs", "data": {"axis": "y", "value": int(args.y / args.height * 32767)}}, - ]}) - time.sleep(0.15) - qmp.call("input-send-event", {"events": [{"type": "btn", "data": {"down": True, "button": "left"}}]}) - time.sleep(0.15) - qmp.call("input-send-event", {"events": [{"type": "btn", "data": {"down": False, "button": "left"}}]}) - - -if __name__ == "__main__": - main() diff --git a/image/qualification-results/2026-09-26/build.json b/image/qualification-results/2026-09-26/build.json deleted file mode 100644 index 6fff3486..00000000 --- a/image/qualification-results/2026-09-26/build.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "artifacts": { - "CybexOS-Live-44-20260926T150946Z-99900b99.iso": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "applications.json": "952ccf1a5e7673b01a0f2143668d70303f971065be25bd35d21d946d17689f36", - "build-provenance.json": "016a81a05571e2b095459a0d05c51666acb7611c31cbee4658ad5b8a65dfa581", - "builder-packages.txt": "c47a03ea52d880785d72c15396b6f66edcacced34b27fc5005e23a377bb7018d", - "cybexos-desktop-0.0.0~dev-1.20260926150946.ga991a97d0999.fc44.x86_64.rpm": "e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b", - "mdview-0.0.1-1.fc44.x86_64.rpm": "2100ed7607ffb6b49fdaca46fd14d09da6f716bcdac8fa8596e5f051f199d83d", - "package-manifest.json": "6a99aaffc29a432d301f507a1439cfffdd6baa6d212472426d1413e71df4d8dc", - "packages.txt": "ac068e4d42f5502c65268c5f10ce76953c6502e4f25ad869bb678b5f2aa481d9", - "voxtype-0.7.5-1.x86_64.rpm": "50dd10fe8dcdd0f0d7a2796ca8d628e21810c81e2ae1c365075643e552e79c61" - }, - "build_id": "20260926T150946Z-99900b99", - "compression": "xz", - "download_cache_hits": 51, - "phases": [ - { - "phase": "Validate public update channel", - "seconds": 0.01, - "started_utc": "2026-09-26T15:09:46.164249+00:00", - "status": "complete" - }, - { - "phase": "Verify cached Fedora builder", - "seconds": 0.34, - "started_utc": "2026-09-26T15:09:46.178644+00:00", - "status": "complete" - }, - { - "phase": "Record source and prepare isolated VM", - "seconds": 0.98, - "started_utc": "2026-09-26T15:09:46.514244+00:00", - "status": "complete" - }, - { - "phase": "Wait for builder and restore verified downloads", - "seconds": 12.92, - "started_utc": "2026-09-26T15:09:47.491321+00:00", - "status": "complete" - }, - { - "phase": "Build applications, desktop RPM and compressed image", - "seconds": 1168.17, - "started_utc": "2026-09-26T15:10:00.409922+00:00", - "status": "complete" - }, - { - "phase": "Save verified download cache", - "seconds": 3.3, - "started_utc": "2026-09-26T15:29:28.582309+00:00", - "status": "complete" - }, - { - "phase": "Verify and atomically deliver completed artifacts", - "seconds": 55.94, - "started_utc": "2026-09-26T15:29:31.887078+00:00", - "status": "complete" - } - ], - "resources": { - "cpus": 8, - "free_gib": { - "/data/cybexos-candidate-20260926-installer-qualification/cache": 1624.6 - }, - "memory_mib": 24576, - "seed_tool": "cloud-localds" - }, - "source": { - "changes": [], - "configuration_sha256": "e0a03813306db3a615ac54a2b331c0d023fedee0d5b8d240c902509ff89808f6", - "revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", - "source_archive_sha256": "a0f0462f828f637c68c5dcb8233a78e5f32541223aef1b76f60cdc0d040bb1cc" - }, - "source_dirty": false, - "source_epoch": 1790435325, - "source_revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", - "status": "complete", - "tools": { - "python": "3.13.5 (main, Aug 10 2026, 12:06:59) [GCC 14.2.0]", - "qemu-img": "qemu-img version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)", - "qemu-system-x86_64": "QEMU emulator version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)" - }, - "utc": "2026-09-26T15:09:46.150792+00:00" -} diff --git a/image/qualification-results/2026-09-26/encrypted-nl.json b/image/qualification-results/2026-09-26/encrypted-nl.json deleted file mode 100644 index 7b87c0fc..00000000 --- a/image/qualification-results/2026-09-26/encrypted-nl.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password", - "initial-encrypted-keyring-unlocked-without-prompt", - "synthetic-keyring-secret-survives-cold-reboot", - "logout-requires-authentication", - "logout-password-recovery-and-keyring", - "compositor-crash-requires-authentication", - "compositor-crash-password-recovery-and-keyring", - "manager-restart-requires-authentication", - "manager-restart-password-recovery-and-keyring", - "missing-cached-password-preserves-locked-encrypted-keyring", - "fallback-password-login-unlocks-existing-keyring", - "first-autologin-launch-failure-consumes-boot-attempt", - "failed-first-autologin-manager-restart-requires-authentication", - "failed-first-autologin-password-login-recovers-desktop" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "encrypted-nl", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/encrypted-us.json b/image/qualification-results/2026-09-26/encrypted-us.json deleted file mode 100644 index 80213bbc..00000000 --- a/image/qualification-results/2026-09-26/encrypted-us.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password", - "initial-encrypted-keyring-unlocked-without-prompt", - "synthetic-keyring-secret-survives-cold-reboot", - "logout-requires-authentication", - "logout-password-recovery-and-keyring", - "compositor-crash-requires-authentication", - "compositor-crash-password-recovery-and-keyring", - "manager-restart-requires-authentication", - "manager-restart-password-recovery-and-keyring", - "missing-cached-password-preserves-locked-encrypted-keyring", - "fallback-password-login-unlocks-existing-keyring", - "first-autologin-launch-failure-consumes-boot-attempt", - "failed-first-autologin-manager-restart-requires-authentication", - "failed-first-autologin-password-login-recovers-desktop" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "encrypted-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/plain-nl.json b/image/qualification-results/2026-09-26/plain-nl.json deleted file mode 100644 index 726f13a8..00000000 --- a/image/qualification-results/2026-09-26/plain-nl.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "plain-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "plain-btrfs", - "password-login", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "plain-nl", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/plain-us.json b/image/qualification-results/2026-09-26/plain-us.json deleted file mode 100644 index 3405facd..00000000 --- a/image/qualification-results/2026-09-26/plain-us.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "plain-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "plain-btrfs", - "password-login", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "plain-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/upgrade-recovery.json b/image/qualification-results/2026-09-26/upgrade-recovery.json deleted file mode 100644 index 6735627e..00000000 --- a/image/qualification-results/2026-09-26/upgrade-recovery.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": "e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b", - "checks": [ - "legacy-installer-only-session-desktop-bootstrap", - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "installed-rpm-upgrade", - "recovery-boot-restore" - ], - "firmware": "uefi", - "iso_sha256": "9b6d480e558590dfc257b6b661f221e3c479c9703ebcefa5f5dad517018c235a", - "legacy_session_bootstrap": "activated full desktop target from baseline installer-only session", - "network": "outbound-blocked", - "scenario": "encrypted-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification.py b/image/qualification.py deleted file mode 100644 index 6f15771f..00000000 --- a/image/qualification.py +++ /dev/null @@ -1,488 +0,0 @@ -"""Opt-in graphical installation and lifecycle qualification on disposable QEMU disks.""" -import argparse -import json -from pathlib import Path -import secrets -import signal -import subprocess -import sys -import time - -from build_support import atomic_json, digest -from browser_qualification import browser_dependencies, ensure_installer_started, qualify_browser -from login_qualification import qualify_login -from qualification_contract import SCHEMA, validate as validate_qualification -from public_update_qualification import qualify_public_updater -from installed_outcomes import capture_guest -from offline_upgrade_inputs import load_bundle -from parity_qualification import desktop_lifecycle, prepare_saved_choices, verify_saved_choices -from upgrade_qualification import (create_recovery_point, prepare_user_choices, select_recovery_boot, - upgrade, verify_recovery_boot, verify_restored, verify_user_choices) -from vm_testing import QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL, TestVM, poweroff_guest, require_test_iso, run - - -BACKEND = '/usr/libexec/cybexos-installer-backend' -SCENARIOS = { - 'encrypted-us': (True, 'us', 'en_US.UTF-8', 'UTC'), - 'plain-us': (False, 'us', 'en_US.UTF-8', 'UTC'), - 'encrypted-nl': (True, 'nl', 'nl_NL.UTF-8', 'Europe/Amsterdam'), - 'plain-nl': (False, 'nl', 'nl_NL.UTF-8', 'Europe/Amsterdam'), - 'encrypted-de': (True, 'de', 'de_DE.UTF-8', 'Europe/Berlin'), - 'plain-de': (False, 'de', 'de_DE.UTF-8', 'Europe/Berlin'), -} - - -def backend(vm, action, payload=None): - result = run([*vm.ssh, f'sudo {BACKEND} {action}'], input=json.dumps(payload or {}), text=True, capture_output=True, timeout=120) - responses = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] - results = [item for item in responses if item.get('event') == 'result'] - if not results or not results[-1].get('ok'): - raise RuntimeError(f'Installer {action} failed without a successful result') - return results[-1]['data'] - - -def root_script(vm, script, password, timeout=120): - # Password stays in memory/stdin and is never part of a command line or log. - if subprocess.run([*vm.ssh, "sudo -k -n true"], capture_output=True, timeout=15).returncode == 0: - return run([*vm.ssh, "sudo -n bash -e -s"], input=script, text=True, capture_output=True, timeout=timeout) - return run([*vm.ssh, "sudo -k -S -p '' bash -e -s"], input=password + '\n' + script, text=True, capture_output=True, timeout=timeout) - - -def reapply_saved_policy(vm, password): - # ISO qualification deliberately blocks outbound networking. Reapply the - # packaged policy with the same supported offline mode as installation. - try: - root_script(vm, '/usr/libexec/cybexos-configure-installed --offline --user qualification\n', - password, timeout=1800) - except subprocess.CalledProcessError as error: - details = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-3000:].strip() - raise RuntimeError(f'Reapplying the installed policy failed: {details}') from error - - -# The default "Install CybexOS" boot entry: only the installer session runs, -# and its window has already started Anaconda. -INSTALL_MODE_AUDIT = r''' -set -eu -test -e /run/cybexos-install-mode -tr ' ' '\n' /dev/null -''' - - -INSTALLED_AUDIT = r''' -trap 'printf "Installed audit shell check failed at line %s\n" "$LINENO" >&2' ERR -if getent passwd liveuser; then - echo 'Installed audit failed: liveuser still exists' >&2 - exit 1 -else - test "$?" -eq 2 -fi -for path in /etc/sudoers.d/cybexos-live /etc/polkit-1/rules.d/49-cybexos-live.rules /usr/lib/systemd/system/cybexos-live.service; do - test ! -e "$path" -done -test "$(getenforce)" = Enforcing -test "$(findmnt -n -o FSTYPE /)" = btrfs -root_source=$(findmnt -n -o SOURCE /) -root_types=$(lsblk -s -n -o TYPE "${root_source%%\[*}") -if [ "${EXPECTED_ENCRYPTED}" = true ]; then - grep -qx crypt <<< "$root_types" -elif grep -qx crypt <<< "$root_types"; then - echo 'Installed audit failed: plain installation uses encryption' >&2 - exit 1 -fi -systemctl is-active --quiet sddm.service -test "$(systemctl show sddm.service -p KeyringMode --value)" = inherit -if rpm -q gdm >/dev/null; then - echo 'Installed audit failed: GDM is installed' >&2 - exit 1 -else - test "$?" -eq 1 -fi -if [ "${REQUIRE_SECURE_SUDO}" = true ]; then - grep -qx 'passwordless_wheel: false' /etc/cybexos/config.yml - test ! -e /etc/sudoers.d/10-wheel-nopasswd -fi -python3 - <<'CHECK' -import configparser,json -import os -from pathlib import Path -import re -import subprocess -encrypted=os.environ['EXPECTED_ENCRYPTED'] == 'true' -policy=json.loads(Path('/etc/cybexos/login.json').read_text()) -assert policy == {'version':1, 'user':'qualification', 'autologin':encrypted, 'live':False}, 'Installed login policy differs' -config=configparser.ConfigParser() -config.read('/etc/sddm.conf') -assert config.get('Autologin','User',fallback='') == ('qualification' if encrypted else ''), 'SDDM login user differs' -if encrypted: - assert config.get('Autologin','Session') == 'hyprland-quickshell.desktop', 'SDDM session differs' - assert not config.getboolean('Autologin','Relogin'), 'SDDM permits repeated autologin' -marker=Path('/run/cybexos-login/autologin-used') -if encrypted: - assert marker.exists() and marker.stat().st_uid == 0, 'Autologin marker missing or not root-owned' - assert not marker.stat().st_mode & 0o077, 'Autologin marker permissions are too broad' -plymouth=configparser.ConfigParser() -plymouth.read('/etc/plymouth/plymouthd.conf') -assert plymouth.get('Daemon','Theme') == 'cybex', 'Plymouth theme differs' -contract=json.loads(Path('/usr/share/cybexos/desktop-contract.json').read_text()) -settings=json.loads(Path('/home/qualification/.config/cybexos/shell.json').read_text()) -for key,value in contract['shell'].items(): - assert settings.get(key) == value, f'Desktop default mismatch: {key}' -assert not Path('/home/qualification/.config/cybexos/hypr/user.lua').exists(), 'Unexpected personal Hyprland override' -assert Path('/home/qualification/.local/state/cybexos/offline-apps-seeded').exists(), 'Offline application seed marker missing' -expected_locale=os.environ['EXPECTED_LOCALE'] -expected_timezone=os.environ['EXPECTED_TIMEZONE'] -expected_keyboard=os.environ['EXPECTED_KEYBOARD'] -expected_boot_keymap=os.environ['EXPECTED_BOOT_KEYMAP'] -locale=dict(line.split('=', 1) for line in Path('/etc/locale.conf').read_text().splitlines() - if line.startswith('LANG=')) -assert locale['LANG'].strip('"') == expected_locale, 'Installed locale differs' -# Fedora's UTC aliases can be hardlinks, whose resolved path names differ. -assert Path('/etc/localtime').samefile(Path('/usr/share/zoneinfo') / expected_timezone), 'Installed timezone differs' -console=dict(line.split('=', 1) for line in Path('/etc/vconsole.conf').read_text().splitlines() - if line.startswith('KEYMAP=')) -assert console['KEYMAP'].strip('"') == expected_boot_keymap, 'Installed console keymap differs' -localectl=subprocess.check_output(['localectl', 'status'], text=True, - env={**os.environ, 'LC_ALL': 'C'}) -assert re.search(r'^\s*X11 Layout:\s*' + re.escape(expected_keyboard) + r'\s*$', localectl, re.M), 'Installed X11 keyboard differs' -CHECK -''' - - -DESKTOP_KEYBOARD_AUDIT = r''' -import json -import os -import subprocess -import sys - -expected = sys.argv[1] -manager = subprocess.check_output(['systemctl', '--user', 'show-environment'], text=True) -for line in manager.splitlines(): - if line.startswith(('HYPRLAND_INSTANCE_SIGNATURE=', 'WAYLAND_DISPLAY=')): - key, value = line.split('=', 1) - os.environ[key] = value -assert os.environ.get('HYPRLAND_INSTANCE_SIGNATURE') -result = subprocess.check_output(['hyprctl', '-j', 'getoption', 'input:kb_layout'], text=True) -assert json.loads(result)['str'] == expected, 'Desktop keyboard differs from installed choice' -''' - - -def installed_audit(encrypted, require_secure_sudo, keyboard, boot_keymap, locale, timezone): - return (f'export EXPECTED_ENCRYPTED={str(encrypted).lower()}\n' - f'export REQUIRE_SECURE_SUDO={str(require_secure_sudo).lower()}\n' - f'export EXPECTED_KEYBOARD={keyboard}\n' - f'export EXPECTED_BOOT_KEYMAP={boot_keymap}\n' - f'export EXPECTED_LOCALE={locale}\n' - f'export EXPECTED_TIMEZONE={timezone}\n' + INSTALLED_AUDIT) - - -def verify_installed_audit(vm, encrypted, require_secure_sudo, keyboard, boot_keymap, - locale, timezone, password): - script = installed_audit(encrypted, require_secure_sudo, keyboard, boot_keymap, locale, timezone) - try: - return root_script(vm, script, password) - except subprocess.CalledProcessError as error: - details = '\n'.join(str(part or '') for part in (error.stdout, error.stderr)) - details = details.replace(password, '[redacted]')[-3000:].strip() - raise RuntimeError(f'Installed audit failed (exit {error.returncode}): {details}') from error - - -def qualification_disks(vm): - output = run([*vm.ssh, 'lsblk -dn -o NAME,SERIAL'], text=True, capture_output=True, timeout=15).stdout - devices = {} - for line in output.splitlines(): - parts = line.split() - if len(parts) == 2 and parts[1] in (QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL): - if parts[1] in devices: - raise RuntimeError('Duplicate qualification disk serial in guest') - devices[parts[1]] = parts[0] - if set(devices) != {QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL}: - raise RuntimeError('Both serial-identified disposable disks must be visible in the guest') - return devices[QUALIFICATION_DISK_SERIAL], devices[QUALIFICATION_UNUSED_SERIAL] - - -def focus_installed_desktop(vm, password, autologin): - """Return from the verified text console to the graphical session.""" - from login_qualification import wait_login_state - # On subsequent boots SSH can become ready before SDDM creates its VT. - wait_login_state(vm, desktop=autologin) - script = '''python3 - <<'PY' -import subprocess -for line in subprocess.check_output(['loginctl', 'list-sessions', '--no-legend', '--no-pager'], text=True).splitlines(): - identifier = line.split()[0] - values = dict(item.split('=', 1) for item in subprocess.check_output( - ['loginctl', 'show-session', identifier, '-p', 'Class', '-p', 'Name', '-p', 'Type', '-p', 'VTNr'], text=True).splitlines()) - if ((values.get('Class') == 'greeter' and not AUTOLOGIN) or - (values.get('Class') == 'user' and values.get('Name') == 'qualification' and - values.get('Type') == 'wayland' and AUTOLOGIN)) and values.get('VTNr', '').isdigit(): - print(values['VTNr']) - raise SystemExit(0) -raise SystemExit(1) -PY -''' - script = script.replace('import subprocess\n', f'import subprocess\nAUTOLOGIN = {autologin!r}\n', 1) - vt = int(run([*vm.ssh, 'bash -s'], input=script, text=True, capture_output=True, - timeout=20).stdout.strip()) - if not 1 <= vt <= 6: - raise RuntimeError('Installed greeter was not on an expected virtual terminal') - vm.keypress(f'ctrl+alt+f{vt}') - if autologin: - vm.wait_desktop() - wait_login_state(vm, desktop=True) - return - time.sleep(2) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - - -def boot_installed(vm, password, encrypted, *, autologin=None): - if autologin is None: - autologin = encrypted - vm.start(user='qualification') - if encrypted: - vm.unlock_disk(password) - try: - vm.wait_ssh(timeout=12, setup=False, redactions=(password,)) - except RuntimeError: - vm.bootstrap_installed_ssh(password) - focus_installed_desktop(vm, password, autologin) - - -def poweroff_installed(vm, password): - poweroff_guest(vm, password, root_script) - - -def main(): - # Register here as well as direct module execution: image/qualify imports - # this function, so module __main__ hooks alone do not protect cleanup. - for name in (signal.SIGTERM, signal.SIGHUP): - signal.signal(name, interrupt) - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('iso', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--firmware', choices=('uefi', 'bios'), default='uefi') - parser.add_argument('--scenario', choices=tuple(SCENARIOS), default='encrypted-us') - parser.add_argument('--candidate-rpm', type=Path, help='Newer desktop RPM for an offline N to N+1 upgrade') - parser.add_argument('--candidate-offline-rpm', type=Path, help='Companion offline applications RPM for the same upgrade') - parser.add_argument('--candidate-dependencies', type=Path, - help='Manifest-bound third-party RPM directory for the single offline upgrade transaction') - parser.add_argument('--recovery-check', action='store_true', help='Boot and restore the pre-upgrade recovery point') - parser.add_argument('--legacy-installer', action='store_true', help='Permit baseline installer without new policy/rescan controls') - parser.add_argument('--execute-vm', action='store_true') - parser.add_argument('--erase-disposable-disk', action='store_true', help='Explicitly allow installation onto the new task-owned virtual disk') - parser.add_argument('--keep-artifacts', action='store_true', help='Retain task-owned disk/logs for unresolved diagnostics') - parser.add_argument('--install-timeout', type=int, default=1800) - parser.add_argument('--capture-outcomes', action='store_true', help='Capture fresh and saved-choice outcomes for same-source checkout comparison') - parser.add_argument('--boot-entry', choices=('install', 'live'), default='install', - help='ISO menu entry: the default installation-only session, or the live desktop') - args = parser.parse_args() - if not args.execute_vm or not args.erase_disposable_disk: - parser.error('qualification requires --execute-vm --erase-disposable-disk; no VM or installer starts without both') - if args.candidate_rpm and not (args.recovery_check and args.candidate_offline_rpm): - parser.error('Candidate qualification requires --recovery-check and both candidate RPMs') - if args.candidate_rpm and args.scenario != 'encrypted-us': - parser.error('Candidate/public updater qualification reuses the encrypted-us guest') - if args.recovery_check and not args.candidate_rpm: - parser.error('--recovery-check requires --candidate-rpm') - if args.candidate_offline_rpm and not args.candidate_rpm: - parser.error('--candidate-offline-rpm requires --candidate-rpm') - if args.candidate_dependencies and not (args.candidate_rpm and args.candidate_offline_rpm): - parser.error('--candidate-dependencies requires both --candidate-rpm and --candidate-offline-rpm') - if args.candidate_offline_rpm and (args.candidate_offline_rpm.is_symlink() or not args.candidate_offline_rpm.is_file() - or args.candidate_offline_rpm.suffix != '.rpm'): - parser.error('--candidate-offline-rpm must be a regular .rpm file') - if args.capture_outcomes and (args.candidate_rpm or args.legacy_installer): - parser.error('Outcome parity capture requires the fresh candidate ISO') - iso = require_test_iso(args.iso) - if args.candidate_rpm and (args.candidate_rpm.is_symlink() or not args.candidate_rpm.is_file() - or args.candidate_rpm.suffix != '.rpm'): - parser.error('--candidate-rpm must be a regular .rpm file') - dependencies = None - if args.candidate_dependencies: - try: - dependencies = load_bundle(args.candidate_dependencies, baseline_iso=iso, - candidate_rpm=args.candidate_rpm, candidate_offline_rpm=args.candidate_offline_rpm) - except (ValueError, OSError) as error: - parser.error(str(error)) - # Direct invocations need the same early host check as release-gate. - # Missing browser/Node dependencies must not consume a live boot or disk. - browser_dependencies() - encrypted, keyboard, locale, timezone = SCENARIOS[args.scenario] - vm = TestVM(args.output, args.firmware, guard_disk=True) - # Hex uses the same physical keys under US, NL and DE layouts, including - # the early boot unlock and text-console bootstrap paths. - password = secrets.token_hex(24) - report = {'evidence_schema': SCHEMA, 'execution_mode': 'qemu', - 'qualification_kind': 'upgrade' if args.candidate_rpm else 'fresh', - 'legacy_installer': args.legacy_installer, 'iso_sha256': digest(iso), - 'candidate_rpm_sha256': digest(args.candidate_rpm) if args.candidate_rpm else None, - 'candidate_offline_rpm_sha256': digest(args.candidate_offline_rpm) if args.candidate_offline_rpm else None, - 'boot_entry': args.boot_entry, 'scenario': args.scenario, 'firmware': args.firmware, 'status': 'failed', 'checks': [], - 'network': 'outbound-blocked', 'bootstrap': 'recognized disk-unlock prompt; bounded graphical SSH setup', 'scope': 'QEMU fixture; does not qualify physical hardware or Secure Boot'} - if dependencies: - report['candidate_dependencies'] = dependencies.evidence() - try: - vm.prepare() - untouched_sha = digest(vm.unused_disk) - vm.start(iso) - vm.expect_shell = args.boot_entry == 'live' - vm.select_boot_entry(args.boot_entry) - vm.wait_ssh() - if args.boot_entry == 'install' and not args.legacy_installer: - run([*vm.ssh, 'bash -s'], input=INSTALL_MODE_AUDIT, text=True, timeout=45) - report['checks'].append('installation-only-session') - if args.legacy_installer: - # The retained N image deliberately starts an installer-only - # session target in live mode. Activate its full desktop target - # only in this disposable older guest for the shell/application - # audit. New images must pass normal startup without this branch. - live_shell = subprocess.run( - [*vm.ssh, 'systemctl --user is-active --quiet quickshell.service'], - capture_output=True, timeout=15) - if live_shell.returncode: - installer_only = subprocess.run( - [*vm.ssh, 'test -e /run/cybexos-install-mode && ' - 'systemctl --user is-active --quiet cybexos-install-session.target'], - capture_output=True, timeout=15) - if installer_only.returncode: - raise RuntimeError('Legacy guest shell is inactive outside the expected installer-only session') - run([*vm.ssh, 'systemctl --user start hyprland-session.target'], timeout=30) - report['legacy_session_bootstrap'] = 'activated full desktop target from baseline installer-only session' - report['checks'].append('legacy-installer-only-session-desktop-bootstrap') - if args.boot_entry == 'live' or args.legacy_installer: - vm.expect_shell = True - vm.audit() - report['checks'].append('live-boot-and-offline-applications') - target_disk, unused_disk = qualification_disks(vm) - if args.boot_entry == 'live' or args.legacy_installer: - ensure_installer_started(vm) - browser_result = qualify_browser(vm, password=password, target_disk=target_disk, unused_disk=unused_disk, - encrypted=encrypted, keyboard=keyboard, locale=locale, timezone=timezone, - install_timeout=args.install_timeout, require_policy_controls=not args.legacy_installer) - report['checks'].append('graphical-installer') - report['checks'].append('encrypted-installation' if encrypted else 'plain-installation') - # Audit live state before a graceful shutdown; target mounts are left - # to Anaconda/systemd. Do not assume /mnt/sysroot survives completion. - if args.boot_entry == 'install' and not args.legacy_installer: - run([*vm.ssh, 'bash -s'], input=INSTALL_MODE_AUDIT, text=True, timeout=45) - else: - vm.audit(applications=False) - vm.stop(graceful=True) - if digest(vm.unused_disk) != untouched_sha: - raise RuntimeError('The unused disposable guard disk changed during installation') - report['checks'].append('unused-disk-unchanged') - (vm.work / 'known_hosts').unlink(missing_ok=True) - vm.expect_shell = True - boot_installed(vm, password, encrypted) # No ISO/CD-ROM attached. - vm.audit() - if not args.candidate_rpm: - build = json.loads(root_script(vm, 'cat /usr/share/cybexos/build.json\n', password).stdout) - report.update({key: build[key] for key in ('source_revision', 'source_content_sha256')}) - verify_installed_audit(vm, encrypted, not args.legacy_installer, - keyboard, browser_result['boot_keyboard'], locale, timezone, password) - run([*vm.ssh, f'python3 - {keyboard}'], input=DESKTOP_KEYBOARD_AUDIT, - text=True, capture_output=True, timeout=20) - report['checks'] += ['installed-boot-without-iso', 'encrypted-btrfs' if encrypted else 'plain-btrfs', - 'autologin' if encrypted else 'password-login', 'desktop-parity', - 'selected-locale-timezone-keyboard', 'live-cleanup', 'selinux-enforcing'] - if not args.legacy_installer: - sudo = subprocess.run([*vm.ssh, 'sudo -k -n true'], capture_output=True, timeout=15) - if sudo.returncode == 0: - raise RuntimeError('Fresh installation unexpectedly allows passwordless sudo') - report['checks'].append('sudo-requires-password') - if encrypted and not args.candidate_rpm: - qualify_login(vm, password, root_script, report, secrets.token_urlsafe(32)) - if args.candidate_rpm: - preference = prepare_user_choices(vm, password, root_script) - point = create_recovery_point(vm, password, root_script) if args.recovery_check and not dependencies else None - options = {'candidate_dependencies': dependencies} if dependencies else {} - sha, versions = upgrade(vm, args.candidate_rpm, password, root_script, - offline_candidate=args.candidate_offline_rpm, **options) - if sha != report['candidate_rpm_sha256']: - raise RuntimeError('Candidate RPM checksum changed during qualification') - if args.candidate_offline_rpm and versions['candidate_offline_rpm_sha256'] != report['candidate_offline_rpm_sha256']: - raise RuntimeError('Candidate offline RPM checksum changed during qualification') - if dependencies: - point = versions['recovery_point'] - report['candidate_dependencies']['guest_signatures_verified'] = True - report['checks'].append('offline-dependency-signatures-and-single-transaction') - report['pre_upgrade_recovery_point'] = point - report['checks'].append('installed-rpm-upgrade') - verify_user_choices(vm, password, root_script, preference) - if point and args.recovery_check: - select_recovery_boot(vm, point, password, root_script) - poweroff_installed(vm, password) - # The recovery overlay cannot prove encrypted root ancestry, - # so login policy requires a password even after LUKS unlock. - boot_installed(vm, password, encrypted, autologin=False) - verify_recovery_boot(vm, point, password, root_script) - poweroff_installed(vm, password) - boot_installed(vm, password, encrypted) - verify_restored(vm, versions['installed'], password, root_script) - verify_user_choices(vm, password, root_script, preference) - report['checks'].append('recovery-boot-restore') - qualify_public_updater(vm, args.candidate_rpm, args.candidate_offline_rpm, - password, root_script, candidate_dependencies=dependencies, - boot_installed=boot_installed, poweroff_installed=poweroff_installed, - verify_user_choices=verify_user_choices, preference=preference, - report=report) - if args.capture_outcomes: - desktop_lifecycle(vm) - fresh = capture_guest(vm, root_script, password, installation='iso', scenario=args.scenario, - profile='fresh', manifest='/usr/share/cybexos/applications.json', - provenance='/usr/share/cybexos/build.json') - atomic_json(args.output / 'outcomes-fresh.json', fresh) - prepare_saved_choices(vm, password, root_script) - reapply_saved_policy(vm, password) - poweroff_installed(vm, password) - boot_installed(vm, password, encrypted) - verify_saved_choices(vm, password, root_script) - desktop_lifecycle(vm) - saved = capture_guest(vm, root_script, password, installation='iso', scenario=args.scenario, - profile='saved', manifest='/usr/share/cybexos/applications.json', - provenance='/usr/share/cybexos/build.json') - atomic_json(args.output / 'outcomes-saved.json', saved) - report['source_revision'] = fresh['source_revision'] - report['source_content_sha256'] = fresh['source_content_sha256'] - report['checks'] += ['installed-outcomes-captured', 'saved-choice-reconfiguration', 'native-settings-lifecycle'] - # Clear the temporary test access before stopping the disposable disk. - vm.audit(applications=False) - poweroff_guest(vm, password, root_script, timeout=60, cleanup_script=( - 'rm -f /home/qualification/.ssh/authorized_keys /home/qualification/.bash_history\n' - 'systemctl disable sshd.service\n')) - if digest(vm.unused_disk) != untouched_sha: - raise RuntimeError('The unused disposable guard disk changed after installed boot') - report['status'] = 'passed' - validate_qualification(report) - except BaseException as error: - report['status'] = 'failed' - report['error'] = str(error) or type(error).__name__ - raise - finally: - if vm.owned: - failed_before_cleanup = sys.exc_info()[0] is not None - try: - vm.cleanup(args.keep_artifacts) - except BaseException as cleanup_error: - report['status'] = 'failed' - report['cleanup_error'] = str(cleanup_error) or type(cleanup_error).__name__ - if not failed_before_cleanup: - raise - finally: - atomic_json(args.output / 'qualification.json', report) - print(f"Qualification passed: {args.output / 'qualification.json'}") - - -def interrupt(_signum, _frame): - raise KeyboardInterrupt - - -if __name__ == '__main__': - main() diff --git a/image/qualification_contract.py b/image/qualification_contract.py deleted file mode 100644 index 4a97b357..00000000 --- a/image/qualification_contract.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Versioned completed-evidence contract for QEMU producers and release admission. - -This is an evidence inventory, not a substitute for executing the guest checks. -Artifact SHA-256 identities and same-source checkout parity remain mandatory. -""" -import re - -SCHEMA = 1 -FRESH_SCENARIOS = frozenset(('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl')) -BASE = frozenset(('graphical-installer', 'unused-disk-unchanged', 'installed-boot-without-iso', - 'desktop-parity', 'selected-locale-timezone-keyboard', 'live-cleanup', - 'selinux-enforcing')) -LOGIN = frozenset(( - 'initial-encrypted-keyring-unlocked-without-prompt', - 'synthetic-keyring-secret-survives-cold-reboot', - 'missing-cached-password-preserves-locked-encrypted-keyring', - 'fallback-password-login-unlocks-existing-keyring', - 'first-autologin-launch-failure-consumes-boot-attempt', - 'failed-first-autologin-manager-restart-requires-authentication', - 'failed-first-autologin-password-login-recovers-desktop', - *(action + suffix for action in ('logout', 'compositor-crash', 'manager-restart') - for suffix in ('-requires-authentication', '-password-recovery-and-keyring')), -)) -PUBLIC_UPDATE = frozenset(( - 'public-updater-committed', 'public-updater-interrupted-after-apply', - 'public-updater-pre-login-recovery', 'public-updater-second-boot-settled', - 'public-updater-root-package-vendor-restored', 'public-updater-personal-data-preserved', - 'public-updater-no-surviving-workers', - 'provisioning-cgroup-timeout-drains-detached-workers', -)) - - -def required_checks(scenario, kind, boot_entry='install'): - if not isinstance(scenario, str) or not isinstance(kind, str): - raise ValueError('Qualification scenario and kind must be strings') - if scenario not in FRESH_SCENARIOS | {'encrypted-de', 'plain-de'} or kind not in ('fresh', 'upgrade'): - raise ValueError('Unknown qualification scenario or kind') - encrypted = scenario.startswith('encrypted-') - result = BASE | ({'encrypted-installation', 'encrypted-btrfs', 'autologin'} if encrypted else - {'plain-installation', 'plain-btrfs', 'password-login'}) - if kind == 'upgrade': - if scenario != 'encrypted-us': - raise ValueError('Public updater qualification requires the existing encrypted-us guest') - return result | {'installed-rpm-upgrade', 'recovery-boot-restore'} | PUBLIC_UPDATE - session = 'installation-only-session' if boot_entry == 'install' else 'live-boot-and-offline-applications' - return result | {session, 'sudo-requires-password'} | (LOGIN if encrypted else set()) - - -def validate(report): - if (type(report.get('evidence_schema')) is not int or report['evidence_schema'] != SCHEMA - or report.get('execution_mode') != 'qemu' - or report.get('network') != 'outbound-blocked' - or report.get('firmware') not in ('uefi', 'bios') - or report.get('boot_entry') not in ('install', 'live') - or type(report.get('legacy_installer')) is not bool): - raise ValueError('Unsupported qualification evidence schema or execution mode') - kind = report.get('qualification_kind') - if kind == 'fresh' and (report.get('legacy_installer') is not False - or report.get('candidate_rpm_sha256') is not None): - raise ValueError('Fresh qualification must execute the current default installer') - if kind == 'upgrade' and not re.fullmatch(r'[0-9a-f]{64}', str(report.get('candidate_rpm_sha256', ''))): - raise ValueError('Upgrade qualification must bind the exact candidate RPM') - for key, pattern in (('iso_sha256', r'[0-9a-f]{64}'), ('source_revision', r'[0-9a-f]{40,64}'), - ('source_content_sha256', r'[0-9a-f]{64}')): - if not isinstance(report.get(key), str) or not re.fullmatch(pattern, report[key]): - raise ValueError('Qualification must identify the exact artifact/source digest: ' + key) - checks = report.get('checks') - if (not isinstance(checks, list) or not all(isinstance(item, str) for item in checks) - or len(checks) != len(set(checks))): - raise ValueError('Qualification checks must be distinct completed check names') - missing = required_checks(report.get('scenario'), kind, report['boot_entry']) - set(checks) - if missing: - raise ValueError('Qualification is missing required scenario evidence: ' + ', '.join(sorted(missing))) - if report.get('status') != 'passed': - raise ValueError('Every qualification report must have passed') diff --git a/image/qualify b/image/qualify deleted file mode 100755 index 95bffe09..00000000 --- a/image/qualify +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 -from qualification import main - -if __name__ == '__main__': - main() diff --git a/image/qualify-checkout b/image/qualify-checkout deleted file mode 100755 index a3dfcc0c..00000000 --- a/image/qualify-checkout +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env python3 -"""Install the exact ISO source on pinned Fedora, boot its desktop, compare outcomes.""" -import argparse -import json -import os -from pathlib import Path -import secrets -import shlex -import shutil -import signal -import subprocess -import tarfile - -from build_support import atomic_json, create_seed, digest, checksum_entries -from download_cache import cached_download -from installed_outcomes import capture_guest, compare, read_json -from parity_qualification import desktop_lifecycle, prepare_saved_choices, verify_saved_choices -from qualification import SCENARIOS, boot_installed, poweroff_installed, root_script -from source_snapshot import archive_identity -from vm_testing import TestVM, run - -ROOT = Path(__file__).resolve().parents[1] -IMAGE = 'Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2' -IMAGE_URL = 'https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/' + IMAGE -IMAGE_SHA256 = '28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f' -SOURCE = '/home/qualification/cybexos-source' - - -def verify_inputs(artifacts, iso_results, scenario): - checksum_entries(artifacts) - archive = artifacts / 'source.tar.gz' - identity = archive_identity(archive) - provenance = read_json(artifacts / 'build-provenance.json') - if any(provenance.get(key) != value for key, value in identity.items()): - raise ValueError('Source archive does not match the candidate ISO provenance') - with tarfile.open(archive, 'r:gz') as stream: - embedded = json.load(stream.extractfile('image/build-provenance.json')) - if embedded != provenance: - raise ValueError('Source archive embeds different build provenance') - qualification = read_json(iso_results / 'qualification.json') - isos = list(artifacts.glob('*.iso')) - if (len(isos) != 1 or qualification.get('status') != 'passed' - or qualification.get('scenario') != scenario or qualification.get('iso_sha256') != digest(isos[0])): - raise ValueError('Checkout parity requires the exact candidate ISO qualification') - for profile in ('fresh', 'saved'): - result = read_json(iso_results / ('outcomes-' + profile + '.json')) - if (result.get('source_content_sha256') != identity['source_content_sha256'] or - result.get('source_revision') != provenance.get('source_revision') or - result.get('scenario') != scenario or result.get('profile') != profile or result.get('installation') != 'iso'): - raise ValueError('ISO outcomes and checkout source are not from the same build') - return archive, provenance - - -def cloud_seed(vm, password): - # Only a salted password hash is placed in cloud-init; the plaintext stays - # in memory/stdin and never appears in argv, reports, logs or screenshots. - encoded = run(['openssl', 'passwd', '-6', '-stdin'], input=password + '\n', text=True, capture_output=True).stdout.strip() - config = {'users': [{'name': 'qualification', 'groups': ['wheel'], 'shell': '/bin/bash', - 'sudo': 'ALL=(ALL) NOPASSWD:ALL', 'lock_passwd': False, 'passwd': encoded, - 'ssh_authorized_keys': [vm.key.with_suffix('.pub').read_text().strip()]}], - 'ssh_pwauth': False, 'disable_root': True, 'preserve_hostname': True, - 'runcmd': [['hostnamectl', 'set-hostname', 'cybexos-qualification'], - ['loginctl', 'enable-linger', 'qualification'], - ['systemctl', 'enable', '--now', 'sshd.service']]} - (vm.work / 'user-data').write_text('#cloud-config\n' + json.dumps(config)) - (vm.work / 'meta-data').write_text('instance-id: cybexos-parity-' + secrets.token_hex(8) + '\n') - create_seed(vm.work, 'cloud-localds') - vm.seed = vm.work / 'seed.iso' - - -PREPARE_CLOUD_BASELINE = r'''set -euo pipefail -# Compare current Fedora against the ISO's current Fedora baseline. The pinned -# Cloud image predates the candidate by months. This is OS fixture preparation, -# not a project package or application seed import. -dnf upgrade -y --refresh --setopt=install_weak_deps=False --setopt=installonly_limit=2 -dnf install -y --setopt=install_weak_deps=False ansible-core git python3-pyyaml firewalld glibc-all-langpacks -# Cloud's release package pins an older common/identity package set even after -# upgrade. Use the ISO's generic Fedora identity in this disposable OS only. -dnf swap -y --setopt=install_weak_deps=False fedora-release-identity-cloud fedora-release-identity-basic -dnf swap -y --setopt=install_weak_deps=False fedora-release-cloud fedora-release -# Swap can select the release-repository build. Upgrade the new identity too; -# updating only the old Cloud identity before swapping is insufficient. -dnf upgrade -y --refresh --best --setopt=install_weak_deps=False fedora-release fedora-release-common fedora-release-identity-basic -# These belong only to the disposable Cloud bootstrap, not a desktop product. -# Keep all removal inside this explicitly created qualification guest. -systemctl is-active --quiet NetworkManager.service -bootstrap_packages=() -for package in cloud-init cloud-utils-growpart qemu-guest-agent \ - console-login-helper-messages console-login-helper-messages-issuegen \ - console-login-helper-messages-motdgen console-login-helper-messages-profile \ - rpm-plugin-audit rpm-plugin-systemd-inhibit systemd-networkd \ - glibc-minimal-langpack glibc-langpack-en glibc-langpack-nl \ - composefs criu-libs criu dhcpcd fuse-overlayfs gawk-all-langpacks \ - grub2-efi-ia32-modules grub2-efi-x64-modules initscripts-service kpartx \ - libbsd libmd libnet libretls memstrack mkpasswd nano-default-editor netcat \ - pkcs11-provider python-unversioned-command python3-configobj \ - python3-dnf-plugin-tracer python3-dnf-plugins-extras-common python3-html2text \ - python3-jsonpatch python3-jsonpointer python3-oauthlib python3-pysocks \ - python3-tracer python3-unbound python3-urllib3+socks \ - qemu-user-static qemu-user-static-aarch64 qemu-user-static-alpha \ - qemu-user-static-arm qemu-user-static-hexagon qemu-user-static-hppa \ - qemu-user-static-loongarch64 qemu-user-static-m68k qemu-user-static-microblaze \ - qemu-user-static-mips qemu-user-static-or1k qemu-user-static-ppc \ - qemu-user-static-riscv qemu-user-static-s390x qemu-user-static-sh4 \ - qemu-user-static-sparc qemu-user-static-x86 qemu-user-static-xtensa \ - sudo-python-plugin tpm2-tools tpm2-tss-fapi tracer-common unbound-anchor wcurl; do - if rpm -q "$package" >/dev/null 2>&1; then bootstrap_packages+=("$package"); fi -done -if (( ${#bootstrap_packages[@]} )); then - dnf remove -y --no-autoremove "${bootstrap_packages[@]}" -fi -''' - -FINALIZE_CLOUD_BASELINE = r'''set -euo pipefail -# Only this newly created test guest is pruned. Rebooting first means DNF can -# retain the latest/running kernel without protecting the old Cloud kernel. -dnf remove -y --oldinstallonly --limit=1 --no-autoremove -python3 - <<'BASELINE_PY' -import json, subprocess -kernel = subprocess.check_output(['uname', '-r'], text=True).strip() -installed = subprocess.check_output(['rpm', '-q', 'kernel-core', '--qf', '%{VERSION}-%{RELEASE}.%{ARCH}\n'], text=True).splitlines() -if installed != [kernel]: - raise RuntimeError('Cloud fixture did not boot and retain exactly the current kernel') -print(json.dumps({'running_kernel': kernel, 'installed_kernel_core': installed, - 'installed_packages': sorted(subprocess.check_output(['rpm', '-qa', '--qf', '%{NAME}.%{ARCH}=%{EVR}\n'], text=True).splitlines()), - 'scope': 'Disposable Fedora OS preparation before the public CybexOS installer; no product payload imported'})) -BASELINE_PY -''' - - -def prepare_cloud_baseline(vm, password): - """Update only a freshly owned Cloud fixture, then boot its current kernel.""" - if not vm.owned or vm.network_restricted: - raise ValueError('Cloud preparation requires an owned, online disposable VM') - vm.expect_shell = False - try: - root_script(vm, PREPARE_CLOUD_BASELINE, password, timeout=1800) - poweroff_installed(vm, password) - vm.seed = None - vm.start(user='qualification') - vm.wait_ssh(timeout=600, setup=False, redactions=(password,)) - result = root_script(vm, FINALIZE_CLOUD_BASELINE, password, timeout=600) - except subprocess.CalledProcessError as error: - detail = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-6000:] - raise RuntimeError('Disposable Fedora baseline preparation failed: ' + detail) from error - return json.loads(result.stdout.splitlines()[-1]) - - -def install_script(scenario, *, repeat=False): - encrypted, keyboard, locale, timezone = SCENARIOS[scenario] - if encrypted: - raise ValueError('Pinned cloud parity scenarios are plaintext; encrypted ISO qualification remains separate') - initial = '' if repeat else f''' -localectl set-locale LANG={shlex.quote(locale)} -timedatectl set-timezone {shlex.quote(timezone)} -localectl set-x11-keymap {shlex.quote(keyboard)} -''' - return f'''set -euo pipefail -export ANSIBLE_FORCE_COLOR=0 PYTHONDONTWRITEBYTECODE=1 -export SUDO_USER=qualification -{initial} -export LANG={shlex.quote(locale)} -cd {SOURCE} -# Keep the established transport alive through strict firewall convergence; -# the new runtime SSH exception is test-only and disappears on reboot. -cleanup() {{ firewall-cmd --zone=cybexos --add-service=ssh >/dev/null 2>&1 || true; }} -trap cleanup EXIT -./install --non-interactive -rm -f /etc/sudoers.d/90-cloud-init-users -test "$(systemctl get-default)" = graphical.target -systemctl is-enabled --quiet sddm.service -cleanup -trap - EXIT -''' - - -def install(vm, scenario, password, *, repeat=False): - # Use the public installer's supported sudo invocation. SUDO_USER selects - # the account; install derives its actual home through getent, not root's. - try: - root_script(vm, install_script(scenario, repeat=repeat), password, timeout=7200) - except subprocess.CalledProcessError as error: - detail = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-6000:] - raise RuntimeError('Full checkout installation failed: ' + detail) from error - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--artifacts', required=True, type=Path) - parser.add_argument('--iso-results', required=True, type=Path) - parser.add_argument('--scenario', choices=('plain-us', 'plain-nl'), required=True) - parser.add_argument('--output', required=True, type=Path) - parser.add_argument('--cache', type=Path, default=Path(os.environ.get('XDG_CACHE_HOME', str(Path.home() / '.cache'))) / 'cybexos/image/base') - parser.add_argument('--execute-vm', action='store_true') - parser.add_argument('--keep-artifacts', action='store_true') - args = parser.parse_args() - if not args.execute_vm: - parser.error('--execute-vm explicitly authorizes new disposable VM disks and a complete guest installation') - for signal_number in (signal.SIGTERM, signal.SIGHUP): - signal.signal(signal_number, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) - for executable in ('cloud-localds', 'openssl'): - if not shutil.which(executable): - raise RuntimeError('Missing checkout qualification prerequisite: ' + executable) - archive, provenance = verify_inputs(args.artifacts, args.iso_results, args.scenario) - base = cached_download(IMAGE_URL, IMAGE_SHA256, args.cache) - vm = TestVM(args.output, network_restricted=False) - password = secrets.token_hex(24) - report = {'status': 'failed', 'scenario': args.scenario, 'source_revision': provenance['source_revision'], - 'source_content_sha256': provenance['source_content_sha256'], 'source_archive_sha256': digest(archive), - 'fedora_cloud_sha256': IMAGE_SHA256, 'checks': [], - 'scope': 'Full checkout installation and graphical session on disposable QEMU Fedora Cloud; no physical hardware qualification'} - try: - vm.prepare() - vm.disk.unlink() # Only the disk just created by this owned TestVM. - run(['qemu-img', 'create', '-q', '-f', 'qcow2', '-F', 'qcow2', '-b', str(base), str(vm.disk), '100G']) - cloud_seed(vm, password) - vm.start(user='qualification') - vm.wait_ssh(timeout=600, setup=False, redactions=(password,)) - run([*vm.ssh, 'cloud-init status --wait --format=json'], text=True, capture_output=True, timeout=600) - report['fedora_baseline'] = prepare_cloud_baseline(vm, password) - run([*vm.ssh, f'mkdir -p {SOURCE}'], timeout=20) - with archive.open('rb') as source: - run([*vm.ssh, f'tar xzf - -C {SOURCE}'], stdin=source, capture_output=True, timeout=120) - with (args.artifacts / 'applications.json').open('rb') as manifest: - run([*vm.ssh, 'cat > /home/qualification/cybexos-applications.json'], stdin=manifest, timeout=30) - install(vm, args.scenario, password) - report['checks'].append('full-checkout-installation') - # The first shutdown happens before a graphical session is necessarily - # ready; use the authenticated poweroff helper, not TestVM.stop's audit. - poweroff_installed(vm, password) - vm.seed = None - boot_installed(vm, password, False) - vm.expect_shell = True - for profile in ('fresh', 'saved'): - if profile == 'saved': - prepare_saved_choices(vm, password, root_script) - install(vm, args.scenario, password, repeat=True) - poweroff_installed(vm, password) - boot_installed(vm, password, False) - verify_saved_choices(vm, password, root_script) - vm.audit(applications=False) - desktop_lifecycle(vm) - result = capture_guest(vm, root_script, password, installation='checkout', scenario=args.scenario, - profile=profile, manifest='/home/qualification/cybexos-applications.json', - provenance=SOURCE + '/image/build-provenance.json') - atomic_json(args.output / ('outcomes-' + profile + '.json'), result) - comparison = compare(read_json(args.iso_results / ('outcomes-' + profile + '.json')), result, - read_json(ROOT / 'image/parity-exceptions.json')) - atomic_json(args.output / ('parity-' + profile + '.json'), comparison) - if comparison['status'] != 'passed': - raise RuntimeError('Installed outcome mismatch; inspect parity-' + profile + '.json. No differences were auto-approved.') - report['checks'].append('installed-parity-' + profile) - report['checks'] += ['same-source-content', 'native-settings-lifecycle', 'saved-choice-reconfiguration', 'full-graphical-session'] - poweroff_installed(vm, password) - report['status'] = 'passed' - except BaseException as error: - report['error'] = (str(error) or type(error).__name__).replace(password, '[redacted]') - raise - finally: - if vm.owned: - try: - vm.cleanup(args.keep_artifacts) - finally: - for name in ('user-data', 'meta-data', 'seed.iso'): - (vm.work / name).unlink(missing_ok=True) - atomic_json(args.output / 'checkout-qualification.json', report) - print(json.dumps(report)) - - -if __name__ == '__main__': - main() diff --git a/image/real_browser_qualification.cjs b/image/real_browser_qualification.cjs deleted file mode 100644 index 31c6f7d7..00000000 --- a/image/real_browser_qualification.cjs +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -// Drives the guest's real Cockpit/Anaconda page through an SSH tunnel. -// Fixture credentials arrive on stdin and are never printed or saved. -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -let secret = ""; - -async function waitForInitialSetup(page, timeoutMs = 120000, - retryDelays = [1000, 2000, 4000, 8000, 16000, 32000, 32000]) { - const deadline = Date.now() + timeoutMs; - let retries = 0; - while (true) { - const remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("Installer setup did not become ready before its deadline"); - await page.waitForFunction(() => { - const setup = document.querySelector("#setup"); - if (setup && !setup.hidden) return true; - const retry = document.querySelector("#startup-retry"); - const error = document.querySelector("#error"); - return setup?.hidden && retry && !retry.hidden && error && !error.hidden && - error.textContent.trim() === "Another installer operation is still running."; - }, null, {timeout: remaining}); - if (await page.locator("#setup").isVisible()) return; - if (retries >= retryDelays.length) throw new Error("Installer setup remained busy after bounded initialization retries"); - await page.waitForTimeout(Math.min(retryDelays[retries], Math.max(0, deadline - Date.now()))); - if (await page.locator("#setup").isVisible()) return; - const error = await page.locator("#error").textContent(); - if (error?.trim() !== "Another installer operation is still running." || - !await page.locator("#startup-retry").isVisible()) continue; - const clickTimeout = deadline - Date.now(); - if (clickTimeout <= 0) throw new Error("Installer setup did not become ready before its deadline"); - await page.locator("#retry").click({timeout: clickTimeout}); - retries++; - } -} - -async function selectKeyboard(page, keyboard, timeoutMs = 120000, - retryDelays = [1000, 2000, 4000, 8000, 16000, 32000, 32000]) { - const deadline = Date.now() + timeoutMs; - let retries = 0; - while (true) { - let remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("Installer keyboard selection exceeded its deadline"); - await page.selectOption("#keyboard", keyboard, {timeout: remaining}); - remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("Installer keyboard selection exceeded its deadline"); - await page.waitForFunction(expected => { - const error = document.querySelector("#error"); - return (document.querySelector("#keyboard").value === expected && - !document.querySelector("#password").disabled) || - (error && !error.hidden && error.textContent.trim() !== ""); - }, keyboard, {timeout: remaining}); - if (await page.inputValue("#keyboard") === keyboard && - !await page.locator("#password").isDisabled()) return; - const error = (await page.locator("#error").textContent())?.trim(); - if (error !== "Another installer operation is still running.") - throw new Error(`Installer keyboard selection failed: ${error || "unknown error"}`); - if (retries >= retryDelays.length) - throw new Error("Installer keyboard selection remained busy after bounded retries"); - await page.waitForTimeout(Math.min(retryDelays[retries], Math.max(0, deadline - Date.now()))); - retries++; - } -} - -async function main() { - const {chromium} = require("playwright-core"); - const input = JSON.parse(fs.readFileSync(0, "utf8")); - secret = input.password; - const url = new URL(input.url); - assert.equal(url.hostname, "127.0.0.1"); - assert.equal(url.pathname, "/cockpit/@localhost/cybexos-installer/index.html"); - assert.match(input.target_disk, /^[A-Za-z0-9_-]+$/); - assert.match(input.target_serial, /^CYBEXOS-QUALIFY$/); - assert.match(input.unused_disk, /^[A-Za-z0-9_-]+$/); - assert.notEqual(input.target_disk, input.unused_disk); - const browser = await chromium.launch({executablePath: input.browser, headless: true, - args: ["--disable-dev-shm-usage"]}); - let page; - const pageErrors = []; - try { - page = await browser.newPage({viewport: {width: 1280, height: 900}}); - page.on("pageerror", error => { - if (pageErrors.length < 4) pageErrors.push(String(error.stack || error).slice(0, 500)); - }); - // The guest remains offline; the browser may only talk to its tunnel. - await page.route("**/*", route => { - const request = new URL(route.request().url()); - return request.hostname === "127.0.0.1" && request.port === url.port - ? route.continue() : route.abort(); - }); - await page.goto(input.url, {waitUntil: "domcontentloaded", timeout: 30000}); - await waitForInitialSetup(page); - await selectKeyboard(page, input.keyboard); - await page.fill("#username", "qualification"); - await page.fill("#password", input.password); - await page.fill("#confirm", input.password); - await page.locator("#account-form details > summary").click(); - await page.selectOption("#locale", input.locale); - await page.selectOption("#timezone", input.timezone); - const sudo = page.locator("#passwordless-wheel"); - if (input.require_policy_controls) { - assert.equal(await sudo.count(), 1, "Sudo policy choice is missing"); - assert.equal(await sudo.isChecked(), false, "Sudo must require a password by default"); - } - if (await sudo.count()) assert.equal(await sudo.isChecked(), false); - await page.locator("#account-form button[type=submit]").click(); - await page.locator("#location").waitFor({state: "visible"}); - const rescan = page.locator("#rescan-disks"); - if (input.require_policy_controls) assert.equal(await rescan.count(), 1, "Disk rescan is missing"); - if (await rescan.count()) { - await rescan.click(); - await page.waitForFunction(() => !document.querySelector("#rescan-disks").disabled); - assert.equal(await page.inputValue("#disk"), ""); - } - const options = await page.locator("#disk option").evaluateAll(items => items.map(item => item.value)); - assert(options.includes(input.target_disk), "Expected target disk is absent from selector"); - assert(options.includes(input.unused_disk), "Unused guard disk is absent from selector"); - await page.selectOption("#disk", input.target_disk); - const details = page.locator("#disk-details"); - if (input.require_policy_controls) { - assert.match(await details.textContent(), /CYBEXOS-QUALIFY/); - assert.match(await details.textContent(), /Existing partitions:/); - } - await page.locator("#disk-form details > summary").click(); - const encrypted = page.locator("#encrypted"); - assert.equal(await encrypted.isChecked(), true); - if (!input.encrypted) await encrypted.uncheck(); - await page.locator("#disk-form button[type=submit]").click(); - await page.locator("#review").waitFor({state: "visible", timeout: 180000}); - const summary = await page.locator("#summary").textContent(); - const bootKeyboard = await page.locator("#summary dt").evaluateAll(items => - items.find(item => item.textContent.trim() === "Boot keyboard")?.nextElementSibling?.textContent.trim() || ""); - assert.match(summary, /qualification/); - if (input.require_policy_controls) { - assert.match(bootKeyboard, /^[a-z0-9_-]+$/, "Planned boot keyboard is missing"); - assert.match(summary, /CYBEXOS-QUALIFY/); - assert.match(summary, /ask for your account password/); - } - assert.match(summary, input.encrypted ? /Encrypted Btrfs/ : /unencrypted/); - assert.equal(await page.locator("#install").isDisabled(), true); - await page.check("#erase"); - await page.click("#install"); - await page.locator("#progress").waitFor({state: "visible"}); - await page.getByRole("heading", {name: "Your workspace is ready."}).waitFor({timeout: input.install_timeout_ms}); - assert.equal(await page.inputValue("#password"), ""); - assert.equal(await page.inputValue("#confirm"), ""); - process.stdout.write(JSON.stringify({check: "graphical-installer", selected_disk: input.target_disk, - encrypted: input.encrypted, keyboard: input.keyboard, - boot_keyboard: bootKeyboard || input.keyboard, locale: input.locale}) + "\n"); - } catch (error) { - let visibleText = "(unavailable)"; - if (page) { - try { - visibleText = await page.locator("body").innerText({timeout: 3000}); - } catch { /* Keep the original browser failure. */ } - } - const details = [`Visible installer text: ${visibleText.slice(0, 1800)}`]; - if (pageErrors.length) details.push(`Page errors: ${pageErrors.join(" | ")}`); - error.stack = `${String(error.stack || error)}\n${details.join("\n")}`; - throw error; - } finally { - await browser.close(); - } -} - -if (require.main === module) main().catch(error => { - const detail = String(error.stack || error); - const redacted = secret ? detail.replaceAll(secret, "[redacted]") : detail; - process.stderr.write(`Graphical installer qualification failed: ${redacted}\n`); - process.exitCode = 1; -}); - -module.exports = {waitForInitialSetup, selectKeyboard}; diff --git a/image/reconstruct-iso b/image/reconstruct-iso deleted file mode 100755 index 7a285ca0..00000000 --- a/image/reconstruct-iso +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Verify release parts and atomically reconstruct an ISO without replacing files.""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import re -import tempfile - - -def digest(value): - return isinstance(value, str) and re.fullmatch(r'[0-9a-f]{64}', value) - - -def reconstruct(manifest, output=None): - manifest = Path(manifest) - if manifest.stat().st_size > 1024 * 1024: - raise ValueError('ISO parts manifest exceeds the size limit') - record = json.loads(manifest.read_text()) - name = record.get('file', '') - if (record.get('format') != 1 or not isinstance(name, str) - or not re.fullmatch(r'[A-Za-z0-9._-]+\.iso', name) - or not digest(record.get('sha256')) - or not isinstance(record.get('bytes'), int) or record['bytes'] <= 0 - or not isinstance(record.get('parts'), list) or not record['parts']): - raise ValueError('Invalid ISO reconstruction manifest') - destination = Path(output) if output is not None else manifest.parent / name - if destination.exists() or destination.is_symlink(): - raise ValueError('ISO output already exists; existing files are preserved') - descriptor, temporary = tempfile.mkstemp(prefix='.cybexos-iso-', dir=destination.parent) - try: - total, complete = 0, hashlib.sha256() - with os.fdopen(descriptor, 'wb') as result: - for index, part in enumerate(record['parts']): - if (part.get('file') != f'{name}.part-{index:03d}' - or not digest(part.get('sha256')) - or not isinstance(part.get('bytes'), int) - or not 0 < part['bytes'] < 2 * 1024 ** 3): - raise ValueError('Invalid or reordered ISO part') - source = manifest.parent / part['file'] - if source.is_symlink() or not source.is_file(): - raise ValueError('ISO parts must be regular files') - size, current = 0, hashlib.sha256() - with source.open('rb') as stream: - while block := stream.read(8 * 1024 * 1024): - size += len(block) - if size > part['bytes']: - raise ValueError('ISO part exceeds its recorded size') - current.update(block) - complete.update(block) - result.write(block) - if size != part['bytes'] or current.hexdigest() != part['sha256']: - raise ValueError('ISO part checksum or size mismatch: ' + source.name) - total += size - if total != record['bytes'] or complete.hexdigest() != record['sha256']: - raise ValueError('Reconstructed ISO checksum or size mismatch') - result.flush() - os.fsync(result.fileno()) - os.fchmod(result.fileno(), 0o644) - # Hard-link publication refuses even a concurrent output creation. - os.link(temporary, destination) - finally: - Path(temporary).unlink(missing_ok=True) - return destination - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('manifest', type=Path) - parser.add_argument('--output', type=Path) - args = parser.parse_args() - try: - print(reconstruct(args.manifest, args.output)) - except (OSError, ValueError, KeyError, TypeError) as error: - parser.exit(1, f'reconstruct-iso: {error}\n') - - -if __name__ == '__main__': - main() diff --git a/image/release-gate b/image/release-gate deleted file mode 100755 index c283fcf5..00000000 --- a/image/release-gate +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Build, publish to local iVentoy, and qualify an ISO release on a trusted PXE runner.""" -import argparse -import json -import os -from pathlib import Path -import re -import shutil -import signal -import subprocess - -from build_support import atomic_json, digest -from browser_qualification import browser_dependencies -from offline_upgrade_inputs import snapshot_raw_dependencies -from vm_testing import require_test_iso - -ROOT = Path(__file__).resolve().parents[1] - - -def interrupted(_signum, _frame): - raise KeyboardInterrupt - - -def run_child(command): - """Let task-owned build/VM helpers clean up on interruption before removal.""" - with subprocess.Popen(command, start_new_session=True) as process: - try: - code = process.wait() - except BaseException: - if process.poll() is None: - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - process.wait(timeout=30) - except subprocess.TimeoutExpired: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - raise - if code: - raise subprocess.CalledProcessError(code, command) - - -def main(): - for signum in (signal.SIGTERM, signal.SIGHUP): - signal.signal(signum, interrupted) - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--baseline-iso', type=Path, required=True) - parser.add_argument('--tag', required=True) - parser.add_argument('--memory', type=int, default=24576) - parser.add_argument('--upgrade-dependency-rpms', type=Path, - help='Trusted raw third-party RPM directory, snapshotted before the build') - parser.add_argument('--upgrade-dependency-provenance', type=Path, - help='Matching filename-to-{id,url} JSON; requires --upgrade-dependency-rpms and host rpm') - parser.add_argument('--execute', action='store_true') - args = parser.parse_args() - if not args.execute: - parser.error('--execute explicitly authorizes a build and tests on new disposable VM disks') - if bool(args.upgrade_dependency_rpms) != bool(args.upgrade_dependency_provenance): - parser.error('--upgrade-dependency-rpms and --upgrade-dependency-provenance must be supplied together') - if not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', args.tag): - parser.error('Use a versioned release tag') - if (ROOT / 'VERSION').read_text().strip() != args.tag[1:]: - parser.error('The release tag must match VERSION in the reviewed checkout') - baseline = require_test_iso(args.baseline_iso) - # This runner must be the actual PXE host, not a workstation with a same-named directory. - if not Path('/data/pxe/README.md').is_file(): - parser.error('Run on the documented PXE host with /data/pxe/README.md') - subprocess.run(['systemctl', 'is-active', '--quiet', 'iventoy.service'], check=True) - browser_dependencies() - output = args.output.resolve() - if output.exists() or output.is_relative_to(Path('/data/pxe/iso')): - parser.error('Output must be a new directory outside the served ISO tree') - if args.upgrade_dependency_rpms and output.is_relative_to(args.upgrade_dependency_rpms.resolve()): - parser.error('Output must be outside the raw dependency input directory') - output.mkdir(parents=True, mode=0o700) - report = {'status': 'running', 'tag': args.tag, 'baseline_iso': str(baseline)} - build = output / 'build' - dependency_staging = output / 'upgrade-dependencies' - try: - fixture = None - if args.upgrade_dependency_rpms: - fixture = snapshot_raw_dependencies(args.upgrade_dependency_rpms, - args.upgrade_dependency_provenance, dependency_staging) - report['upgrade_dependency_fixture'] = fixture.evidence() - run_child([str(ROOT / 'image/build'), '--output', str(build), '--memory', str(args.memory), - '--update-channel', str(ROOT / 'image/channels/stable.json')]) - artifacts = build / 'artifacts' - isos = list(artifacts.glob('*.iso')) - rpms = list(artifacts.glob('cybexos-desktop-*.rpm')) - offline = list(artifacts.glob('cybexos-offline-apps-*.rpm')) - sboms = list(artifacts.glob('*.spdx.json')) - # Publication needs the SBOM; fail before hours of VM qualification. - if len(isos) != 1 or len(rpms) != 1 or len(offline) != 1 or len(sboms) != 1: - raise RuntimeError('Build must deliver exactly one ISO, desktop RPM, offline applications RPM and SPDX SBOM') - if digest(baseline) == digest(isos[0]): - raise RuntimeError('Upgrade baseline must be a different prior ISO, not the candidate image') - dependencies = None - if fixture: - # This build creates a new EVR and new bytes. Bind only now, before - # publication or any VM qualification, to these exact artifacts. - dependencies = fixture.bind(baseline, rpms[0], offline[0]) - report['candidate_dependencies'] = dependencies.evidence() - atomic_json(output / 'upgrade-dependency-manifest.json', dependencies.manifest) - run_child([str(ROOT / 'image/publish-pxe'), str(artifacts), '--execute']) - iso = require_test_iso(Path('/data/pxe/iso') / isos[0].name) - report['testing_iso'] = str(iso) - report['testing_iso_bytes'] = iso.stat().st_size - for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): - command = [str(ROOT / 'image/qualify'), str(iso), '--output', str(output / scenario), - '--execute-vm', '--erase-disposable-disk', '--scenario', scenario] - if scenario.startswith('plain-'): - command.append('--capture-outcomes') - run_child(command) - report['installation_parity'] = [] - for scenario in ('plain-us', 'plain-nl'): - directory = output / ('checkout-' + scenario) - run_child([str(ROOT / 'image/qualify-checkout'), '--artifacts', str(artifacts), - '--iso-results', str(output / scenario), '--scenario', scenario, - '--output', str(directory), '--execute-vm']) - checkout = json.loads((directory / 'checkout-qualification.json').read_text()) - fresh = json.loads((output / scenario / 'qualification.json').read_text()) - if (checkout.get('status') != 'passed' or checkout.get('scenario') != scenario - or not checkout.get('source_content_sha256') - or checkout['source_content_sha256'] != fresh.get('source_content_sha256') - or checkout.get('source_revision') != fresh.get('source_revision')): - raise RuntimeError('Checkout parity did not qualify the exact ISO source') - for profile in ('fresh', 'saved'): - parity = json.loads((directory / ('parity-' + profile + '.json')).read_text()) - if (parity.get('status') != 'passed' or parity.get('source_content_sha256') != checkout['source_content_sha256'] - or parity.get('scenario') != scenario or parity.get('profile') != profile): - raise RuntimeError('Missing or mismatched real installed parity result') - fresh['checkout_parity'] = checkout - fresh['checks'] += ['installed-parity-fresh', 'installed-parity-saved'] - atomic_json(output / scenario / 'qualification.json', fresh) - report['installation_parity'].append({'scenario': scenario, 'source_content_sha256': checkout['source_content_sha256'], - 'checkout_report_sha256': digest(directory / 'checkout-qualification.json')}) - command = [str(ROOT / 'image/qualify'), str(baseline), '--output', str(output / 'upgrade'), - '--execute-vm', '--erase-disposable-disk', '--scenario', 'encrypted-us', - '--candidate-rpm', str(rpms[0]), '--candidate-offline-rpm', str(offline[0]), - '--recovery-check', '--legacy-installer'] - if dependencies: - command += ['--candidate-dependencies', str(dependencies.directory)] - run_child(command) - if dependencies: - evidence = output / 'upgrade/qualification.json' - qualified = json.loads(evidence.read_text()) - actual = qualified.get('candidate_dependencies', {}) - required = {'installed-rpm-upgrade', 'recovery-boot-restore', 'offline-dependency-signatures-and-single-transaction'} - if (qualified.get('status') != 'passed' or actual.get('guest_signatures_verified') is not True - or any(actual.get(key) != value for key, value in dependencies.evidence().items()) - or not required.issubset(qualified.get('checks', [])) - or any(qualified.get(key) != dependencies.manifest[binding] for key, binding in ( - ('iso_sha256', 'baseline_iso_sha256'), ('candidate_rpm_sha256', 'candidate_rpm_sha256'), - ('candidate_offline_rpm_sha256', 'candidate_offline_rpm_sha256')))): - raise RuntimeError('Upgrade did not qualify the exact dependency fixture, artifacts and recovery') - report['upgrade_qualification_sha256'] = digest(evidence) - report['status'] = 'passed' - report['artifacts'] = str(artifacts) - except BaseException as error: - report.update(status='failed', error=str(error) or type(error).__name__) - # QEMU helpers always stop their guests. Preserve only compact evidence - # and a completed published testing ISO needed to diagnose a failed boot. - if build.exists(): - shutil.rmtree(build) - raise - finally: - try: - if args.upgrade_dependency_rpms and dependency_staging.exists(): - shutil.rmtree(dependency_staging) - except BaseException as error: - report.update(status='failed', cleanup_error=f'{dependency_staging}: {error}') - raise - finally: - atomic_json(output / 'release-gate.json', report) - print(json.dumps(report)) - - -if __name__ == '__main__': - main() diff --git a/image/release-repository b/image/release-repository deleted file mode 100755 index cc387049..00000000 --- a/image/release-repository +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 -"""Prepare a signed CybexOS desktop RPM repository without uploading it.""" -from release_repository import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/image/release-runner b/image/release-runner deleted file mode 100755 index 6893c0e2..00000000 --- a/image/release-runner +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and explicitly start one ephemeral runner for a reviewed release job.""" -from release_runner import main - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/release_metadata.py b/image/release_metadata.py deleted file mode 100644 index 44648f59..00000000 --- a/image/release_metadata.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Public desktop release metadata shared by the builder and RPM packager.""" - -from datetime import datetime, timezone -import json -from pathlib import Path -import re -import subprocess -import tempfile -from urllib.parse import urlsplit - - -def fingerprint(value): - value = str(value).replace(" ", "").upper() - if not re.fullmatch(r"[0-9A-F]{40}|[0-9A-F]{64}", value): - raise ValueError("Use the complete OpenPGP signing-key fingerprint") - return value - - -def repository_url(value): - if not isinstance(value, str) or any(char.isspace() for char in value) or "\\" in value: - raise ValueError("The desktop repository needs one HTTPS base URL") - parsed = urlsplit(value) - if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password - or parsed.query or parsed.fragment): - raise ValueError("The desktop repository must use HTTPS without credentials, query, or fragment") - remainder = value.replace("$releasever", "").replace("$basearch", "") - if "$" in remainder or any(ord(char) < 32 for char in value): - raise ValueError("Only $releasever and $basearch repository variables are supported") - return value.rstrip("/") - - -def public_key(path, expected): - """Verify one public key in a disposable GnuPG home and return ASCII armor.""" - expected = fingerprint(expected) - path = Path(path).resolve(strict=True) - data = path.read_bytes() - if len(data) > 1024 * 1024 or b"PRIVATE KEY" in data: - raise ValueError("The update channel accepts a public key, never private signing material") - with tempfile.TemporaryDirectory(prefix="cybexos-public-key-") as directory: - command = ["gpg", "--no-options", "--homedir", directory, "--batch", "--no-autostart"] - result = subprocess.run( - [*command, "--with-colons", "--show-keys", str(path)], - check=True, capture_output=True, text=True, - ) - primaries = [] - waiting = False - for line in result.stdout.splitlines(): - fields = line.split(":") - if fields[0] in ("sec", "ssb"): - raise ValueError("Private keys cannot be embedded in an image") - if fields[0] == "pub": - if fields[1] in ("r", "e", "d"): - raise ValueError("The update key is revoked, expired, or disabled") - waiting = True - elif fields[0] == "fpr" and waiting: - primaries.append(fields[9].upper()) - waiting = False - if primaries != [expected]: - raise ValueError("The update public key does not match the single expected fingerprint") - subprocess.run([*command, "--import", str(path)], check=True, capture_output=True) - return subprocess.run( - [*command, "--armor", "--export", expected], check=True, capture_output=True, - ).stdout - - -def stage_update_channel(config_path, destination): - """Return explicit archive additions containing only validated public data.""" - config_path = Path(config_path).resolve(strict=True) - if config_path.stat().st_size > 65536: - raise ValueError("Update channel configuration is too large") - config = json.loads(config_path.read_text()) - if not isinstance(config, dict) or set(config) != {"baseurl", "fingerprint", "key_file"}: - raise ValueError("Update channel requires exactly baseurl, fingerprint, and key_file") - normalized = {"baseurl": repository_url(config["baseurl"]), - "fingerprint": fingerprint(config["fingerprint"])} - key_path = Path(config["key_file"]).expanduser() - if not key_path.is_absolute(): - key_path = config_path.parent / key_path - armor = public_key(key_path, normalized["fingerprint"]) - destination = Path(destination) - destination.mkdir(parents=True, exist_ok=True) - settings = destination / "update-channel.json" - key = destination / "update-key.asc" - settings.write_text(json.dumps(normalized, sort_keys=True, indent=2) + "\n") - key.write_bytes(armor) - settings.chmod(0o644) - key.chmod(0o644) - return {"image/update-channel.json": settings, "image/update-key.asc": key} - - -def rpm_identity(version_file, provenance): - version = Path(version_file).read_text().strip() - if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?", version): - raise ValueError("VERSION must contain major.minor.patch with an optional prerelease") - version = version.replace("-", "~", 1) - revision = provenance.get("source_revision", "") - if not re.fullmatch(r"[0-9a-f]{40,64}", revision): - raise ValueError("Build provenance needs the full source revision") - timestamp = datetime.fromisoformat(provenance["utc"].replace("Z", "+00:00")) - if timestamp.tzinfo is None: - raise ValueError("Build time must include its timezone") - release = "1." + timestamp.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") - release += ".g" + revision[:12] - if provenance.get("source_dirty"): - release += ".dirty" - return version, release - - -WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") -MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") - - -def changelog_entry(version, release, provenance): - """This build's %changelog entry. An ISO installation's Updates view shows - the entries newer than the installed desktop (`dnf changelog --upgrades`), - so each build names its release and where that release's notes are.""" - built = datetime.fromisoformat(provenance["utc"].replace("Z", "+00:00")).astimezone(timezone.utc) - stamp = f"{WEEKDAYS[built.weekday()]} {MONTHS[built.month - 1]} {built.day:02d} {built.year}" - release_name = version.replace("~", "-", 1) - lines = [f"* {stamp} CybexOS - {version}-{release}", - f"- CybexOS desktop {release_name} ({provenance['source_revision'][:12]})"] - if not release_name.endswith("-dev"): - lines.append(f"- Release notes: https://github.com/DigitalPals/CybexOS/releases/tag/v{release_name}") - return "\n".join(lines) + "\n\n" - - -def render_spec(source_spec, version_file, provenance): - version, release = rpm_identity(version_file, provenance) - # Subpackages have independent identities. Only rewrite the main header. - header, separator, body = source_spec.partition("%description\n") - result, count = re.subn(r"(?m)^Version:.*$", f"Version: {version}", header) - if count != 1: - raise ValueError("Desktop spec must contain exactly one Version field") - result, count = re.subn(r"(?m)^Release:.*$", f"Release: {release}%{{?dist}}", result) - if count != 1: - raise ValueError("Desktop spec must contain exactly one Release field") - result += separator + body - entry = changelog_entry(version, release, provenance) - return re.sub(r"(?m)^%changelog\n", lambda match: match.group(0) + entry, result, count=1) - - -def channel_payload(baseurl, expected, armor): - """Canonical enabled-channel bytes, also checked before release signing.""" - url, expected = repository_url(baseurl), fingerprint(expected) - lines = ["# Managed CybexOS desktop release channel.", "[cybexos-desktop]", - "name=CybexOS desktop updates", "gpgcheck=1", "repo_gpgcheck=1", - "skip_if_unavailable=0", "metadata_expire=6h", "enabled=1", - f"baseurl={url}", "gpgkey=file:///usr/share/cybexos/update-key.asc"] - status = {"enabled": True, "baseurl": url, "fingerprint": expected} - return {"etc/yum.repos.d/cybexos-desktop.repo": ("\n".join(lines) + "\n").encode(), - "usr/share/cybexos/update-channel.json": (json.dumps(status, indent=2) + "\n").encode(), - "usr/share/cybexos/update-key.asc": armor} - - -def install_update_channel(source_image_dir, payload_dir): - """Package a disabled channel by default, or the explicitly pinned channel.""" - source_image_dir, payload_dir = Path(source_image_dir), Path(payload_dir) - source = source_image_dir / "update-channel.json" - lines = ["# Managed CybexOS desktop release channel.", "[cybexos-desktop]", - "name=CybexOS desktop updates", "gpgcheck=1", "repo_gpgcheck=1", - "skip_if_unavailable=0", "metadata_expire=6h"] - vendor = payload_dir / "usr/share/cybexos" - vendor.mkdir(parents=True, exist_ok=True) - if source.exists(): - config = json.loads(source.read_text()) - if set(config) != {"baseurl", "fingerprint"}: - raise ValueError("Invalid normalized desktop update channel") - url = repository_url(config["baseurl"]) - expected = fingerprint(config["fingerprint"]) - key = public_key(source_image_dir / "update-key.asc", expected) - for relative, content in channel_payload(url, expected, key).items(): - path = payload_dir / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(content) - path.chmod(0o644) - else: - lines += ["# Configure --update-channel when building a release.", "enabled=0"] - status = {"enabled": False} - repo = payload_dir / "etc/yum.repos.d/cybexos-desktop.repo" - repo.parent.mkdir(parents=True, exist_ok=True) - repo.write_text("\n".join(lines) + "\n") - repo.chmod(0o644) - (vendor / "update-channel.json").write_text(json.dumps(status, indent=2) + "\n") - provenance = source_image_dir / "build-provenance.json" - if not provenance.is_file(): - raise ValueError("Build provenance is missing; build through image/build") - build = json.loads(provenance.read_text()) - (vendor / "build.json").write_text(json.dumps(build, indent=2, sort_keys=True) + "\n") - return build diff --git a/image/release_repository.py b/image/release_repository.py deleted file mode 100644 index edcee2c9..00000000 --- a/image/release_repository.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Create a signed desktop RPM repository in a new local directory. - -Hosting is a separate release action. Original RPMs and the user's RPM keyring -are never changed. Signing keys remain in the operator's GnuPG keyring. -""" - -import argparse -import ctypes -from datetime import datetime, timezone -import hashlib -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import urllib.error -import urllib.request - -from release_metadata import channel_payload, fingerprint, public_key, repository_url - - -def run(command, **kwargs): - return subprocess.run(command, check=True, **kwargs) - - -def sha256(path): - with Path(path).open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def rename_new_directory(source, destination): - """Atomically publish on Linux without replacing even an empty directory.""" - libc = ctypes.CDLL(None, use_errno=True) - renameat2 = libc.renameat2 - renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] - renameat2.restype = ctypes.c_int - if renameat2(-100, os.fsencode(source), -100, os.fsencode(destination), 1): - error = ctypes.get_errno() - raise OSError(error, os.strerror(error), str(destination)) - - -def package_identity(path): - result = run(["rpm", "-qp", "--qf", "%{NAME}\t%{EPOCHNUM}\t%{VERSION}\t%{RELEASE}\t%{ARCH}", - str(path)], capture_output=True, text=True) - fields = result.stdout.split("\t") - if len(fields) != 5 or fields[0] not in ("cybexos-desktop", "cybexos-offline-apps") or fields[4] != "x86_64": - raise ValueError(f"Expected an x86_64 CybexOS desktop or offline-apps RPM: {path.name}") - if any(not re.fullmatch(r"[A-Za-z0-9.~^+_-]+", field) for field in fields): - raise ValueError("RPM identity contains invalid characters") - return dict(zip(("name", "epoch", "version", "release", "arch"), fields)) - - -def package_payload_identity(path): - """Ignore build timestamps/signatures, but bind file contents and ownership.""" - result = run(['rpm', '-qp', '--qf', - '%{FILEDIGESTALGO}\n[%{FILENAMES}\t%{FILEDIGESTS}\t%{FILELINKTOS}\t%{FILEMODES}\t%{FILEUSERNAME}\t%{FILEGROUPNAME}\t%{FILEFLAGS}\t%{FILECAPS}\n]', - str(path)], capture_output=True, text=True) - return hashlib.sha256('\n'.join(sorted(result.stdout.splitlines())).encode()).hexdigest() - - -def previous_packages(location, key, expected, baseurl, work): - """Authenticate the previous published manifest before checking immutability.""" - if location is None: - return [] - directory = Path(work) / 'previous-release' - directory.mkdir() - for name in ('release.json', 'release.json.asc'): - if str(location).startswith('https://'): - try: - with urllib.request.urlopen(repository_url(str(location)) + '/' + name, timeout=30) as response: - data = response.read(2 * 1024 * 1024 + 1) - except urllib.error.HTTPError as error: - # No repository exists before the first public release. - if error.code == 404 and name == 'release.json': - return [] - raise - else: - data = (Path(location) / name).read_bytes() - if len(data) > 2 * 1024 * 1024: - raise ValueError('Previous release manifest exceeds the size limit') - (directory / name).write_bytes(data) - home = directory / 'keyring' - home.mkdir(mode=0o700) - command = ['gpg', '--no-options', '--homedir', str(home), '--batch', '--no-autostart'] - run([*command, '--import', str(key)], capture_output=True) - run([*command, '--verify', str(directory / 'release.json.asc'), str(directory / 'release.json')], - capture_output=True) - manifest = json.loads((directory / 'release.json').read_text()) - if manifest.get('fingerprint') != expected or manifest.get('baseurl') != baseurl or manifest.get('format') != 1: - raise ValueError('Previous release does not match the reviewed channel') - return manifest['packages'] - - -def verify_offline_identity(identity, payload_sha256, previous): - if identity['name'] != 'cybexos-offline-apps': - return - for item in previous: - if all(item.get(key) == value for key, value in identity.items()): - if item.get('payload_sha256') != payload_sha256: - raise ValueError('Offline application payload changed under an existing NEVRA; advance its independent version') - - -def verify_signed_rpm(path, key, temporary): - database = Path(temporary) / "rpmdb" - database.mkdir(exist_ok=True) - command = ["rpmkeys", "--dbpath", str(database), "--define", "_keyring rpmdb", - "--define", "_pkgverify_level all", "--define", "_pkgverify_flags 0x0"] - run([*command, "--import", str(key)], capture_output=True) - result = run([*command, "--checksig", "--verbose", str(path)], capture_output=True, text=True, - env={**os.environ, "LC_ALL": "C"}) - # Do not mistake digest-only success for a verified release signature. - if not re.search(r"(?im)^\s*.*\b(?:signature|openpgp)\b.*:\s*OK\s*$", result.stdout): - raise ValueError(f"No verified OpenPGP signature on {path.name}") - - -def verify_embedded_channel(path, baseurl, key_id, armor): - # RPM verifies payload/header integrity during signing and the subsequent - # independent signature check. Inspect its file digests without extracting - # or installing a multi-gigabyte application payload. - result = run(["rpm", "-qp", "--qf", "%{FILEDIGESTALGO}\n[%{FILENAMES}\t%{FILEDIGESTS}\n]", - str(path)], capture_output=True, text=True) - lines = result.stdout.splitlines() - if not lines or lines[0] != "8": - raise ValueError("Release RPMs must use SHA-256 file digests") - digests = dict(line.split("\t", 1) for line in lines[1:] if "\t" in line) - for relative, content in channel_payload(baseurl, key_id, armor).items(): - if digests.get("/" + relative) != hashlib.sha256(content).hexdigest(): - raise ValueError("RPM update channel does not match this release. Build it with the same --update-channel first.") - - -def create_repository(packages, output, key_file, key_id, baseurl, gnupghome=None, - packages_baseurl=None, previous_repository=None): - output = Path(output).absolute() - if output.exists() or output.is_symlink(): - raise ValueError("Repository output must be a new directory; existing releases are preserved") - expected = fingerprint(key_id) - url = repository_url(baseurl) - if packages_baseurl: - packages_baseurl = repository_url(packages_baseurl) - if '$' in packages_baseurl: - raise ValueError('RPM download URLs must identify an immutable release') - armor = public_key(key_file, expected) - packages = [Path(path).resolve(strict=True) for path in packages] - if not packages or len({path.name for path in packages}) != len(packages): - raise ValueError("Supply RPMs with unique filenames") - for path in packages: - if path.suffix != ".rpm" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm", path.name): - raise ValueError("RPM filenames must be ASCII without spaces") - for executable in ("rpm", "rpmkeys", "rpmsign", "createrepo_c", "gpg"): - if not shutil.which(executable): - raise ValueError(f"Missing {executable}; install rpm-sign, createrepo_c, and gnupg2 on Fedora") - identities = [package_identity(path) for path in packages] - names = [item['name'] for item in identities] - if names.count('cybexos-desktop') != 1 or len(set(names)) != len(names): - raise ValueError('Supply exactly one desktop RPM and at most one offline applications RPM') - if len({json.dumps(item, sort_keys=True) for item in identities}) != len(identities): - raise ValueError("Duplicate RPM identities cannot be published in one release") - output.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix=".cybexos-repository-", dir=output.parent) as directory: - work = Path(directory) - stage = work / "repository" - stage.mkdir() - target_packages = stage / "Packages" - target_packages.mkdir() - key = stage / "CYBEXOS-desktop.asc" - key.write_bytes(armor) - previous = previous_packages(previous_repository, key, expected, url, work) - command = ["rpmsign", "--addsign", "--key-id", expected, - "--define", "_openpgp_sign gpg"] - signing = ["gpg", "--batch", "--local-user", expected] - signing_environment = dict(os.environ) - if gnupghome: - home = Path(gnupghome).resolve(strict=True) - signing_environment["GNUPGHOME"] = str(home) - command += ["--undefine", "_gpg_path"] - signing += ["--homedir", str(home)] - records = [] - for original, identity in zip(packages, identities): - payload_sha256 = package_payload_identity(original) - verify_offline_identity(identity, payload_sha256, previous) - destination = target_packages / original.name - shutil.copyfile(original, destination) - if identity["name"] == "cybexos-desktop": - verify_embedded_channel(destination, url, expected, armor) - run([*command, str(destination)], env=signing_environment) - verify_signed_rpm(destination, key, work) - record = {**identity, "file": str(destination.relative_to(stage)), - "sha256": sha256(destination), "unsigned_input_sha256": sha256(original), - "payload_sha256": payload_sha256} - if packages_baseurl: - record['url'] = packages_baseurl + '/' + destination.name - records.append(record) - if packages_baseurl: - # GitHub Pages serves only metadata; large RPMs are release assets. - # Scan Packages itself so relative locations are bare asset names. - run(["createrepo_c", "--checksum", "sha256", "--general-compress-type", "gz", "--outputdir", str(stage), - "--baseurl", packages_baseurl + '/', str(target_packages)]) - else: - run(["createrepo_c", "--checksum", "sha256", "--general-compress-type", "gz", str(stage)]) - metadata = stage / "repodata/repomd.xml" - run([*signing, "--armor", "--detach-sign", "--output", str(metadata) + ".asc", str(metadata)]) - manifest = stage / "release.json" - manifest.write_text(json.dumps({ - "format": 1, "created": datetime.now(timezone.utc).isoformat(), - "fingerprint": expected, "baseurl": url, "packages": records, - }, indent=2, sort_keys=True) + "\n") - run([*signing, "--armor", "--detach-sign", "--output", str(manifest) + ".asc", str(manifest)]) - # Verify detached signatures in an independent keyring containing only - # the reviewed public key; signing success alone is insufficient. - verifier = work / "verify-gpg" - verifier.mkdir(mode=0o700) - verify = ["gpg", "--no-options", "--homedir", str(verifier), "--batch", "--no-autostart"] - run([*verify, "--import", str(key)], capture_output=True) - for signed in (metadata, manifest): - run([*verify, "--verify", str(signed) + ".asc", str(signed)], capture_output=True) - (stage / "update-channel.json").write_text(json.dumps({ - "baseurl": url, "fingerprint": expected, "key_file": key.name, - }, indent=2) + "\n") - files = sorted(path for path in stage.rglob("*") if path.is_file()) - (stage / "SHA256SUMS").write_text("".join( - f"{sha256(path)} {path.relative_to(stage)}\n" for path in files - )) - for path in [stage, *stage.rglob("*")]: - path.chmod(0o755 if path.is_dir() else 0o644) - # Renaming a completed sibling directory never exposes partial RPMs or - # metadata and refuses replacing nonempty previous release directories. - if output.exists() or output.is_symlink(): - raise ValueError("Repository output appeared while signing; refusing to replace it") - rename_new_directory(stage, output) - return output - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("packages", type=Path, nargs="+") - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--public-key", type=Path, required=True) - parser.add_argument("--key", required=True, help="Complete fingerprint of an existing signing key") - parser.add_argument("--baseurl", required=True, help="HTTPS URL where this repository will be hosted") - parser.add_argument("--gnupghome", type=Path) - parser.add_argument("--packages-baseurl", help="HTTPS release-asset directory; keep large RPMs off the metadata host") - parser.add_argument("--previous-repository", help="Previous signed repository directory or HTTPS URL; check offline RPM immutability") - args = parser.parse_args(argv) - try: - destination = create_repository(args.packages, args.output, args.public_key, args.key, - args.baseurl, args.gnupghome, args.packages_baseurl, args.previous_repository) - except (ValueError, OSError, subprocess.CalledProcessError) as error: - parser.exit(1, f"release-repository: {error}\n") - print(f"Signed repository ready for explicit hosting: {destination}") - print(f"Use its update-channel.json with image/build --update-channel {destination / 'update-channel.json'}") - return 0 diff --git a/image/release_runner.py b/image/release_runner.py deleted file mode 100644 index dbfe154d..00000000 --- a/image/release_runner.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Start one verified, ephemeral PXE runner for one already queued release job. - -The operator's gh credentials remain outside the runner environment. This is -not a sandbox: only reviewed workflow code may execute as the PXE operator. -""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import platform -import re -import signal -import shutil -import subprocess -import tarfile -import tempfile -import sys -from urllib.parse import urlsplit -from urllib.request import urlopen -import uuid - -REPOSITORY = 'DigitalPals/CybexOS' -ROOT = Path(__file__).resolve().parents[1] -WORKFLOWS = {'.github/workflows/release.yml', '.github/workflows/desktop-release.yml'} -ARCHIVE_LIMIT = 1024 ** 3 - - -def api(endpoint, *, method='GET', body=None, paginate=False): - command = ['gh', 'api', '--hostname', 'github.com', '--method', method, '-H', 'Accept: application/vnd.github+json', - '-H', 'X-GitHub-Api-Version: 2022-11-28', endpoint] - if paginate: - command += ['--paginate', '--slurp'] - if body is not None: - command += ['--input', '-'] - result = subprocess.run(command, input=json.dumps(body) if body is not None else None, - capture_output=True, text=True, timeout=60, check=False) - if result.returncode: - if method == 'DELETE' and '(HTTP 404)' in result.stderr: - return None # Ephemeral registration already removed itself. - # API payloads can contain registration credentials; never echo them. - raise RuntimeError(f'GitHub API {method} {endpoint} failed; check gh authentication and repository administration access') - return json.loads(result.stdout) if result.stdout.strip() else None - - -def pages(endpoint, field): - return [item for page in api(endpoint, paginate=True) for item in page[field]] - - -def validate_run(run, head): - if (run.get('repository', {}).get('full_name') != REPOSITORY - or run.get('head_repository', {}).get('full_name') != REPOSITORY - or run.get('event') not in ('push', 'workflow_dispatch') - or run.get('path') not in WORKFLOWS - or run.get('head_sha') != head - or run.get('pull_requests') - or run.get('status') not in ('queued', 'in_progress', 'waiting', 'requested')): - raise ValueError('Run must be an unfinished, non-PR release workflow from this exact reviewed checkout') - branch = run.get('head_branch', '') - if branch != 'main' and not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', branch): - raise ValueError('Release runners accept only main or a versioned release tag') - return run - - -def validate_job(jobs, run_id, head): - label = f'cybexos-iso-{run_id}' - matching = [job for job in jobs if label in job.get('labels', []) - and job.get('status') in ('queued', 'in_progress', 'waiting', 'pending')] - if len(matching) != 1: - raise ValueError('Exactly one queued qualification job with this run-specific label is required') - job = matching[0] - if (job.get('status') != 'queued' or job.get('run_id') != run_id - or job.get('head_sha') != head or job.get('labels') != [label]): - raise ValueError('The qualification job must be queued and request only its exact run-specific label') - return job - - -def checkout_head(checkout=ROOT): - def git(*args): - return subprocess.run(['git', '-C', str(checkout), *args], check=True, - capture_output=True, text=True, timeout=15).stdout.strip() - if git('status', '--porcelain', '--untracked-files=all'): - raise ValueError('Use a clean, reviewed checkout matching the queued workflow head') - remote = git('remote', 'get-url', 'origin') - if remote not in (f'https://github.com/{REPOSITORY}', f'https://github.com/{REPOSITORY}.git', - f'git@github.com:{REPOSITORY}.git', f'ssh://git@github.com/{REPOSITORY}.git'): - raise ValueError('Checkout origin must be the official CybexOS repository') - head = git('rev-parse', 'HEAD') - if not re.fullmatch(r'[0-9a-f]{40,64}', head): - raise ValueError('Checkout commit identity is invalid') - return head - - -def reviewed_job(run_id, head, own_runner=None): - run = validate_run(api(f'repos/{REPOSITORY}/actions/runs/{run_id}'), head) - job = validate_job(pages(f'repos/{REPOSITORY}/actions/runs/{run_id}/jobs?filter=latest&per_page=100', 'jobs'), run_id, head) - label = f'cybexos-iso-{run_id}' - # A unique-only label avoids the generic self-hosted pool. Refuse a second - # runner for this run, or another pending job explicitly targeting it. - runners = pages(f'repos/{REPOSITORY}/actions/runners?per_page=100', 'runners') - if any(runner.get('id') != own_runner and label in [item.get('name') for item in runner.get('labels', [])] - for runner in runners): - raise ValueError('A runner already exists for this workflow run') - for state in ('queued', 'in_progress', 'waiting', 'pending', 'requested'): - for other in pages(f'repos/{REPOSITORY}/actions/runs?status={state}&per_page=100', 'workflow_runs'): - if other['id'] == run_id: - continue - others = pages(f'repos/{REPOSITORY}/actions/runs/{other["id"]}/jobs?filter=latest&per_page=100', 'jobs') - if any(label in item.get('labels', []) and item.get('status') != 'completed' for item in others): - raise ValueError('Another unfinished workflow requests the same release-runner label') - return run, job - - -def runner_package(releases): - found = [item for item in releases if item.get('os') == 'linux' and item.get('architecture') == 'x64'] - if len(found) != 1: - raise ValueError('GitHub must provide exactly one current Linux x64 runner package') - package = found[0] - checksum = package.get('sha256_checksum', '') - url = urlsplit(package.get('download_url', '')) - if (not re.fullmatch(r'[0-9a-fA-F]{64}', checksum) or url.scheme != 'https' - or url.netloc != 'github.com' or url.query or url.fragment - or not re.fullmatch(r'/actions/runner/releases/download/v[0-9.]+/actions-runner-linux-x64-[0-9.]+\.tar\.gz', url.path)): - raise ValueError('Runner download must have the GitHub API SHA-256 pin and official release URL') - return package['download_url'], checksum.lower() - - -def download(url, checksum, destination): - digest, size = hashlib.sha256(), 0 - with urlopen(url, timeout=60) as response, destination.open('xb') as stream: - if urlsplit(response.url).scheme != 'https': - raise ValueError('Runner download redirected to an insecure URL') - while block := response.read(8 * 1024 * 1024): - size += len(block) - if size > ARCHIVE_LIMIT: - raise ValueError('Runner archive exceeds the size limit') - digest.update(block) - stream.write(block) - if digest.hexdigest() != checksum: - raise ValueError('Runner download SHA-256 does not match the GitHub API pin') - - -def extract(archive, destination): - # Python 3.12+ data filtering rejects escaping paths, device files and - # escaping symlinks. Only checksum-verified official runner bytes reach it. - with tarfile.open(archive, 'r:gz') as bundle: - bundle.extractall(destination, filter='data') - for name in ('config.sh', 'run.sh'): - path = destination / name - if path.is_symlink() or not path.is_file() or not os.access(path, os.X_OK): - raise ValueError('Official runner archive is missing its executable entry points') - - -def child_environment(work): - home, temporary = work / 'home', work / 'tmp' - home.mkdir(mode=0o700) - temporary.mkdir(mode=0o700) - return {'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), - 'HOME': str(home), 'TMPDIR': str(temporary), 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8', - 'PYTHONDONTWRITEBYTECODE': '1'} - - -def stop(process): - if process.poll() is not None: - return - for signum, deadline in ((signal.SIGINT, 30), (signal.SIGTERM, 15), (signal.SIGKILL, 10)): - try: - os.killpg(process.pid, signum) - except ProcessLookupError: - break - try: - process.wait(timeout=deadline) - return - except subprocess.TimeoutExpired: - continue - process.wait(timeout=10) - - -def run_child(command, work, environment, timeout, *, capture=False): - with subprocess.Popen(command, cwd=work, env=environment, start_new_session=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE if capture else None, - stderr=subprocess.PIPE if capture else None) as process: - try: - process.communicate(timeout=timeout) - except BaseException: - stop(process) - raise - if process.returncode: - raise RuntimeError('Ephemeral runner command failed; local credentials and logs will be removed') - - -class ActiveRunnerError(RuntimeError): - """A transient unit could not be proven stopped; retain its task files.""" - - -def manager_environment(): - runtime = f'/run/user/{os.getuid()}' - return {'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), - 'XDG_RUNTIME_DIR': runtime, 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=' + runtime + '/bus', - 'LANG': 'C.UTF-8'} - - -def check_user_manager(): - result = subprocess.run(['systemctl', '--user', 'show', '--property=Version', '--value'], - env=manager_environment(), capture_output=True, timeout=15, check=False) - if result.returncode: - raise ValueError('A reachable operator systemd user manager is required for bounded runner/VM cleanup') - - -def run_scoped_runner(work, environment, timeout, name): - """A transient cgroup contains build VMs even when helpers call setsid().""" - unit = name + '.service' - manager = manager_environment() - command = ['systemd-run', '--user', '--quiet', '--wait', '--pipe', '--collect', - '--service-type=exec', '--unit=' + unit, '--working-directory=' + str(work), - '--property=RuntimeMaxSec=' + str(timeout) + 's', '--property=KillSignal=SIGINT', - '--property=TimeoutStopSec=120s', '--property=KillMode=mixed', - '--property=SendSIGKILL=yes', '/usr/bin/env', '-i', - *(key + '=' + value for key, value in sorted(environment.items())), str(work / 'run.sh')] - try: - # Give systemd time to complete graceful cancellation at RuntimeMaxSec. - run_child(command, work, manager, timeout + 135) - finally: - try: - subprocess.run(['systemctl', '--user', 'stop', unit], env=manager, - capture_output=True, timeout=135, check=False) - result = subprocess.run(['systemctl', '--user', 'show', unit, '--property=ActiveState', '--value'], - env=manager, capture_output=True, text=True, timeout=15, check=False) - if not ((result.returncode == 0 and result.stdout.strip() in ('inactive', 'failed')) - or (result.returncode == 4 and not result.stdout.strip())): - raise ActiveRunnerError(f'Could not confirm {unit} stopped; inspect that exact user unit before removing {work}') - except (OSError, subprocess.SubprocessError) as error: - raise ActiveRunnerError(f'Could not stop {unit}; inspect that exact user unit before removing {work}') from error - - -def owned_runner(name): - found = [runner for runner in pages(f'repos/{REPOSITORY}/actions/runners?per_page=100', 'runners') - if runner.get('name') == name] - if len(found) > 1: - raise RuntimeError('Ambiguous ephemeral runner registration; refuse to delete another runner') - return found[0]['id'] if found else None - - -def cleanup_registration(name): - # Ephemeral runners normally deregister themselves. Exact unique names also - # recover a partially completed config.sh registration without deleting peers. - identifier = owned_runner(name) - if identifier is not None: - api(f'repos/{REPOSITORY}/actions/runners/{identifier}', method='DELETE') - - -def execute(run_id, head, work_root, timeout): - check_user_manager() - reviewed_job(run_id, head) - url, checksum = runner_package(api(f'repos/{REPOSITORY}/actions/runners/downloads')) - name = f'cybexos-iso-{run_id}-{uuid.uuid4().hex[:12]}' - attempted = False - work = Path(tempfile.mkdtemp(prefix=f'cybexos-runner-{run_id}-', dir=work_root)) - retain = False - try: - download(url, checksum, work / 'runner.tar.gz') - extract(work / 'runner.tar.gz', work) - work.chmod(0o700) - (work / 'runner.tar.gz').unlink() - environment = child_environment(work) - # Recheck after a potentially slow download, immediately before enrolling. - reviewed_job(run_id, head) - registration = api(f'repos/{REPOSITORY}/actions/runners/registration-token', method='POST') - token = registration.get('token') if isinstance(registration, dict) else None - if not isinstance(token, str) or not token: - raise ValueError('GitHub did not return a registration token') - try: - attempted = True - # Runner CommandSettings accepts ACTIONS_RUNNER_INPUT_* and removes - # it from the environment after reading. Never put tokens in argv. - run_child([str(work / 'config.sh'), '--unattended', '--url', f'https://github.com/{REPOSITORY}', - '--name', name, '--ephemeral', '--disableupdate', '--no-default-labels', - '--labels', f'cybexos-iso-{run_id}', '--work', '_work'], - work, {**environment, 'ACTIONS_RUNNER_INPUT_TOKEN': token}, 120, capture=True) - token = None - registration = None - identifier = owned_runner(name) - if identifier is None: - raise RuntimeError('Runner registration was not visible in the repository') - _run, expected_job = reviewed_job(run_id, head, own_runner=identifier) - print(f'Starting ephemeral runner {name} for reviewed run {run_id}; automatic stop after {timeout} seconds.', flush=True) - try: - run_scoped_runner(work, environment, timeout, name) - except ActiveRunnerError: - retain = True - raise - completed = api(f'repos/{REPOSITORY}/actions/jobs/{expected_job["id"]}') - if (completed.get('run_id') != run_id or completed.get('runner_id') != identifier - or completed.get('head_sha') != head or completed.get('status') != 'completed'): - raise RuntimeError('Runner did not complete the reviewed job; inspect the workflow before retrying') - if completed.get('conclusion') != 'success': - raise RuntimeError('The reviewed qualification job failed; inspect its retained qualification reports') - print('Ephemeral runner stopped; removing its registration and task directory.', flush=True) - finally: - if attempted: - try: - cleanup_registration(name) - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: - raise RuntimeError(f'Registration cleanup failed for {name}; inspect repository Actions runners and the reported transient unit before removing that exact registration') from error - - finally: - if retain: - print(f'Retained task staging {work}: runner unit stop could not be confirmed.', file=sys.stderr) - else: - shutil.rmtree(work) - - -def interrupted(_signal, _frame): - raise KeyboardInterrupt - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--run-id', type=int, required=True, help='Already queued reviewed release workflow run') - parser.add_argument('--execute', action='store_true', help='Register and run one ephemeral runner; otherwise validate only') - parser.add_argument('--work-root', type=Path, default=Path('/data/cybexos-runners')) - parser.add_argument('--timeout', type=int, default=7 * 60 * 60, help='Maximum runner lifetime in seconds (default: 7 hours)') - args = parser.parse_args() - if args.run_id <= 0 or not 60 <= args.timeout <= 8 * 60 * 60: - parser.error('Use a positive run ID and a timeout between 60 seconds and 8 hours') - try: - head = checkout_head() - run, job = reviewed_job(args.run_id, head) - print(f'Reviewed {run["path"]} at {head}; queued job {job["id"]}; label cybexos-iso-{args.run_id}.') - if not args.execute: - print('Validation only. --execute registers an ephemeral runner and starts the reviewed job.') - return 0 - if platform.system() != 'Linux' or platform.machine() != 'x86_64' or os.geteuid() == 0: - raise ValueError('Run as the unprivileged PXE operator on Linux x86_64') - if not Path('/data/pxe/README.md').is_file(): - raise ValueError('Execute on the documented PXE host with /data/pxe/README.md') - work_root = args.work_root.resolve() - if work_root == ROOT or work_root.is_relative_to(ROOT) or work_root.is_relative_to(Path('/data/pxe/iso')): - raise ValueError('Runner staging must be outside the checkout and served ISO tree') - work_root.mkdir(parents=True, exist_ok=True) - for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(signum, interrupted) - execute(args.run_id, head, work_root, args.timeout) - return 0 - except KeyboardInterrupt: - print('Ephemeral runner interrupted; cleanup completed or was reported above.', file=sys.stderr) - return 130 - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, tarfile.TarError) as error: - parser.exit(1, f'release-runner: {error}\n') - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/repair-installed b/image/repair-installed deleted file mode 100755 index 8360b259..00000000 --- a/image/repair-installed +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -"""Repair an existing ISO installation without replacing its home or desktop ownership.""" -import argparse -from datetime import datetime, timezone -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import pwd -import shutil -import subprocess -import tempfile - -import jinja2 -import yaml - -from desktop_payload import prepare_system_session -from provision_payload import prepare_provision - -ROOT = Path(__file__).resolve().parents[1] - - -def load_applications(): - loader = importlib.machinery.SourceFileLoader('repair_applications', str(ROOT / 'image/applications')) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def prepare(payload): - prepare_provision(ROOT, payload) - - def copy(source, destination, executable=False): - target = payload / destination - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(ROOT / source, target) - target.chmod(0o755 if executable else 0o644) - - copy('image/installation_policy.py', 'usr/share/cybexos/lib/installation_policy.py') - for relative in ('usr/libexec/cybexos-configure-installed', 'usr/libexec/cybexos-config', - 'usr/bin/cybex', 'usr/bin/hyprland-quickshell', - 'usr/lib/systemd/user/hyprland-session.target', - 'usr/lib/systemd/user/external-monitor-toggle.service', - 'usr/lib/systemd/system/cybexos-hardware-setup.service', - 'usr/lib/systemd/system/cybexos-hardware-setup.timer'): - copy('image/rootfs/' + relative, relative, relative.startswith(('usr/bin/', 'usr/libexec/'))) - copy('roles/apps/files/cybexos-repository-policy', 'usr/libexec/cybexos-repository-policy', True) - for vendor in ('brave-browser', '1password', 'chatgpt', 'tailscale'): - copy(f'roles/apps/files/{vendor}.repo', f'usr/share/cybexos/repository-policy/{vendor}.repo') - contract = json.loads((ROOT / 'assets/desktop-contract.json').read_text()) - for name in contract['userHelpers']: - copy(f'assets/scripts/{name}', f'usr/share/cybexos/bin/{name}', True) - for relative in ('Common/Updates.qml', 'Common/Tailscale.qml', - 'Popovers/TailscalePopover.qml', 'Popovers/WifiPopover.qml'): - target = 'usr/share/cybexos/runtime/quickshell/' + relative - copy('roles/desktop/files/quickshell/' + relative, target) - path = payload / target - path.write_text(path.read_text().replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-')) - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - # Quickshell's feature-rendered unit, the packaged agent skill and the XPS - # monitor watcher. Provisioning then writes the Kitty fragment directly. - prepare_system_session(ROOT, payload, inventory) - for name in ('features.lua', 'looknfeel.lua'): - content = environment.from_string((ROOT / 'roles/desktop/templates' / (name + '.j2')).read_text()).render( - features=inventory['features'], cybexos_dynamic_hardware=True) - content = content.replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-') - if name == 'looknfeel.lua': - content += '\nhl.window_rule({ match = { class = [[^cybex$]] }, float = true, center = true, size = { 920, 620 } })\n' - target = payload / 'usr/share/cybexos/runtime/hypr' / name - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content) - seed = 'usr/share/cybexos/essential-seed/' - copy('roles/dotfiles/files/fish-config.fish', seed + '.config/fish/conf.d/50-cybexos.fish') - copy('assets/nautilus-localsend.py', seed + '.local/share/nautilus-python/extensions/localsend.py') - for name in ('localsend-share-clipboard', 'localsend-share-file', 'localsend-share-folder'): - path = payload / seed / f'.local/share/applications/{name}.desktop' - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text((ROOT / f'roles/dotfiles/templates/{name}.desktop.j2').read_text() - .replace('{{ primary_home }}/.local/bin/', '')) - path.chmod(0o644) - - -def run(command, **kwargs): - return subprocess.run(command, check=True, **kwargs) - - -def user_command(account, command): - return ['runuser', '-u', account.pw_name, '--', 'env', - 'XDG_RUNTIME_DIR=/run/user/' + str(account.pw_uid), - 'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/' + str(account.pw_uid) + '/bus', - 'HOME=' + account.pw_dir, *command] - - -def apply(payload, account, packages, hardware=False): - if os.geteuid() != 0: - raise PermissionError('Apply with sudo: ./image/repair-installed --apply --user ' + account.pw_name) - if not Path('/usr/share/cybexos/build.json').is_file(): - raise RuntimeError('This repair is for installed CybexOS images') - if Path('/run/cybexos-live').exists(): - raise RuntimeError('Do not repair a live installation session') - backup = Path('/var/lib/cybexos/backups') / ('install-repair-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')) - backup.mkdir(parents=True, mode=0o700, exist_ok=False) - print('Repair backup:', backup, flush=True) - files = [path for path in sorted(payload.rglob('*')) if path.is_file()] - changed = [path for path in files if not (Path('/') / path.relative_to(payload)).is_file() - or (Path('/') / path.relative_to(payload)).read_bytes() != path.read_bytes()] - (backup / 'manifest.json').write_text(json.dumps([str(p.relative_to(payload)) for p in changed], indent=2)) - session = Path('/run/user/' + str(account.pw_uid) + '/bus').exists() - shell_active = session and subprocess.run(user_command(account, ['systemctl', '--user', 'is-active', '--quiet', 'quickshell.service'])).returncode == 0 - for relative in ('etc/passwd', 'etc/group', 'etc/sudoers.d/10-wheel-nopasswd', - 'etc/firewalld/zones/cybexos.xml', - str(Path(account.pw_dir).relative_to('/')) + '/.config/fish/conf.d/50-cybexos.fish'): - source = Path('/') / relative - if source.is_file() and not source.is_symlink(): - saved = backup / relative - saved.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, saved) - try: - for path in changed: - relative = path.relative_to(payload) - target = Path('/') / relative - if target.is_symlink(): - raise RuntimeError('Refusing to replace a system symlink: ' + str(target)) - if target.exists(): - saved = backup / relative - saved.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(target, saved) - target.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix='.cybexos-repair-', dir=target.parent) - try: - with os.fdopen(fd, 'wb') as output: - output.write(path.read_bytes()) - os.fchmod(output.fileno(), path.stat().st_mode & 0o777) - os.replace(temporary, target) - finally: - Path(temporary).unlink(missing_ok=True) - run(['restorecon', '-RF', '/usr/share/cybexos/provision']) - if changed: - run(['restorecon', *[str(Path('/') / path.relative_to(payload)) for path in changed]]) - # Reload changed shell files immediately; package downloads may take minutes. - if shell_active: - run(user_command(account, ['systemctl', '--user', 'restart', 'quickshell.service'])) - run(['/usr/libexec/cybexos-repository-policy']) - if packages: - run(['dnf', '-y', '--setopt=*.skip_if_unavailable=false', 'install', *packages]) - run(['systemctl', 'daemon-reload']) - run(['/usr/libexec/cybexos-configure-installed', '--user', account.pw_name]) - run(user_command(account, ['/usr/libexec/cybexos-user-init', '--essential'])) - if session: - run(user_command(account, ['systemctl', '--user', 'daemon-reload'])) - run(user_command(account, ['systemctl', '--user', 'start', - 'hypridle.service', 'voxtype.service'])) - finally: - if shell_active: - run(user_command(account, ['systemctl', '--user', 'start', 'quickshell.service'])) - # All machine and user changes have finished before the asynchronous - # hardware timer can begin a second package transaction. - if hardware: - run(['/usr/libexec/cybexos-configure-installed', '--hardware', '--user', account.pw_name]) - run(['systemctl', 'enable', '--now', 'cybexos-hardware-setup.timer']) - print('Repair applied. Open a new terminal for Fish and the CLI aliases.', flush=True) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--apply', action='store_true') - parser.add_argument('--hardware', action='store_true') - parser.add_argument('--user', default=os.environ.get('SUDO_USER', os.environ.get('USER'))) - args = parser.parse_args() - account = pwd.getpwnam(args.user) - if account.pw_uid < 1000 or account.pw_name == 'liveuser' or Path(account.pw_dir).parent != Path('/home'): - parser.error('Select the installed desktop user') - packages, _ = load_applications().package_names() - installed = set(subprocess.check_output(['rpm', '-qa', '--qf', '%{NAME}\n%{NAME}.%{ARCH}\n'], text=True).splitlines()) - missing = sorted(set(packages) - installed) - with tempfile.TemporaryDirectory(prefix='cybexos-install-repair-') as temporary: - payload = Path(temporary) - prepare(payload) - print(json.dumps({'user': account.pw_name, 'missingPackages': missing, - 'actions': ['repair session ordering', 'reconcile signed repositories', - 'apply shared baseline, firewall and account defaults', - 'install missing helpers and LocalSend integration', - 'install the configure command, shell feature unit, agent skill and XPS watcher', - 'configure detected hardware'], - 'apply': args.apply}, indent=2), flush=True) - if args.apply: - apply(payload, account, missing, args.hardware) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/bin/cybex b/image/rootfs/usr/bin/cybex deleted file mode 100755 index 0abb6e0e..00000000 --- a/image/rootfs/usr/bin/cybex +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -command_name=${1:-help} -[[ $# == 0 ]] || shift -case $command_name in - welcome) exec /usr/bin/cybexos-welcome "$@" ;; - configure) exec /usr/libexec/cybexos-config "$@" ;; - update) exec /usr/share/cybexos/bin/cybexos-update-run run "$@" ;; - upgrade-system) exec sudo /usr/libexec/cybexos-major-upgrade "$@" ;; - update-channel) exec /usr/libexec/cybexos-update-channel "$@" ;; - prepare-apps) exec /usr/libexec/cybexos-user-init --background "$@" ;; - repair) exec sudo /usr/libexec/cybexos-configure-installed --user "${SUDO_USER:-$USER}" "$@" ;; - agent) exec /usr/share/cybexos/bin/cybexos-agent "$@" ;; - webapp) exec /usr/share/cybexos/bin/cybexos-webapp "$@" ;; - plugin|dev|shell) exec /usr/share/cybexos/bin/cybexos-runtime "$command_name" "$@" ;; - backup) exec /usr/share/cybexos/bin/cybexos-backup "$@" ;; - theme) exec /usr/share/cybexos/bin/cybexos-theme "$@" ;; - version|--version) exec rpm -q cybexos-desktop ;; - verify|doctor) exec /usr/libexec/cybexos-doctor "$@" ;; - report) exec /usr/share/cybexos/bin/cybexos-report "$@" ;; - factory-reset) exec sudo /usr/libexec/cybexos-factory-reset reset "$@" ;; - uninstall) - # The desktop is the operating system of an ISO installation; there is no - # separate workstation layer to remove. - echo 'cybex uninstall is not available on a CybexOS system installed from the ISO:' >&2 - echo 'the CybexOS desktop is part of the operating system. Use cybex configure to' >&2 - echo 'change installation choices, or reinstall to use a different desktop.' >&2 - exit 2 - ;; - help|-h|--help) - cat <<'EOF' -Usage: cybex [options] - -Commands: - welcome Open the CybexOS welcome window - configure Change installation choices and apply them (--check prints them) - update Check for and apply CybexOS and system updates - upgrade-system Prepare, perform, or inspect a supported Fedora major upgrade - update-channel Inspect or enroll the desktop RPM update channel - prepare-apps Prepare the offline applications for this account - repair Reapply the installed-system policy to this account - agent Launch or choose the default AI coding agent - webapp Install, remove, list, or launch web apps - plugin Install, update, clone, remove, or configure desktop plugins - shell Inspect shell health, enter safe mode, or retry the full desktop - theme List, apply, install, export, or remove appearance themes - dev Select, inspect, or disable a live development checkout - backup Back up, check, or restore personal files (setup, now, status, restore) - version Print the installed CybexOS package version - verify Check installed-system health (--json for machine-readable output) - doctor Alias for verify - report Collect a redacted diagnostic bundle for a bug report - factory-reset Erase all accounts and home folders and prepare for a new owner -EOF - ;; - *) echo "Unknown command: $command_name" >&2; exit 2 ;; -esac diff --git a/image/rootfs/usr/bin/cybexos-welcome b/image/rootfs/usr/bin/cybexos-welcome deleted file mode 100755 index 15a41e49..00000000 --- a/image/rootfs/usr/bin/cybexos-welcome +++ /dev/null @@ -1,630 +0,0 @@ -#!/usr/bin/env python3 -"""Unprivileged welcome window. Anaconda owns installation and authorization.""" -import json -import os -from pathlib import Path -import sys - -from PySide6.QtCore import QObject, Property, QProcess, QProcessEnvironment, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QGuiApplication -from PySide6.QtNetwork import QLocalServer, QLocalSocket -from PySide6.QtQml import QQmlApplicationEngine - -# Beside this program in both layouts: /usr/bin → /usr/share when installed, -# image/rootfs/usr/bin → image/rootfs/usr/share when run from a checkout. -QML = Path(__file__).resolve().parents[1] / "share/cybexos/welcome/Main.qml" - -# How long to wait for the desktop shell at login (it can start after this -# window), and how often to ask while a search or download is running. -SHELL_ATTEMPTS = 20 -SHELL_RETRY_MS = 1000 -POLL_MS = 400 -# An attached unattended-installation seed volume opens the installer by -# itself in a live session. USB media can appear a little after login. -SEED_LABELS = ("CYBEXOS-SEED", "CIDATA", "cidata") -SEED_ATTEMPTS = 30 - - -def seed_volume_present(by_label=Path("/dev/disk/by-label")): - return any((by_label / label).exists() for label in SEED_LABELS) - - -class Welcome(QObject): - changed = Signal() - activate = Signal() - wallpapersChanged = Signal() - wallpaperStateChanged = Signal() - setupChanged = Signal() - - # Shell IPC calls the window's buttons make, by the name QML passes. - SHELL_PAGES = { - "appearance": ["settings", "open", "appearance"], - # Settings → Wallpaper, on its Online (Wallhaven) view. - "wallpaper": ["wallpaper", "browse"], - "network": ["settings", "open", "network"], - } - - def __init__(self, install_mode=False): - super().__init__() - self.live = Path("/run/cybexos-live").exists() - # The "Install CybexOS" boot entry: this window is the whole session. - self.install_mode = self.live and install_mode - self.marker = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "cybexos/welcome-complete" - # The runtime helper reaches the shell that is actually running, - # including a development checkout selected with `cybex dev`. - self.runtime = Path.home() / ".local/bin/cybexos-runtime" - self.message = "" - self.process = QProcess(self) - self.process.finished.connect(self.finished) - self.process.errorOccurred.connect(self.failed) - self.process.stateChanged.connect(lambda _: self.changed.emit()) - # Keep diagnostics in the user service's journal, never in a root-owned - # file in the live account's home directory. - self.process.setProcessChannelMode(QProcess.ForwardedChannels) - - # The wallpaper row. `_wallpapers` changes only when the listed images - # do; the current file and download progress are separate properties, - # so polling never rebuilds the row or resets its scroll position. - self._wallpapers = [] - self._source = "loading" - self._note = "" - self._current = "" - self._downloading = "" - self._progress = 0 - self.attempts = 0 - self.local_reason = "" - self.results_pending = False - self.poll_timer = QTimer(self) - self.poll_timer.setSingleShot(True) - self.poll_timer.setInterval(POLL_MS) - self.poll_timer.timeout.connect(self.poll_results) - self.retry_timer = QTimer(self) - self.retry_timer.setSingleShot(True) - self.retry_timer.setInterval(SHELL_RETRY_MS) - self.retry_timer.timeout.connect(self.request_popular) - self._hardware_state = 'pending' - self._hardware_note = '' - self._nvidia_state = '' - self._nvidia_note = '' - self._nvidia_password = '' - self._seed_state = 'pending' - self._network_state = 'checking' - self._reconcile_state = 'pending' - self.setup_timer = QTimer(self) - self.setup_timer.setInterval(4000) - self.setup_timer.timeout.connect(self.refreshSetup) - self.network_process = QProcess(self) - network_environment = QProcessEnvironment.systemEnvironment() - network_environment.insert("LC_ALL", "C") - self.network_process.setProcessEnvironment(network_environment) - self.network_process.finished.connect(self.networkFinished) - self.network_process.errorOccurred.connect(self.networkFailed) - self.reconcile_process = QProcess(self) - self.reconcile_process.finished.connect(self.reconcileFinished) - self.reconcile_process.errorOccurred.connect(self.reconcileFailed) - self.setup_timeout = QTimer(self) - self.setup_timeout.setSingleShot(True) - self.setup_timeout.setInterval(8000) - self.setup_timeout.timeout.connect(self.setupTimedOut) - - def startSetup(self): - # Start only after singleton/autostart checks and successful QML load. - if not self.live: - self.setup_timer.start() - self.refreshSetup() - return - # The installer page validates the seed and shows its countdown; this - # window only opens it, as the Install button would. - self.seed_checks = 0 - self.seed_timer = QTimer(self) - self.seed_timer.setInterval(1000) - self.seed_timer.timeout.connect(self.checkSeed) - self.seed_timer.start() - self.checkSeed() - - def checkSeed(self, by_label=Path("/dev/disk/by-label")): - self.seed_checks += 1 - if seed_volume_present(by_label): - self.seed_timer.stop() - self.install() - elif self.seed_checks >= SEED_ATTEMPTS: - self.seed_timer.stop() - - def stopSetup(self): - self.setup_timer.stop() - self.setup_timeout.stop() - if getattr(self, "seed_timer", None) is not None: - self.seed_timer.stop() - for process in (self.network_process, self.reconcile_process): - # Destruction can emit errors; disconnect before stopping children. - process.finished.disconnect() - process.errorOccurred.disconnect() - if process.state() != QProcess.NotRunning: - process.terminate() - if not process.waitForFinished(200): - process.kill() - process.waitForFinished(200) - - def setupTimedOut(self): - for process in (self.network_process, self.reconcile_process): - if process.state() != QProcess.NotRunning: - process.kill() - - def networkFailed(self, _error): - if self._network_state != 'offline': - self._network_state = 'offline' - self.setupChanged.emit() - - def reconcileFailed(self, _error): - if self._reconcile_state != 'error': - self._reconcile_state = 'error' - self.setupChanged.emit() - - @Slot(str, result="QVariantMap") - def availableWorkArea(self, name): - # QML's ScreenInfo exposes virtual desktop size, which can span - # several monitors. Ask QScreen for this window's actual work area. - screens = QGuiApplication.screens() - screen = next((item for item in screens if item.name() == name), - QGuiApplication.primaryScreen()) - area = screen.availableGeometry() if screen else None - return {"width": max(1, area.width()) if area else 760, - "height": max(1, area.height()) if area else 660} - - @Property(bool, constant=True) - def isLive(self): - return self.live - - @Property(bool, constant=True) - def isInstallMode(self): - return self.install_mode - - @Property(bool, notify=changed) - def busy(self): - return self.process.state() != QProcess.NotRunning - - @Property(str, notify=changed) - def status(self): - return self.message - - @Property(str, notify=setupChanged) - def hardwareState(self): - return self._hardware_state - - @Property(str, notify=setupChanged) - def hardwareNote(self): - return self._hardware_note - - # '' (no NVIDIA GPU) | pending | checking | ready | reboot | enroll | - # skipped | failed; see nvidia_state(). - @Property(str, notify=setupChanged) - def nvidiaState(self): - return self._nvidia_state - - @Property(str, notify=setupChanged) - def nvidiaNote(self): - return self._nvidia_note - - # The one-time Secure Boot enrollment password. Its file is root:wheel - # 0640, so only an administrator's window can show it. - @Property(str, notify=setupChanged) - def nvidiaPassword(self): - return self._nvidia_password - - @Property(str, notify=setupChanged) - def seedState(self): - return self._seed_state - - @Property(str, notify=setupChanged) - def networkState(self): - return self._network_state - - @Property(str, notify=setupChanged) - def reconcileState(self): - return self._reconcile_state - - @staticmethod - def local_json(path): - try: - value = json.loads(path.read_text()) - return value if isinstance(value, dict) else {} - except (OSError, ValueError): - return {} - - @staticmethod - def nvidia_state(policy, status, boot): - """The NVIDIA row's state from the hardware role's status file.""" - state = status.get('state') - if not state or state == 'absent': - return 'pending' if policy.get('nvidia') else '' - # After a restart the boot-time hardware setup confirms the driver - # within minutes; until then the recorded request is out of date. - if state in ('reboot', 'enroll') and status.get('boot_id') not in (None, '', boot): - return 'checking' - if state == 'enroll': - return 'enroll' if status.get('key') == 'pending' else 'skipped' - return state if state in ('ready', 'reboot', 'failed') else 'pending' - - @staticmethod - def enrollment_password(status, enrollment): - password = str(enrollment.get('password', '')) - matches = bool(enrollment.get('key_sha256')) and enrollment.get('key_sha256') == status.get('key_sha256') - return password if matches and password.isdigit() else '' - - @Slot() - def refreshSetup(self): - if self.live: - return - hardware = self.local_json(Path('/var/lib/cybexos/hardware-status.json')) - policy = self.local_json(Path('/etc/cybexos/hardware.json')) - state_dir = Path(os.environ.get('XDG_STATE_HOME', str(Path.home() / '.local/state'))) / 'cybexos' - seed = self.local_json(state_dir / 'seed-progress.json') - nvidia = self.local_json(Path('/var/lib/cybexos/nvidia-status.json')) - try: - boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip() - except OSError: - boot = '' - nvidia_state = self.nvidia_state(policy, nvidia, boot) - values = { - '_hardware_state': 'skipped' if not policy.get('xps_2026') and not policy.get('nvidia') and not hardware - else hardware.get('state', 'pending'), - '_hardware_note': str(hardware.get('reason', '')), - '_nvidia_state': nvidia_state, - '_nvidia_note': str(nvidia.get('reason', '')), - '_nvidia_password': self.enrollment_password( - nvidia, self.local_json(Path('/var/lib/cybexos/nvidia-mok.json'))) if nvidia_state == 'enroll' else '', - '_seed_state': 'ready' if (state_dir / 'offline-apps-seeded').is_file() else seed.get('state', 'pending'), - } - if Path('/var/lib/cybexos/hardware-unsupported').is_file(): - values['_hardware_state'] = 'failed' - if any(getattr(self, key) != value for key, value in values.items()): - for key, value in values.items(): - setattr(self, key, value) - self.setupChanged.emit() - started = False - if self.network_process.state() == QProcess.NotRunning: - started = True - self.network_process.start('/usr/bin/nmcli', ['-t', '-f', 'STATE', 'general']) - if self.reconcile_process.state() == QProcess.NotRunning: - started = True - self.reconcile_process.start('/usr/libexec/cybexos-reconcile', ['--status']) - if started and not self.setup_timeout.isActive(): - self.setup_timeout.start() - - def reconcileFinished(self, code, _status): - output = bytes(self.reconcile_process.readAllStandardOutput()).decode('utf-8', 'replace') - try: - state = json.loads(output).get('state', 'error') if code == 0 else 'error' - except (ValueError, AttributeError): - state = 'error' - if self._reconcile_state != state: - self._reconcile_state = state - self.setupChanged.emit() - - def networkFinished(self, code, _status): - output = bytes(self.network_process.readAllStandardOutput()).decode('utf-8', 'replace').strip() - state = 'connected' if code == 0 and output.splitlines()[:1] == ['connected'] else 'offline' - if self._network_state != state: - self._network_state = state - self.setupChanged.emit() - - @Slot() - def retryApps(self): - if self.live: - return - QProcess.startDetached('/usr/bin/cybex', ['prepare-apps']) - - @Slot() - def retryHardware(self): - if self.live: - return - QProcess.startDetached('/usr/bin/kitty', ['-e', '/usr/bin/cybex', 'repair', '--hardware']) - - @Slot() - def install(self): - if not self.live or self.busy: - return - if not Path("/usr/bin/liveinst").is_file(): - self.message = "The installer is missing. Please use a complete CybexOS live image." - self.changed.emit() - return - self.message = "Opening the installer…" - self.process.start("/usr/bin/liveinst", ["--nosave=all_ks"]) - - def finished(self, code, _status): - self.message = ("The installer has closed. If installation finished, restart and remove the USB drive." - if code == 0 else "The installer could not finish. You can try again; details are in the installer logs.") - self.changed.emit() - - def failed(self, _error): - self.message = "Could not open the installer: " + self.process.errorString() - self.changed.emit() - - @Slot() - def startDesktop(self): - """Leave installation-only mode for the full live desktop.""" - if not self.install_mode or self.busy: - return - success, _ = QProcess.startDetached("systemctl", ["--user", "start", "--no-block", "hyprland-session.target"]) - if not success: - self.message = "The live desktop could not be started." - self.changed.emit() - return - # The desktop session opens its own live welcome window. - QGuiApplication.quit() - - @Slot(str) - def power(self, action): - # Installation-only mode has no shell, so this window offers the power menu. - if not self.install_mode or self.busy or action not in ("reboot", "poweroff"): - return - success, _ = QProcess.startDetached("systemctl", [action]) - if not success: - self.message = "The computer could not be restarted or powered off from here." - self.changed.emit() - - @Slot(str) - def openSettings(self, page): - if page not in self.SHELL_PAGES: - return - success, _ = QProcess.startDetached(str(self.runtime), ["ipc"] + self.SHELL_PAGES[page]) - if not success: - self.message = "The desktop settings could not be opened." - self.changed.emit() - - # Closing the window is how an installed system finishes the welcome; a - # live session never records it. - @Slot() - def markComplete(self): - if self.live: - return - self.marker.parent.mkdir(parents=True, exist_ok=True) - self.marker.touch() - - @Slot() - def finish(self): - if self.busy: - return - self.markComplete() - QGuiApplication.quit() - - # ---- wallpapers --------------------------------------------------- - - @Property("QVariantList", notify=wallpapersChanged) - def wallpapers(self): - return self._wallpapers - - # loading | waiting | online | local | unavailable - @Property(str, notify=wallpaperStateChanged) - def wallpaperSource(self): - return self._source - - @Property(str, notify=wallpaperStateChanged) - def wallpaperNote(self): - return self._note - - @Property(str, notify=wallpaperStateChanged) - def currentFile(self): - return self._current - - @Property(str, notify=wallpaperStateChanged) - def downloadingKey(self): - return self._downloading - - @Property(int, notify=wallpaperStateChanged) - def downloadProgress(self): - return self._progress - - def call(self, arguments, done): - """Runs `cybexos-runtime ipc ARGUMENTS` without blocking the window and - calls done(ok, stdout) exactly once.""" - process = QProcess(self) - settled = [False] - - def settle(ok): - if settled[0]: - return - settled[0] = True - try: - output = bytes(process.readAllStandardOutput()).decode("utf-8", "replace").strip() - process.deleteLater() - except RuntimeError: - # The window, and with it this process, is already gone. - return - done(ok, output) - - process.finished.connect( - lambda code, status: settle(status == QProcess.NormalExit and code == 0)) - # A program that never starts reports only this, never finished(). - process.errorOccurred.connect( - lambda error: settle(False) if error == QProcess.FailedToStart else None) - process.start(str(self.runtime), ["ipc"] + arguments) - - def set_state(self, **values): - changed = False - for name, value in values.items(): - attribute = "_" + name - if getattr(self, attribute) != value: - setattr(self, attribute, value) - changed = True - if changed: - self.wallpaperStateChanged.emit() - - def set_wallpapers(self, entries): - if [entry["key"] for entry in entries] != [entry["key"] for entry in self._wallpapers] \ - or [entry["kind"] for entry in entries] != [entry["kind"] for entry in self._wallpapers]: - self._wallpapers = entries - self.wallpapersChanged.emit() - - @Slot() - def loadWallpapers(self): - """Asks the shell for its Popular Wallhaven listing.""" - if self.live: - return - self.attempts = 0 - self.poll_timer.stop() - self.set_state(source="loading" if not self._wallpapers else self._source, note="") - self.request_popular() - - def request_popular(self): - self.call(["wallpaper", "popular"], self.popular_requested) - - def popular_requested(self, ok, _output): - if ok: - self.poll_results() - return - # The shell may still be starting: the welcome service and the shell - # come up with the same session target. - self.attempts += 1 - if self.attempts < SHELL_ATTEMPTS: - self.set_state(source="waiting", note="") - self.retry_timer.start() - else: - self.set_state(source="unavailable", - note="The desktop shell is not answering, so wallpapers cannot be shown yet.") - - @Slot() - def poll_results(self): - if self.results_pending: - return - self.results_pending = True - self.call(["wallpaper", "results"], self.results_received) - - def results_received(self, ok, output): - self.results_pending = False - state = self.parse(output) if ok else None - if state is None: - self.set_state(source="unavailable", - note="The desktop shell did not answer. Try again in a moment.") - return - busy = bool(state.get("busy")) or not state.get("searched") - items = [item for item in state.get("items") or [] if self.valid_online(item)] - if busy and not items: - self.set_state(source="loading") - self.poll_timer.start() - return - if not items: - # Offline, rate limited or an empty listing: the folder still - # offers something to pick. - self.load_local(state.get("error") or "Wallhaven returned no wallpapers.") - return - self.set_wallpapers([{ - "key": item["id"], - "kind": "online", - "thumb": item["thumb"], - "fileName": item["fileName"], - "label": "{} × {}".format(item.get("width", 0), item.get("height", 0)), - } for item in items]) - downloading = str(state.get("downloading") or "") - self.set_state(source="online", current=str(state.get("current") or ""), - downloading=downloading, - progress=int(state.get("progress") or 0), - note=str(state.get("downloadError") or "")) - if downloading or busy: - self.poll_timer.start() - - @staticmethod - def parse(output): - try: - value = json.loads(output) - except ValueError: - return None - return value if isinstance(value, dict) else None - - @staticmethod - def valid_online(item): - return (isinstance(item, dict) and isinstance(item.get("id"), str) - and isinstance(item.get("thumb"), str) and item["thumb"].startswith("https://th.wallhaven.cc/") - and isinstance(item.get("fileName"), str)) - - def load_local(self, reason=None): - """Lists the wallpaper folder instead; `reason` says why, and None - keeps the reason given when the row first fell back.""" - if reason is not None: - self.local_reason = reason - - def received(ok, output): - state = self.parse(output) if ok else None - if state is None: - self.set_state(source="unavailable", - note="The desktop shell did not answer. Try again in a moment.") - return - folder = Path(str(state.get("dir") or "~/Pictures/Wallpapers")).expanduser() - names = [name for name in state.get("files") or [] - if isinstance(name, str) and name and "/" not in name] - self.set_wallpapers([{ - "key": name, - "kind": "local", - "thumb": QUrl.fromLocalFile(str(folder / name)).toString(), - "fileName": name, - "label": name, - } for name in names]) - self.set_state(source="local", current=str(state.get("current") or ""), - downloading="", progress=0, - note=(self.local_reason + " " if self.local_reason else "") - + "Showing the wallpapers on this computer.") - self.call(["wallpaper", "list"], received) - - @Slot(str) - def pickWallpaper(self, key): - entry = next((entry for entry in self._wallpapers if entry["key"] == key), None) - if entry is None: - return - if entry["kind"] == "online": - self.set_state(downloading=key, progress=0, note="") - self.call(["wallpaper", "pick", key], lambda _ok, _output: self.poll_results()) - else: - self.set_state(current=key, note="") - self.call(["wallpaper", "set", key], - lambda ok, output: self.load_local() if ok and output == "ok" else - self.set_state(note="That wallpaper could not be applied.")) - - @Slot() - def refreshWallpapers(self): - """Catches up with a wallpaper chosen elsewhere (Settings, rotation).""" - if self._source == "online": - self.poll_results() - elif self._source == "local": - self.load_local() - - -def main(): - app = QGuiApplication(sys.argv) - app.setApplicationName("cybex") - app.setDesktopFileName("cybex") - backend = Welcome(install_mode="--install-mode" in sys.argv) - if "--autostart" in sys.argv and not backend.live and backend.marker.exists(): - return 0 - socket_name = str(Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "cybexos-welcome") - client = QLocalSocket() - client.connectToServer(socket_name) - if client.waitForConnected(300): - client.write(b"activate") - client.waitForBytesWritten(300) - return 0 - QLocalServer.removeServer(socket_name) - server = QLocalServer() - server.setSocketOptions(QLocalServer.UserAccessOption) - if not server.listen(socket_name): - raise RuntimeError(server.errorString()) - - def activate(): - connection = server.nextPendingConnection() - if connection: - connection.close() - connection.deleteLater() - backend.activate.emit() - - server.newConnection.connect(activate) - engine = QQmlApplicationEngine() - engine.rootContext().setContextProperty("welcome", backend) - engine.load(QUrl.fromLocalFile(str(QML))) - if not engine.rootObjects(): - return 1 - app.aboutToQuit.connect(backend.stopSetup) - backend.startSetup() - return app.exec() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/image/rootfs/usr/bin/hyprland-quickshell b/image/rootfs/usr/bin/hyprland-quickshell deleted file mode 100755 index c724394d..00000000 --- a/image/rootfs/usr/bin/hyprland-quickshell +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=Hyprland XDG_SESSION_DESKTOP=Hyprland -export DESKTOP_SESSION=hyprland-quickshell DESKTOP_SHELL=quickshell -export MOZ_ENABLE_WAYLAND=1 QT_QPA_PLATFORM=wayland -# Wayland applications reach IBus through the input-method protocol; -# XWayland applications need its XIM server. -export XMODIFIERS=@im=ibus -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$HOME/.npm-global/bin:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/cmdline-tools/latest/bin:/usr/local/bin:/usr/bin" -export XDG_DATA_DIRS="$HOME/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" -export ANDROID_HOME="$HOME/Android/Sdk" ANDROID_SDK_ROOT="$HOME/Android/Sdk" -/usr/libexec/cybexos-user-init --essential -# A live boot from the "Install CybexOS" entry starts only the installer. -# Hyprland passes this to cybexos-hyprland-session-start. -if [[ -e /run/cybexos-live && -e /run/cybexos-install-mode ]]; then - export CYBEXOS_SESSION_TARGET=cybexos-install-session.target -else - export CYBEXOS_SESSION_TARGET=hyprland-session.target -fi -CYBEXOS_XPS_2026=$(python3 -c 'import json,pathlib; p=pathlib.Path("/etc/cybexos/hardware.json"); print("1" if p.is_file() and json.loads(p.read_text()).get("xps_2026") else "0")') -# Units gated on the hardware (external-monitor-toggle.service) read it from -# the user manager, so it is imported with the session environment below. -export CYBEXOS_XPS_2026 -# Anaconda writes the system keyboard choice through locale1. Query it on -# every login so the shared image never hardcodes the live account's layout. -keyboard_property() { - busctl --system get-property org.freedesktop.locale1 /org/freedesktop/locale1 \ - org.freedesktop.locale1 "$1" 2>/dev/null | python3 -c \ - 'import shlex,sys; values=shlex.split(sys.stdin.read()); print(values[1] if len(values)>1 else "")' -} -CYBEXOS_KEYBOARD_LAYOUT=$(keyboard_property X11Layout) || true -CYBEXOS_KEYBOARD_VARIANT=$(keyboard_property X11Variant) || true -export CYBEXOS_KEYBOARD_LAYOUT=${CYBEXOS_KEYBOARD_LAYOUT:-us} -export CYBEXOS_KEYBOARD_VARIANT -# NVIDIA session variables, written by the NVIDIA hardware role, apply only -# while the proprietary DRM driver is loaded: a boot that fell back to -# nouveau must not inherit them. Read as data, never sourced. -nvidia_environment=() -if [[ -d /sys/module/nvidia_drm && -r /etc/cybexos/nvidia-session.env ]]; then - while IFS='=' read -r name value; do - case $name in - LIBVA_DRIVER_NAME|__GLX_VENDOR_LIBRARY_NAME|NVD_BACKEND) - [[ $value =~ ^[A-Za-z0-9_.-]+$ ]] || continue - export "$name=$value" - nvidia_environment+=("$name") - ;; - esac - done /dev/null 2>&1 || true' EXIT -# Launch once: compositor failure ends this login and returns to SDDM's -# password prompt. The watchdog's automatic safe-mode restart bypasses it. -Hyprland --config /usr/share/cybexos/runtime/hypr/hyprland.lua "$@" \ - >>"${XDG_STATE_HOME:-$HOME/.local/state}/hyprland/session.log" 2>&1 diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-first-boot.service b/image/rootfs/usr/lib/systemd/system/cybexos-first-boot.service deleted file mode 100644 index c7def9ed..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-first-boot.service +++ /dev/null @@ -1,20 +0,0 @@ -[Unit] -Description=Finish the CybexOS installation or factory reset before the first login -# Only when the installer or a factory reset left work for this boot. -ConditionPathExists=|/var/lib/cybexos/factory-point.pending -ConditionPathExists=|/var/lib/cybexos/factory-reset.json -ConditionPathExists=|/var/lib/cybexos/first-boot -ConditionKernelCommandLine=!rd.live.image -ConditionKernelCommandLine=!cybexos.recovery -After=local-fs.target cybexos-recovery-refresh.service cybexos-update-recover.service -Before=systemd-user-sessions.service display-manager.service cybexos-new-owner.service - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-first-boot -# Removing a previous owner's home folders can take a while. -TimeoutStartSec=45min -UMask=0077 - -[Install] -WantedBy=multi-user.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.service b/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.service deleted file mode 100644 index b071dd76..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Configure detected CybexOS hardware with the shared workstation role -Wants=network-online.target -After=network-online.target -ConditionPathExists=!/run/cybexos-live -ConditionPathExists=/etc/cybexos/hardware.json - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-configure-installed --hardware --automatic -TimeoutStartSec=45min -Nice=10 - diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.timer b/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.timer deleted file mode 100644 index bbde9997..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Complete detected hardware setup after installation -ConditionPathExists=!/run/cybexos-live - -[Timer] -OnBootSec=2min -OnUnitInactiveSec=15min -RandomizedDelaySec=30s - -[Install] -WantedBy=timers.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-new-owner.service b/image/rootfs/usr/lib/systemd/system/cybexos-new-owner.service deleted file mode 100644 index 51622d0e..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-new-owner.service +++ /dev/null @@ -1,32 +0,0 @@ -[Unit] -Description=CybexOS setup for a new owner -ConditionPathExists=/etc/cybexos/oem.json -ConditionKernelCommandLine=!rd.live.image -ConditionKernelCommandLine=!cybexos.recovery -After=cybexos-first-boot.service systemd-vconsole-setup.service -# The login screen and any console login wait until an account exists. -Wants=getty-pre.target -Before=getty-pre.target getty@tty1.service display-manager.service - -[Service] -Type=oneshot -# Free the first console from the boot splash and hide systemd's status lines. -ExecStartPre=-/usr/bin/plymouth quit -ExecStartPre=-/usr/bin/kill -s SIGRTMIN+21 1 -# Shows the graphical setup in cage, as an unprivileged transient user, on -# this console; asks on the console itself when that cannot start. -ExecStart=/usr/libexec/cybexos-new-owner -ExecStopPost=-/usr/bin/kill -s SIGRTMIN+20 1 -StandardInput=tty -StandardOutput=tty -# Diagnostics, cage's included, go to the journal, never onto the console -# the setup is shown on. They never contain an answer. -StandardError=journal -TTYPath=/dev/tty1 -TTYReset=yes -TTYVHangup=yes -TTYVTDisallocate=yes -TimeoutStartSec=infinity - -[Install] -WantedBy=graphical.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service deleted file mode 100644 index 03ba5c66..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Reconcile installed CybexOS policy after a desktop upgrade -After=local-fs.target systemd-user-sessions.service -ConditionPathExists=!/run/cybexos-live -ConditionPathExists=/usr/share/cybexos/reconcile-version - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-reconcile -TimeoutStartSec=45min -Nice=10 -UMask=0022 diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer deleted file mode 100644 index 0cdf5039..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Apply pending CybexOS desktop policy and initialize new accounts -ConditionPathExists=!/run/cybexos-live - -[Timer] -OnBootSec=2min -OnActiveSec=2min -OnUnitInactiveSec=5min -RandomizedDelaySec=30s - -[Install] -WantedBy=timers.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-tailscale-enroll.service b/image/rootfs/usr/lib/systemd/system/cybexos-tailscale-enroll.service deleted file mode 100644 index a74b118f..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-tailscale-enroll.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Join Tailscale once with the installation's auth key -# Started by cybexos-first-boot only when an unattended seed provided a key, -# so ordinary boots never wait for the network on its account. -ConditionPathExists=/run/cybexos/first-boot/tailscale-authkey -Wants=network-online.target -After=network-online.target tailscaled.service cybexos-first-boot.service - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-first-boot tailscale -TimeoutStartSec=6min diff --git a/image/rootfs/usr/lib/systemd/user/cybexos-app-seed.service b/image/rootfs/usr/lib/systemd/user/cybexos-app-seed.service deleted file mode 100644 index 97197e3e..00000000 --- a/image/rootfs/usr/lib/systemd/user/cybexos-app-seed.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Prepare offline CybexOS applications -PartOf=hyprland-session.target -After=quickshell.service - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-user-init --background -TimeoutStartSec=infinity -Nice=10 -IOSchedulingClass=idle diff --git a/image/rootfs/usr/lib/systemd/user/cybexos-session-lock.service b/image/rootfs/usr/lib/systemd/user/cybexos-session-lock.service deleted file mode 100644 index 0f6c582d..00000000 --- a/image/rootfs/usr/lib/systemd/user/cybexos-session-lock.service +++ /dev/null @@ -1,8 +0,0 @@ -[Unit] -Description=Singleton session lock -PartOf=hyprland-session.target -ConditionPathExists=!/run/cybexos-live - -[Service] -ExecStart=/usr/share/cybexos/bin/cybexos-runtime exec hyprlock -TimeoutStopSec=5 diff --git a/image/rootfs/usr/lib/systemd/user/cybexos-welcome.service b/image/rootfs/usr/lib/systemd/user/cybexos-welcome.service deleted file mode 100644 index 3fac2c7c..00000000 --- a/image/rootfs/usr/lib/systemd/user/cybexos-welcome.service +++ /dev/null @@ -1,8 +0,0 @@ -[Unit] -Description=CybexOS welcome -PartOf=hyprland-session.target -After=quickshell.service - -[Service] -ExecStart=/usr/bin/cybexos-welcome --autostart -Restart=no diff --git a/image/rootfs/usr/lib/systemd/user/external-monitor-toggle.service b/image/rootfs/usr/lib/systemd/user/external-monitor-toggle.service deleted file mode 100644 index f8bc8103..00000000 --- a/image/rootfs/usr/lib/systemd/user/external-monitor-toggle.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Apply XPS lid and external display policy -PartOf=hyprland-session.target -After=graphical-session-pre.target -StartLimitIntervalSec=0 -# hyprland-quickshell exports the detected hardware from -# /etc/cybexos/hardware.json; every other machine skips this unit. -ConditionEnvironment=CYBEXOS_XPS_2026=1 - -[Service] -Type=simple -ExecStart=/usr/libexec/cybexos-external-monitor-toggle -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=hyprland-session.target diff --git a/image/rootfs/usr/lib/systemd/user/hypridle.service.d/50-cybexos.conf b/image/rootfs/usr/lib/systemd/user/hypridle.service.d/50-cybexos.conf deleted file mode 100644 index 18d179bf..00000000 --- a/image/rootfs/usr/lib/systemd/user/hypridle.service.d/50-cybexos.conf +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -PartOf=hyprland-session.target -ConditionPathExists=!/run/cybexos-live - -[Service] -ExecStart= -ExecStart=/usr/share/cybexos/bin/cybexos-runtime exec hypridle diff --git a/image/rootfs/usr/lib/systemd/user/hyprland-session.target b/image/rootfs/usr/lib/systemd/user/hyprland-session.target deleted file mode 100644 index a6d4f565..00000000 --- a/image/rootfs/usr/lib/systemd/user/hyprland-session.target +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=CybexOS graphical session -# Vendor services start after graphical-session.target; do not order the -# target after every Wanted service, which would close a dependency cycle. -DefaultDependencies=no -BindsTo=graphical-session.target -Wants=graphical-session-pre.target quickshell.service hypridle.service voxtype.service hermes-menubar-bridge.service cybexos-welcome.service cybexos-app-seed.service cybexos-input-method.service external-monitor-toggle.service cybexos-backup.timer -After=graphical-session-pre.target -Before=graphical-session.target diff --git a/image/rootfs/usr/lib/systemd/user/voxtype.service.d/50-cybexos-seed.conf b/image/rootfs/usr/lib/systemd/user/voxtype.service.d/50-cybexos-seed.conf deleted file mode 100644 index 5fce8e68..00000000 --- a/image/rootfs/usr/lib/systemd/user/voxtype.service.d/50-cybexos-seed.conf +++ /dev/null @@ -1,4 +0,0 @@ -[Unit] -# The model is large; let the desktop appear while its offline copy finishes. -Wants=cybexos-app-seed.service -After=cybexos-app-seed.service diff --git a/image/rootfs/usr/libexec/cybexos-config b/image/rootfs/usr/libexec/cybexos-config deleted file mode 100755 index 521add35..00000000 --- a/image/rootfs/usr/libexec/cybexos-config +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env python3 -"""Record and change the installation choices saved in /etc/cybexos/config.yml. - -An ISO installation keeps the same schema that ./install writes on a -workstation, so cybexos-configure-installed passes it to provisioning. This -helper never reads, writes or asks for a password. -""" -import argparse -import copy -import grp -import json -import os -from pathlib import Path -import pwd -import re -import subprocess -import sys -import tempfile - -import yaml - -# Source fixtures must exercise this checkout, even on a machine with an older -# packaged policy installed. The installed helper reads its RPM-owned library. -source_image = Path(__file__).resolve().parent.parent.parent.parent -sys.path.insert(0, str(source_image) if (source_image / 'installation_policy.py').is_file() - else '/usr/share/cybexos/lib') -from installation_policy import ( # noqa: E402 - BOOLEANS, FEATURES, apply_choices, choices as validate_choices, defaults as installation_defaults) - - -CONFIG = Path('/etc/cybexos/config.yml') -# Explicit choices for the first account, recorded by the installer from an -# unattended seed or kept by a factory reset. Only a fresh account uses them. -PENDING = 'etc/cybexos/install-choices.json' -INVENTORY = Path('/usr/share/cybexos/provision/inventory/group_vars/all.yml') -APPLY = '/usr/libexec/cybexos-configure-installed' -# Key order and quoting follow ./install's generated file. -STRINGS = ('primary_user', 'primary_group', 'primary_home', 'machine_hostname', 'machine_timezone', - 'machine_locale', 'regional_locale', 'machine_keyboard_layout', 'machine_keyboard_variant') -PATTERNS = { - 'machine_hostname': r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', - 'machine_timezone': r'[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*', - 'machine_locale': r'[A-Za-z][A-Za-z0-9_@.-]+', - 'regional_locale': r'[A-Za-z][A-Za-z0-9_@.-]+', - 'machine_keyboard_layout': r'[A-Za-z0-9_,-]+', - 'machine_keyboard_variant': r'[A-Za-z0-9_,-]*', -} -FALLBACK = {'machine_hostname': 'fedora', 'machine_timezone': 'UTC', 'machine_locale': 'en_US.UTF-8', - 'regional_locale': 'en_US.UTF-8', 'machine_keyboard_layout': 'us', 'machine_keyboard_variant': ''} - - -def group_name(gid): - return grp.getgrgid(gid).gr_name - - -def desktop_accounts(entries): - """The installed desktop accounts that cybexos-configure-installed serves.""" - return [entry for entry in entries - if 1000 <= entry.pw_uid < 65534 and entry.pw_name != 'liveuser' - and Path(entry.pw_dir).parent == Path('/home')] - - -def login_policy(root): - try: - value = json.loads((root / 'etc/cybexos/login.json').read_text()) - except (OSError, ValueError): - return {} - return value if isinstance(value, dict) else {} - - -def assignments(path): - """Read a systemd KEY=value file such as locale.conf or vconsole.conf.""" - try: - lines = path.read_text().splitlines() - except OSError: - return {} - values = {} - for line in lines: - key, separator, value = line.strip().partition('=') - if separator and not key.startswith('#'): - values[key.strip()] = value.strip().strip('"\'') - return values - - -def keyboard(root): - # localed prefers vconsole.conf's XKB keys and otherwise reads the X11 - # file Anaconda writes; hyprland-quickshell reads the same through locale1. - console = assignments(root / 'etc/vconsole.conf') - if console.get('XKBLAYOUT'): - return console['XKBLAYOUT'], console.get('XKBVARIANT', '') - try: - text = (root / 'etc/X11/xorg.conf.d/00-keyboard.conf').read_text() - except OSError: - text = '' - options = dict(re.findall(r'^\s*Option\s+"(Xkb\w+)"\s+"([^"]*)"', text, re.MULTILINE)) - return options.get('XkbLayout', ''), options.get('XkbVariant', '') - - -def identity(root): - try: - hostname = (root / 'etc/hostname').read_text().strip() - except OSError: - hostname = '' - try: - timezone = os.readlink(root / 'etc/localtime').partition('zoneinfo/')[2] - except OSError: - timezone = '' - locale = assignments(root / 'etc/locale.conf').get('LANG', '') - if locale in ('C', 'POSIX') or locale.startswith('C.'): - locale = '' - layout, variant = keyboard(root) - values = {'machine_hostname': hostname, 'machine_timezone': timezone, 'machine_locale': locale, - 'regional_locale': locale, 'machine_keyboard_layout': layout, - 'machine_keyboard_variant': variant if layout else ''} - return {key: value if valid_identity(key, value, root) else FALLBACK[key] - for key, value in values.items()} - - -def valid_identity(key, value, root=Path('/')): - if not isinstance(value, str) or not re.fullmatch(PATTERNS[key], value): - return False - return key != 'machine_timezone' or (root / 'usr/share/zoneinfo' / value).is_file() - - -def pending_choices(root=Path('/')): - """The validated first-account choices, or {} when none were recorded.""" - path = root / PENDING - try: - document = json.loads(path.read_text()) - except FileNotFoundError: - return {} - except (OSError, ValueError) as error: - raise ValueError(f'{path} could not be read: {error}') from None - if not isinstance(document, dict) or document.get('version') != 1: - raise ValueError(f'{path} is not a version 1 choice record.') - try: - return validate_choices(document.get('choices')) - except ValueError as error: - raise ValueError(f'{path}: {error}') from None - - -def detect(root=Path('/'), entries=None, group=group_name, inventory=INVENTORY, fresh_account=False): - """Describe an installed system in ./install's schema, or None if ambiguous.""" - candidates = desktop_accounts(pwd.getpwall() if entries is None else entries) - login = login_policy(root) - account = next((entry for entry in candidates if entry.pw_name == login.get('user')), None) - if account is None and len(candidates) == 1: - account = candidates[0] - if account is None: - return None - values = {'config_schema_version': 1, 'primary_user': account.pw_name, - 'primary_group': group(account.pw_gid), 'primary_home': account.pw_dir, **identity(root)} - values.update(installation_defaults(inventory, fresh_account=fresh_account)) - if not fresh_account: - # Repair may record defaults before its first shared policy application. - # An existing administrator grant is not an explicit opt-out: retain it - # when synthesizing a legacy configuration, until cybex configure changes it. - try: - for line in (root / 'etc/group').read_text().splitlines(): - fields = line.split(':') - if len(fields) == 4 and fields[0] == 'docker': - values['docker_sudoless'] = (account.pw_name in fields[3].split(',') - or str(account.pw_gid) == fields[2]) - except FileNotFoundError: - pass - if fresh_account: - # The same policy function the checkout installer uses for saved - # choices; an older account is never opted into them by repair. - values = apply_choices(values, pending_choices(root)) - # Only the target helper's verified decision (or a saved login policy) - # can enable automatic login; the build host is never an input. - values['desktop_autologin'] = login.get('user') == account.pw_name and login.get('autologin') is True - return values - - -def quoted(value): - return "'" + value.replace("'", "''") + "'" - - -def render(values): - lines = ['---', 'config_schema_version: 1'] - lines += [f'{key}: {quoted(values[key])}' for key in STRINGS] - lines += [f'{key}: {str(values[key]).lower()}' for key in BOOLEANS] - lines += ['features:'] + [f' {key}: {str(values["features"][key]).lower()}' for key in FEATURES] - return '\n'.join(lines) + '\n' - - -def validate(document, root=Path('/'), entries=None, group=group_name): - """Require the complete schema 1 shape and the installed account's identity.""" - if not isinstance(document, dict) or set(document) != {'config_schema_version', 'features', *STRINGS, *BOOLEANS}: - raise ValueError('The configuration does not have the schema 1 keys.') - if type(document['config_schema_version']) is not int or document['config_schema_version'] != 1: - raise ValueError('Only configuration schema 1 is supported.') - features = document['features'] - if not isinstance(features, dict) or set(features) != set(FEATURES): - raise ValueError('The configuration does not have the schema 1 features.') - if not all(type(value) is bool for value in [*features.values(), *(document[key] for key in BOOLEANS)]): - raise ValueError('Configuration choices must be true or false.') - for key in PATTERNS: - if not valid_identity(key, document[key], root): - raise ValueError(f'Invalid {key}: {document[key]!r}') - account = next((entry for entry in desktop_accounts(pwd.getpwall() if entries is None else entries) - if entry.pw_name == document['primary_user']), None) - if account is None or account.pw_dir != document['primary_home'] or group(account.pw_gid) != document['primary_group']: - raise ValueError('primary_user must name an installed desktop account with its home and group.') - return document - - -def parse(text, **options): - try: - document = yaml.safe_load(text) - except yaml.YAMLError as error: - raise ValueError(f'The configuration is not valid YAML: {error}') from None - return validate(document, **options) - - -def write(text, path=CONFIG, replace=True): - """Write atomically as 0644; without replace, never overwrite a saved file.""" - path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) - fd, name = tempfile.mkstemp(prefix='.' + path.name + '.', dir=path.parent) - try: - with os.fdopen(fd, 'w') as stream: - os.fchmod(stream.fileno(), 0o644) - stream.write(text) - stream.flush() - os.fsync(stream.fileno()) - if replace: - os.replace(name, path) - else: - os.link(name, path) - finally: - Path(name).unlink(missing_ok=True) - - -def ensure(root=Path('/'), **options): - """Record a missing configuration; return whether one was written.""" - path = root / 'etc/cybexos/config.yml' - if path.exists() or path.is_symlink(): - return False - values = detect(root, **options) - if values is None: - return False - try: - write(render(values), path, replace=False) - except FileExistsError: - return False - if options.get('fresh_account'): - # config.yml now holds these choices; the record has been consumed. - (root / PENDING).unlink(missing_ok=True) - return True - - -def current(root=Path('/'), entries=None, group=group_name, inventory=INVENTORY): - """Saved choices, completed with detected values for any missing keys.""" - path = root / 'etc/cybexos/config.yml' - values = detect(root, entries, group, inventory) or {} - try: - saved = yaml.safe_load(path.read_text()) - except FileNotFoundError: - if not values: - raise ValueError('No single installed desktop account could be selected.') from None - saved = {} - except (OSError, yaml.YAMLError) as error: - raise ValueError(f'{path} could not be read: {error}') from None - if not isinstance(saved, dict): - raise ValueError(f'{path} does not contain a configuration.') - features = {**values.get('features', {}), **(saved.get('features') or {})} - values.update({key: saved[key] for key in ('config_schema_version', *STRINGS, *BOOLEANS) if key in saved}) - values['features'] = {key: features[key] for key in FEATURES if key in features} - return validate(values, root, entries, group) - - -def ask_bool(prompt, default, read=input): - suffix = ' [Y/n]: ' if default else ' [y/N]: ' - while True: - answer = read(prompt + suffix).strip().lower() - if not answer: - return default - if answer in ('y', 'yes'): - return True - if answer in ('n', 'no'): - return False - print('Please answer yes or no.', file=sys.stderr) - - -def questionnaire(values, read=input): - """Ask only the policy choices that installed-system provisioning applies.""" - result = copy.deepcopy(values) - features = result['features'] - print('CybexOS configuration') - print('Current choices are offered as the default. Hostname, timezone, language and') - print('keyboard are changed in the system settings, not here.') - print() - features['docker'] = ask_bool('Enable Docker?', features['docker'], read) - features['tailscale'] = ask_bool('Enable Tailscale?', features['tailscale'], read) - features['connected_widgets'] = ask_bool('Enable connected-service widgets?', features['connected_widgets'], read) - features['local_network_services'] = ask_bool('Open feature-selected local-network firewall ports?', - features['local_network_services'], read) - print() - print('The following choices relax local authentication. Review each explicitly.') - result['passwordless_wheel'] = ask_bool('Allow the wheel group to use sudo without a password?', - result['passwordless_wheel'], read) - result['passwordless_local_polkit'] = ask_bool( - 'Authorize active local wheel users through Polkit without a password?', - result['passwordless_local_polkit'], read) - result['docker_sudoless'] = features['docker'] and ask_bool( - f"Let {result['primary_user']} use Docker without sudo? (The docker group is equivalent to root access.)", - result['docker_sudoless'], read) - result['desktop_autologin'] = ask_bool( - 'Automatically log this user into Hyprland when the root disk is encrypted?', - result['desktop_autologin'], read) - print() - result['manage_personal_dotfiles'] = ask_bool('Install the opinionated shell and application dotfiles?', - result['manage_personal_dotfiles'], read) - return result - - -def summary(values): - features = values['features'] - print() - print('Configuration summary') - print(f"User: {values['primary_user']} ({values['primary_home']})\nHost: {values['machine_hostname']}") - print(f"Docker: {features['docker']} Tailscale: {features['tailscale']} " - f"Connected widgets: {features['connected_widgets']} " - f"Local-network ports: {features['local_network_services']}") - print(f"Passwordless sudo: {values['passwordless_wheel']} " - f"Passwordless Polkit: {values['passwordless_local_polkit']} " - f"Sudoless Docker: {values['docker_sudoless']} Autologin: {values['desktop_autologin']}") - print(f"Personal dotfiles: {values['manage_personal_dotfiles']}") - if values['desktop_autologin']: - print('Automatic login is saved only after the root filesystem is verified as encrypted,') - print('and every boot verifies it again before logging in.') - - -def main(argv=None, read=input, run=subprocess.run, interactive=None, **options): - parser = argparse.ArgumentParser(prog='cybex configure', - description='Change the saved CybexOS installation choices and apply them.') - parser.add_argument('--check', action='store_true', help='Show the configuration; do not change it') - args = parser.parse_args(argv) - try: - values = current(**options) - except ValueError as error: - print(f'cybex configure: {error}', file=sys.stderr) - return 1 - if args.check: - sys.stdout.write(render(values)) - return 0 - if not (sys.stdin.isatty() if interactive is None else interactive): - print('cybex configure: interactive setup needs a terminal (or use --check)', file=sys.stderr) - return 2 - try: - changed = questionnaire(values, read) - summary(changed) - confirmed = ask_bool('Apply this configuration?', False, read) - except (EOFError, KeyboardInterrupt): - print('\nConfiguration cancelled.') - return 1 - if not confirmed: - print('Configuration cancelled.') - return 0 - # The root helper validates, saves atomically and reapplies the policy. - result = run(['sudo', APPLY, '--user', changed['primary_user'], '--save-config'], - input=render(changed), text=True, check=False) - if result.returncode: - print('cybex configure: the configuration was not applied; see the messages above.', file=sys.stderr) - return result.returncode - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-configure-installed b/image/rootfs/usr/libexec/cybexos-configure-installed deleted file mode 100755 index 5fc5eff4..00000000 --- a/image/rootfs/usr/libexec/cybexos-configure-installed +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the same baseline/account/hardware tasks used by checkout installs.""" -import argparse -import fcntl -import grp -import hashlib -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import pwd -import signal -import subprocess -import sys -import time -import uuid - - -PROVISION = Path('/usr/share/cybexos/provision') -CONFIG_TOOL = Path('/usr/libexec/cybexos-config') -LOGIN_PREPARE = Path('/usr/libexec/cybexos-login-prepare') -LOGIN = Path('/etc/cybexos/login.json') - - -def load(name, path): - loader = importlib.machinery.SourceFileLoader(name, str(path)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -# A fixed unit also fences an orphaned workload after its caller is killed. -# setsid/double-fork do not escape a systemd cgroup; process groups do. -WORK_UNIT = 'cybexos-provision-work.service' -WORK_CGROUP = Path('/sys/fs/cgroup/system.slice') / WORK_UNIT - - -def populated(): - try: - return 'populated 1' in (WORK_CGROUP / 'cgroup.events').read_text().splitlines() - except FileNotFoundError: - return False - - -def require_manager(): - if not Path('/sys/fs/cgroup/cgroup.controllers').is_file(): - raise RuntimeError('Live provisioning requires the systemd cgroup v2 manager') - subprocess.run(['/usr/bin/systemctl', 'show', '--property=Version', '--value'], - check=True, stdout=subprocess.DEVNULL, timeout=30) - - -def drain_workload(): - """Keep the provisioning lock until the entire previous workload is gone.""" - require_manager() - try: - subprocess.run(['/usr/bin/systemctl', 'stop', WORK_UNIT], check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) - except subprocess.TimeoutExpired: - pass - warned = False - while populated(): - # cgroup.kill includes sub-cgroups and detached descendants atomically. - # An unkillable kernel task must fence further provisioning/recovery, - # rather than releasing serialization on an unverified timeout. - try: - (WORK_CGROUP / 'cgroup.kill').write_text('1\n') - except FileNotFoundError: - continue - if not warned: - print('Waiting for the provisioning cgroup to become empty; serialization remains held', - file=sys.stderr, flush=True) - warned = True - time.sleep(0.1) - subprocess.run(['/usr/bin/systemctl', 'reset-failed', WORK_UNIT], check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) - - -def drain(): - # Cancellation cannot unwind the lock's context before teardown finishes. - blocked = {signal.SIGTERM, signal.SIGHUP, signal.SIGINT} - previous = signal.pthread_sigmask(signal.SIG_BLOCK, blocked) - try: - drain_workload() - finally: - signal.pthread_sigmask(signal.SIG_SETMASK, previous) - - -def provision_output(identifier, cursor=None): - """Read a bounded journal batch for this launch, including after collection.""" - command = ['/usr/bin/journalctl', '--boot', '--no-pager', '--output=short-precise', - '--show-cursor', '--lines=100', 'SYSLOG_IDENTIFIER=' + identifier] - if cursor: - command += ['--after-cursor=' + cursor] - try: - result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=5) - output = [] - for line in result.stdout.splitlines(keepends=True): - if line.startswith('-- cursor: '): - cursor = line.removeprefix('-- cursor: ').strip() - else: - output.append(line) - # Bound caller output as well as the number of journal entries. - text = ''.join(output) - if len(text) > 32768: - print('[Provisioning journal batch truncated to its last 32768 characters]', flush=True) - if text: - print(text[-32768:], end='' if text.endswith('\n') else '\n', flush=True) - except (OSError, subprocess.SubprocessError) as error: - # Diagnostic failure must never hide the worker's result or its drain. - print(f'Provisioning journal unavailable ({identifier}): {error}', file=sys.stderr, flush=True) - return cursor - - -def contained(command, *, env, cwd=None, timeout=600): - # Called only while main owns provision.lock. Drain a preceding caller's - # abandoned unit before launching anything or changing saved choices. - drain() - # Journal snapshots preserve service containment without forwarding caller - # descriptors to PID 1. --wait still depends on the public manager bus. - # A fresh identifier prevents the fixed unit's older launches leaking in. - identifier = 'cybexos-provision-' + uuid.uuid4().hex - launch = ['/usr/bin/systemd-run', '--system', '--quiet', '--wait', '--collect', - '--unit=' + WORK_UNIT, '--service-type=exec', - '--property=ExitType=cgroup', '--property=KillMode=control-group', - '--property=StandardOutput=journal', '--property=StandardError=journal', - '--property=SyslogIdentifier=' + identifier, - '--property=TimeoutStopSec=15s', '--property=SendSIGKILL=yes', - '--property=RuntimeMaxSec=' + str(timeout) + 's'] - if cwd: - launch += ['--working-directory=' + str(cwd)] - launch += ['--setenv=' + key + '=' + value for key, value in env.items()] - launch += ['--', *command] - cursor = None - print(f'Provisioning in {WORK_UNIT} (limit {timeout}s); journal identifier: {identifier}', flush=True) - try: - # Poll finite journal snapshots, never an orphanable journal follower. - # Kill the client on cancellation and let its context reap it. Only - # drain() establishes that its service and detached descendants stopped. - with subprocess.Popen(launch, env=env) as client: - deadline = time.monotonic() + timeout + 60 - try: - while True: - try: - result = client.wait(timeout=max(.01, min(15, deadline - time.monotonic()))) - except subprocess.TimeoutExpired: - if time.monotonic() >= deadline: - raise subprocess.TimeoutExpired(launch, timeout + 60) from None - print(f'Provisioning still running in {WORK_UNIT}', flush=True) - cursor = provision_output(identifier, cursor) - else: - if result: - raise subprocess.CalledProcessError(result, launch) - break - finally: - if client.poll() is None: - client.kill() - finally: - # A timed-out systemd-run client is not proof that its unit stopped. - try: - drain() - finally: - provision_output(identifier, cursor) - - -def interrupted(signum, _frame): - raise InterruptedError('Provisioning interrupted by signal ' + str(signum)) - - -def encrypted_root(): - """Reuse the boot-time login check; anything unverifiable is unencrypted.""" - try: - return load('cybexos_login_prepare', LOGIN_PREPARE).encrypted_root() is True - except (OSError, ImportError, SyntaxError): - return False - - -def save_config(settings, text, user): - try: - values = settings.parse(text) - except ValueError as error: - raise SystemExit(f'The configuration was not saved: {error}') from None - if values['primary_user'] != user: - raise SystemExit('The configuration was not saved: it belongs to another account.') - if values['desktop_autologin'] and not encrypted_root(): - raise SystemExit('The configuration was not saved: automatic login requires a root ' - 'filesystem verified as encrypted.') - settings.write(settings.render(values)) - - -def record_login(settings): - """Keep login intent in step with the saved choice, like the finalize role. - - cybexos-login-prepare still verifies root encryption on every boot. - """ - if not settings.CONFIG.is_file(): - return - try: - values = settings.current() - except ValueError as error: - print(f'Login policy unchanged: {error}', file=sys.stderr) - return - policy = {'version': 1, 'user': values['primary_user'], 'autologin': values['desktop_autologin'], 'live': False} - try: - if json.loads(LOGIN.read_text()) == policy: - return - except (OSError, ValueError): - pass - settings.write(json.dumps(policy) + '\n', LOGIN) - - -def accounts(entries, selected=None): - return [entry for entry in entries - if 1000 <= entry.pw_uid < 65534 and entry.pw_name != 'liveuser' - and Path(entry.pw_dir).parent == Path('/home') - and (selected is None or entry.pw_name == selected)] - - -def configure(account, offline=False, hardware=False, reconcile=False, automatic=False, installer_target=False): - # Do not inherit a user's callback plugins, Ansible configuration or Python - # path into a root provisioning process. The payload contains reviewed roles. - environment = { - 'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', - 'LANG': 'C.UTF-8', 'ANSIBLE_CONFIG': str(PROVISION / 'image/provision.cfg'), - 'ANSIBLE_ROLES_PATH': str(PROVISION / 'roles'), - 'ANSIBLE_STDOUT_CALLBACK': 'default', 'ANSIBLE_FORCE_HANDLERS': 'true', - 'ANSIBLE_PIPELINING': 'true', 'ANSIBLE_LOCALHOST_WARNING': 'false', - 'PYTHONDONTWRITEBYTECODE': '1', - } - values = { - 'primary_user': account.pw_name, 'primary_home': account.pw_dir, - 'primary_group': grp.getgrgid(account.pw_gid).gr_name, - 'config_repo': str(PROVISION), 'cybexos_offline': offline, - 'cybexos_hardware_only': hardware, - # Boot-time setup renews a skipped Secure Boot key request only on - # an explicit cybex repair --hardware. - 'cybexos_hardware_automatic': automatic, - 'cybexos_reconcile': reconcile, - # Repair is not fresh consent for the selected account. In particular, - # another account's saved Docker choice must not become its own. - 'cybexos_docker_direct_install': False, - 'start_optional_hardware_services': not offline, - } - # Seeding a later non-admin account must not promote it to wheel. - wheel = grp.getgrnam('wheel') - values['base_account_groups'] = ['video', 'input'] + ( - ['wheel'] if account.pw_name in wheel.gr_mem or account.pw_gid == wheel.gr_gid else []) - command = ['/usr/bin/ansible-playbook', '-i', 'localhost,', - str(PROVISION / 'image/provision.yml')] - saved = Path('/etc/cybexos/config.yml') - if saved.is_file(): - command += ['--extra-vars', '@' + str(saved)] - command += ['--extra-vars', json.dumps(values)] - if installer_target: - # Only the explicit, verified Anaconda chroot has no target manager. - subprocess.run(command, cwd=PROVISION, env=environment, check=True, timeout=2700) - else: - contained(command, cwd=PROVISION, env=environment, timeout=600 if reconcile else 2700) - - -def retire_legacy_fish(account): - legacy = Path(account.pw_dir) / '.config/fish/conf.d/50-cybex.fish' - vendor = Path('/usr/share/cybexos/essential-seed/.config/fish/conf.d/50-cybex.fish') - # Operate as the owner, never as root through user-controlled directories. - if vendor.is_file(): - subprocess.run(['runuser', '-u', account.pw_name, '--', 'python3', '-c', - 'import pathlib,sys; p=pathlib.Path(sys.argv[1]); v=pathlib.Path(sys.argv[2]); ' - 'p.unlink() if p.is_file() and not p.is_symlink() and p.read_bytes()==v.read_bytes() else None', - str(legacy), str(vendor)], check=True) - - -def boot_id(): - return Path('/proc/sys/kernel/random/boot_id').read_text().strip() - - -def read_hardware_status(path): - try: - value = json.loads(path.read_text()) - return value if isinstance(value, dict) and value.get('state') in ('pending', 'completed', 'failed', 'running') else {} - except (OSError, ValueError): - return {} - - -def write_hardware_status(path, value): - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix('.tmp') - temporary.write_text(json.dumps(value, sort_keys=True) + '\n') - temporary.replace(path) - - -def merge_nvidia(state, reason, nvidia): - """Fold the NVIDIA role's outcome into the hardware state. - - A restart (driver switch or Secure Boot key enrollment) is pending, so - the next boot runs setup again and confirms the driver. A failed driver, - or a key request the user skipped at boot, waits for cybex repair - --hardware; the open-source driver keeps the desktop running meanwhile. - """ - nvidia = nvidia if isinstance(nvidia, dict) else {} - nvidia_state = nvidia.get('state', 'absent') - detail = str(nvidia.get('reason', '')) - needs_retry = nvidia_state == 'failed' or (nvidia_state == 'enroll' and nvidia.get('key') == 'skipped') - if needs_retry and state != 'failed': - return 'failed', detail or 'NVIDIA driver setup failed; run cybex repair --hardware', nvidia - if nvidia_state in ('reboot', 'enroll') and state == 'completed': - # Not "reboot": that word marks the camera transaction's restart. - return 'pending', detail or 'restart required for the NVIDIA driver', nvidia - return state, reason, nvidia - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--installer-target', action='store_true', - help='Provision the offline Anaconda target chroot without a target systemd') - parser.add_argument('--drain', action='store_true', help='Wait for abandoned provisioning workers to stop') - parser.add_argument('--offline', action='store_true') - parser.add_argument('--hardware', action='store_true') - parser.add_argument('--reconcile', action='store_true') - parser.add_argument('--user') - parser.add_argument('--automatic', action='store_true') - parser.add_argument('--save-config', action='store_true', - help='Validate and save a configuration from standard input, then apply it') - args = parser.parse_args() - if os.geteuid() != 0: - parser.error('Run as root in the installed system') - if sum((args.offline, args.hardware, args.reconcile)) > 1: - parser.error('--offline, --hardware, and --reconcile are mutually exclusive') - if args.automatic and not args.hardware: - parser.error('--automatic is only valid for hardware setup') - if args.save_config and (args.offline or args.hardware or args.reconcile or not args.user): - parser.error('--save-config applies one account in the running installed system') - if args.installer_target: - if not args.offline or args.drain or subprocess.run( - ['/usr/bin/systemd-detect-virt', '--chroot', '--quiet'], check=False).returncode != 0: - parser.error('--installer-target requires --offline in a verified installer chroot') - text = sys.stdin.read() if args.save_config else None - lock_directory = Path('/run/lock/cybexos') - lock_directory.mkdir(mode=0o755, exist_ok=True) - with (lock_directory / 'provision.lock').open('a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - for signum in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): - signal.signal(signum, interrupted) - if not args.installer_target: - drain() - if args.drain: - return - users = accounts(pwd.getpwall(), args.user) - if args.user and not users: - parser.error('Select an existing installed desktop account') - settings = None if args.hardware or args.reconcile else load('cybexos_config', CONFIG_TOOL) - if args.save_config: - save_config(settings, text, args.user) - elif settings and settings.ensure(fresh_account=args.offline): - # Installation and repair of an older ISO installation record the - # detected choices before provisioning consumes them. - print('Recorded the installation choices in /etc/cybexos/config.yml') - digest = hashlib.sha256() - for path in sorted(PROVISION.rglob('*')): - if path.is_file(): - digest.update(str(path.relative_to(PROVISION)).encode()) - digest.update(path.read_bytes()) - saved_config = Path('/etc/cybexos/config.yml') - if saved_config.is_file(): - digest.update(saved_config.read_bytes()) - identity = digest.hexdigest() + ':' + os.uname().release + ':' + ','.join(user.pw_name for user in users) - marker = Path('/var/lib/cybexos/hardware-configured') - unsupported = marker.with_name('hardware-unsupported') - status_path = marker.with_name('hardware-status.json') - status = read_hardware_status(status_path) if args.hardware else {} - this_boot = boot_id() if args.hardware else '' - same_identity = status.get('identity') == identity - if args.automatic and unsupported.is_file() and unsupported.read_text() == identity: - raise SystemExit('Camera setup needs kernel compatibility review; see /var/lib/xps-hardware/ipu7/abi-failed.log. Automatic retry waits for a changed kernel or provisioning payload.') - if args.automatic and same_identity: - if status['state'] == 'completed': - return - if status['state'] == 'pending' and status.get('boot_id') == this_boot: - return - if status['state'] == 'failed' and status.get('boot_id') == this_boot: - return - if status.get('resume_boot_id') == this_boot: - return - # A legacy marker did not distinguish success from reboot-pending. - # Validate it once and produce a structured outcome before skipping. - if args.hardware and users: - resumed = bool(same_identity and status.get('state') == 'pending' and status.get('boot_id') != this_boot) - write_hardware_status(status_path, {'version': 1, 'state': 'running', 'identity': identity, - 'boot_id': this_boot, 'resume_boot_id': this_boot if resumed else ''}) - marker.with_name('hardware-result.json').unlink(missing_ok=True) - for account in users: - try: - if args.installer_target: - configure(account, args.offline, args.hardware, args.reconcile, args.automatic, - installer_target=True) - else: - configure(account, args.offline, args.hardware, args.reconcile, args.automatic) - except subprocess.CalledProcessError: - diagnostic = Path('/var/lib/xps-hardware/ipu7/abi-failed.log') - # The hardware role deliberately reuses a cached ABI diagnostic - # for an unchanged kernel; its timestamp need not be from this run. - if (args.hardware and diagnostic.is_file() - and 'IPU7_STOCK_ABI_CHANGED' in diagnostic.read_text() - and os.uname().release in diagnostic.read_text()): - unsupported.parent.mkdir(parents=True, exist_ok=True) - unsupported.write_text(identity) - if args.hardware: - write_hardware_status(status_path, {'version': 1, 'state': 'failed', 'identity': identity, - 'boot_id': this_boot, 'reason': 'hardware role failed; run cybex repair --hardware'}) - raise - if args.reconcile: - # Home traversal keeps the account's privileges and shares the - # same containment and serialization as baseline convergence. - contained(['/usr/sbin/runuser', '-u', account.pw_name, '--', '/usr/bin/env', '-i', - 'HOME=' + account.pw_dir, 'USER=' + account.pw_name, - 'PATH=/usr/bin:/bin', 'LANG=C.UTF-8', 'PYTHONDONTWRITEBYTECODE=1', - '/usr/libexec/cybexos-user-init', '--essential'], - env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', - 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'}, timeout=120) - if not args.hardware and not args.reconcile: - retire_legacy_fish(account) - # The Anaconda target helper decides login intent after verifying encryption. - if settings and not args.offline: - record_login(settings) - if args.hardware and users: - # The role result is a separate schema, so read it directly. - try: - result = json.loads(marker.with_name('hardware-result.json').read_text()) - except (OSError, ValueError): - result = None - if not isinstance(result, dict) or not isinstance(result.get('reboot_required'), bool): - write_hardware_status(status_path, {'version': 1, 'state': 'failed', 'identity': identity, - 'boot_id': this_boot, 'reason': 'hardware role did not report an outcome'}) - raise SystemExit('Hardware role did not report an outcome; run cybex repair --hardware') - pending_reboot = result['reboot_required'] - preserved = bool(result.get('preserved_previous') and not result.get('camera_ready')) - state = 'pending' if pending_reboot else 'failed' if preserved else 'completed' - reason = ('reboot required for camera validation' if pending_reboot else - 'previous camera stack retained after a failed refresh; run cybex repair --hardware' if preserved else '') - state, reason, nvidia = merge_nvidia(state, reason, result.get('nvidia')) - write_hardware_status(status_path, {'version': 1, 'state': state, - 'identity': identity, 'boot_id': this_boot, - 'reason': reason, - 'camera_ready': result.get('camera_ready', False), - 'preserved_previous': result.get('preserved_previous', False), - 'nvidia_state': nvidia.get('state', 'absent'), - 'nvidia_key': nvidia.get('key', '')}) - if state == 'completed': - pending = marker.with_suffix('.pending') - pending.write_text(identity) - pending.replace(marker) - unsupported.unlink(missing_ok=True) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/libexec/cybexos-doctor b/image/rootfs/usr/libexec/cybexos-doctor deleted file mode 100755 index 9796aed1..00000000 --- a/image/rootfs/usr/libexec/cybexos-doctor +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -"""Read-only installed-image health summary. Never probes capture hardware.""" -import argparse -import json -import os -from pathlib import Path -import re -import subprocess - - -def run(*argv): - try: - result = subprocess.run(argv, text=True, capture_output=True, timeout=8, check=False, - env={**os.environ, "LC_ALL": "C"}) - return result.returncode, result.stdout.strip() - except (OSError, subprocess.TimeoutExpired): - return 127, '' - - -def read_json(path): - try: - value = json.loads(path.read_text()) - return value if isinstance(value, dict) else {} - except (OSError, ValueError): - return {} - - -def collect(root=Path('/'), home=Path.home(), command=run): - def at(path): - return root / path.lstrip('/') - - checks = [] - def add(name, state, detail, action=''): - checks.append({'name': name, 'state': state, 'detail': detail, 'action': action}) - - build = read_json(at('/usr/share/cybexos/build.json')) - rc, package = command('rpm', '-q', 'cybexos-desktop') - if rc or not re.fullmatch(r'[0-9a-f]{40,64}', str(build.get('source_revision', ''))): - add('build', 'fail', 'Desktop package or build provenance is missing.', 'Reinstall the CybexOS desktop RPM.') - else: - add('build', 'ok', f"{package}; source {str(build['source_revision'])[:12]}") - - rc, channel_text = command('/usr/libexec/cybexos-update-channel', 'status', '--json') - try: - channel = json.loads(channel_text) if rc == 0 else {} - except ValueError: - channel = {} - if not isinstance(channel, dict): - channel = {} - if channel.get('status') == 'desktop-channel-ready': - add('desktop_channel', 'ok', 'Desktop update channel is enabled.') - elif channel.get('status') == 'desktop-channel-disabled': - add('desktop_channel', 'warning', 'Desktop update channel is not enrolled.', - 'Run cybex update-channel status for enrollment details.') - else: - add('desktop_channel', 'fail', 'Desktop update channel cannot be verified.', - 'Run cybex update-channel status.') - - rc, active = command('systemctl', '--user', 'is-active', 'quickshell.service') - add('quickshell', 'ok' if rc == 0 and active == 'active' else 'fail', - 'Quickshell is active.' if rc == 0 and active == 'active' else 'Quickshell is not active.', - '' if rc == 0 and active == 'active' else 'Run systemctl --user status quickshell.service.') - rc, errors = command('hyprctl', 'configerrors') - add('hyprland', 'ok' if rc == 0 and not errors else 'fail', - 'Hyprland configuration has no reported errors.' if rc == 0 and not errors else 'Hyprland reported configuration errors.', - '' if rc == 0 and not errors else 'Run hyprctl configerrors.') - - failed = [] - failed_query_ok = True - for args in (('systemctl', '--failed', '--plain', '--no-legend'), - ('systemctl', '--user', '--failed', '--plain', '--no-legend')): - rc, output = command(*args) - if rc == 0: - failed.extend(line.split()[0] for line in output.splitlines() if line.split()) - else: - failed_query_ok = False - add('failed_units', 'fail' if failed else 'ok' if failed_query_ok else 'warning', - 'Failed units: ' + ', '.join(failed[:8]) if failed else - 'No failed system or user units.' if failed_query_ok else 'Could not query all failed units.', - 'Run systemctl --failed and systemctl --user --failed.' if failed else '') - rc, qml_errors = command('journalctl', '--user', '-b', '-u', 'quickshell.service', '-p', 'warning', '--no-pager', '-o', 'cat') - # Report only a count. Journals may contain private window or widget data. - count = len([line for line in qml_errors.splitlines() if 'qml' in line.lower() or 'qrc:' in line.lower()]) if rc == 0 else 0 - add('qml', 'warning' if count or rc else 'ok', - f'{count} Quickshell QML error lines in this boot.' if count else - 'No Quickshell QML errors in this boot.' if rc == 0 else 'Could not read the Quickshell journal.') - - state_dir = home / '.local/state/cybexos' - seed = read_json(state_dir / 'seed-progress.json') - seed_state = seed.get('state', 'pending') - if (state_dir / 'offline-apps-seeded').is_file(): - seed_state = 'ready' - seed_level = {'ready': 'ok', 'copying': 'pending', 'pending': 'pending', 'error': 'fail'}.get(seed_state, 'warning') - add('seeded_apps', seed_level, - {'ready': 'Offline applications are ready.', 'copying': 'Offline applications are being prepared.', - 'pending': 'Offline applications are pending.', 'error': 'Offline application preparation failed.'}.get(seed_state, 'Offline application status is unknown.'), - 'Run cybex prepare-apps to retry.' if seed_level == 'fail' else '') - - rc, reconciliation_text = command('/usr/libexec/cybexos-reconcile', '--status') - try: - reconciliation = json.loads(reconciliation_text) if rc == 0 else {} - except ValueError: - reconciliation = {} - if not isinstance(reconciliation, dict): - reconciliation = {} - reconcile_state = reconciliation.get('state', 'unknown') - add('reconciliation', {'ready': 'ok', 'pending': 'pending', 'running': 'pending', - 'error': 'fail', 'blocked': 'fail'}.get(reconcile_state, 'warning'), - f'Installed policy reconciliation: {reconcile_state}.', - 'Run sudo /usr/libexec/cybexos-reconcile --retry.' if reconcile_state in ('error', 'blocked') else '') - - hardware_policy = read_json(at('/etc/cybexos/hardware.json')) - hardware = read_json(at('/var/lib/cybexos/hardware-status.json')) - hardware_state = hardware.get('state', 'pending') - if not hardware_policy.get('xps_2026') and not hardware_policy.get('nvidia') and not hardware: - hardware_state = 'skipped' - if at('/var/lib/cybexos/hardware-unsupported').exists(): - hardware_state = 'failed' - add('hardware', {'completed': 'ok', 'pending': 'pending', 'running': 'pending', - 'failed': 'fail', 'skipped': 'skipped'}.get(hardware_state, 'warning'), - f'Hardware setup: {hardware_state}.', - 'Reboot to validate the camera transaction.' if hardware_state == 'pending' and 'reboot' in hardware.get('reason', '') - else 'Restart to finish NVIDIA driver setup.' if hardware_state == 'pending' - and hardware.get('nvidia_state') in ('reboot', 'enroll') - else 'Run cybex repair --hardware.' if hardware_state == 'failed' else '') - - # The helper corrects a request recorded before the last restart. Never - # report the Secure Boot enrollment password here. - rc, nvidia_text = command('/usr/local/libexec/cybexos-nvidia', 'status') - try: - nvidia = json.loads(nvidia_text) if rc == 0 else {} - except ValueError: - nvidia = {} - if not isinstance(nvidia, dict): - nvidia = {} - nvidia_state = nvidia.get('state', 'absent') - if hardware_policy.get('nvidia') or nvidia_state != 'absent': - skipped = nvidia_state == 'enroll' and nvidia.get('key') != 'pending' - add('nvidia', 'fail' if skipped else {'ready': 'ok', 'reboot': 'pending', 'enroll': 'pending', - 'failed': 'fail'}.get(nvidia_state, 'pending'), - {'ready': f"NVIDIA driver {nvidia.get('driver_version', '')} is active.".replace(' ', ' '), - 'reboot': 'NVIDIA driver is installed; a restart switches to it.', - 'enroll': 'Secure Boot key enrollment is pending.' if not skipped - else 'The Secure Boot key was not enrolled; the open-source driver is in use.', - 'failed': 'NVIDIA driver setup failed; the open-source driver is in use.'}.get( - nvidia_state, 'NVIDIA driver setup has not run yet; it needs a network connection.'), - str(nvidia.get('reason', '')) if nvidia_state != 'ready' else '') - - rc, network = command('nmcli', '-t', '-f', 'STATE', 'general') - connected = rc == 0 and network.splitlines()[:1] == ['connected'] - add('network', 'ok' if connected else 'warning', - 'Network is connected.' if connected else 'Network is disconnected or still connecting.', - '' if connected else 'Open network settings to connect.') - - rc, recovery = command('systemctl', 'is-enabled', 'cybexos-recovery-refresh.service') - recovery_files = all(at(path).is_file() for path in ('/etc/grub.d/42_cybexos_recovery', - '/usr/lib/systemd/system/cybexos-recovery-refresh.service')) - add('recovery', 'ok' if recovery_files and rc == 0 and recovery == 'enabled' else 'warning', - 'Recovery refresh is installed and enabled.' if recovery_files and rc == 0 and recovery == 'enabled' - else 'Recovery refresh is not fully enabled.') - - if hardware_policy.get('xps_2026'): - rc, thermal = command('systemctl', 'is-active', 'thermald.service') - add('xps_thermal', 'ok' if rc == 0 and thermal == 'active' else 'warning', - 'XPS thermal service is active.' if rc == 0 and thermal == 'active' else 'XPS thermal service is not active.') - rc, speaker = command('systemctl', '--user', 'is-active', 'xps-speaker-tuning.service') - add('xps_audio', 'ok' if rc == 0 and speaker == 'active' else 'warning', - 'XPS speaker tuning is active.' if rc == 0 and speaker == 'active' else 'XPS speaker tuning is not active.') - - overall = 'fail' if any(item['state'] == 'fail' for item in checks) else \ - 'warning' if any(item['state'] in ('warning', 'pending') for item in checks) else 'ok' - return {'version': 1, 'overall': overall, 'checks': checks} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--json', action='store_true') - args = parser.parse_args() - result = collect() - if args.json: - print(json.dumps(result, sort_keys=True)) - else: - for item in result['checks']: - print(f"{item['state'].upper():7} {item['name']}: {item['detail']}") - if item['action']: - print(f" {item['action']}") - raise SystemExit(1 if result['overall'] == 'fail' else 0) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/libexec/cybexos-factory-reset b/image/rootfs/usr/libexec/cybexos-factory-reset deleted file mode 100755 index 3bcdbee6..00000000 --- a/image/rootfs/usr/libexec/cybexos-factory-reset +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/python3 -"""Return an ISO installation to its factory state for a new owner. - - reset interactive; run through `cybex factory-reset` (sudo) - finish at the next boot, from cybexos-first-boot.service - -`reset` restores the factory recovery point that the installation took at -its first start, after a typed confirmation. On an encrypted disk it first -replaces every disk passphrase with a temporary handover passphrase. At the -next start, `finish` removes every desktop account and everything in /home, -every other recovery point and set-aside system, and update checkpoints. It -keeps the installation's feature choices and hands the machine to the -new-owner setup. Nothing here can be undone. -""" -import contextlib -import getpass -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import shutil -import socket -import subprocess -import sys -import time - -import yaml - -LIBEXEC = Path(__file__).resolve().parent -INSTALLATION = Path('/etc/cybexos/installation.json') -MARKER = 'var/lib/cybexos/factory-reset.json' -TERMINAL = {'committed', 'aborted', 'rolled-back'} - - -class Failure(Exception): - """A refusal, shown without a traceback.""" - - -def load(name, filename): - loader = importlib.machinery.SourceFileLoader(name, str(LIBEXEC / filename)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def snapshot_module(): - return load('cybexos_snapshot', 'cybexos-system-snapshot') - - -def new_owner_module(): - return load('cybexos_new_owner', 'cybexos-new-owner') - - -def active_transactions(store): - found = [] - for path in sorted((store.path / 'transactions').glob('*/transaction.json')): - try: - state = json.loads(path.read_text()).get('state') - except (OSError, ValueError, AttributeError): - state = 'unreadable' - if state not in TERMINAL: - found.append(path.parent.name) - return found - - -def preflight(snapshot, installation=INSTALLATION): - """The factory point to restore, or the reason a reset is not possible.""" - if not installation.is_file(): - raise Failure('Factory reset is available only on a system installed from the CybexOS ISO.') - layout = snapshot.detect_layout() - if layout.kind == 'recovery': - raise Failure('This is a temporary recovery boot. Restart into the installed system first.') - if not layout.usable: - raise Failure(f'Factory reset needs the standard Btrfs layout ({layout.message or "not found"}).') - if layout.pending_reboot: - raise Failure('A restored recovery point is waiting for a restart. Restart first.') - with snapshot.opened_store(layout, create=False) as store: - point = store.factory_point() if store is not None else '' - if not point: - raise Failure('This installation has no factory recovery point. It is created at the first ' - 'start after a guided installation from the CybexOS ISO.') - if active_transactions(store): - raise Failure('An update has not finished. Let `cybex update` complete or recover it first.') - return layout, point - - -class Console: - def say(self, text=''): - print(text, flush=True) - - def ask(self, prompt): - return input(prompt) - - def secret(self, prompt): - return getpass.getpass(prompt) - - -WARNING = '''Factory reset returns this computer to the state it was installed in and -prepares it for a new owner. When it restarts, it permanently erases: - - - every user account and everything in /home: documents, settings, keys - - all other recovery points and replaced systems - - every change made to the system since installation - -Back up anything you want to keep first, for example with: cybex backup now -''' - - -def confirm(console, hostname): - console.say(WARNING) - answer = console.ask(f'To continue, type the computer name ({hostname}) or "erase": ').strip() - if answer not in (hostname, 'erase'): - raise Failure('The confirmation did not match. Nothing was changed.') - - -def handover(console, devices, run, new_owner): - """The current passphrase (to authorize) and the next owner's temporary one.""" - console.say('\nThis disk is encrypted. The next owner unlocks it once with a temporary') - console.say('passphrase you choose now, then replaces it with their own password.') - current = console.secret('Current disk passphrase: ') - if not all(new_owner.opens(device, current, run) for device in devices): - raise Failure('That passphrase does not unlock this disk. Nothing was changed.') - temporary = console.secret('Temporary passphrase for the next owner: ') - problem = new_owner.validate_password(temporary, console.secret('Confirm the temporary passphrase: ')) - if problem: - raise Failure(problem.replace('password', 'passphrase') + ' Nothing was changed.') - return current, temporary - - -def reset(console=None, run=subprocess.run, snapshot=None, new_owner=None, installation=INSTALLATION, - hostname=None): - if os.geteuid() != 0 and os.environ.get('CYBEXOS_FACTORY_RESET_TESTING') != '1': - raise Failure('Run this through `cybex factory-reset`.') - snapshot = snapshot or snapshot_module() - new_owner = new_owner or new_owner_module() - layout, point = preflight(snapshot, installation) - if console is None: - if not sys.stdin.isatty(): - raise Failure('Factory reset asks for a typed confirmation; run it in a terminal.') - console = Console() - confirm(console, hostname or socket.gethostname()) - devices = new_owner.luks_devices(run) - secrets = handover(console, devices, run, new_owner) if devices else None - console.say('\nResetting. Do not turn the computer off.') - if secrets: - for device in devices: - new_owner.rekey(device, secrets[0], secrets[1], run) - console.say('The disk now opens with the temporary passphrase.') - helper = LIBEXEC / 'cybexos-system-snapshot' - result = run([str(helper), 'restore', point], check=False) - if result.returncode != 0: - raise Failure('The factory state could not be restored; see the message above. ' - + ('The disk passphrase is already the temporary one. ' if secrets else '') - + 'Your accounts and files are unchanged.') - with snapshot.opened_store(layout, create=False) as store: - # Recorded inside the restored root, so only the reset system acts on it. - snapshot.atomic_write(store.top / 'root' / MARKER, - json.dumps({'version': 1, 'point': point, - 'requested': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}) + '\n', - 0o600) - console.say('\nThe factory state is ready. The next start erases all accounts and home folders,') - console.say('then opens the setup for the new owner.') - if console.ask('Restart now? [Y/n]: ').strip().lower() in ('', 'y', 'yes'): - run(['systemctl', 'reboot'], check=False) - return 0 - - -# ---- the next boot --------------------------------------------------------------- - -def device_of(path): - return path.lstat().st_dev - - -def remove_tree(path, run, device_of=device_of): - """Delete a directory tree, including Btrfs subvolumes nested in it. - - A nested subvolume (another st_dev) is deleted as a subvolume; anything - else on another device is refused, never descended into. - """ - if path.is_symlink() or not path.is_dir(): - path.unlink(missing_ok=True) - return - device = device_of(path) - nested = [] - for directory, names, _files in os.walk(path): - keep = [] - for name in names: - child = Path(directory) / name - if not child.is_symlink() and device_of(child) != device: - nested.append(child) - else: - keep.append(name) - names[:] = keep - for child in nested: - result = run(['btrfs', 'subvolume', 'delete', '--recursive', str(child)], check=False, - capture_output=True, timeout=600) - if result.returncode != 0: - raise Failure(f'{child} is a mount, not removable data; nothing below it was touched.') - shutil.rmtree(path) - - -def wipe_home(home, run, device_of=device_of): - """Everything in /home goes; /home itself (a subvolume) stays.""" - for entry in sorted(home.iterdir()): - if not entry.is_symlink() and entry.is_dir() and device_of(entry) != device_of(home): - result = run(['btrfs', 'subvolume', 'delete', '--recursive', str(entry)], check=False, - capture_output=True, timeout=600) - if result.returncode != 0: - raise Failure(f'{entry} is a mount, not removable data.') - continue - remove_tree(entry, run, device_of) - - -def desktop_accounts(root): - rows = [line.split(':') for line in (root / 'etc/passwd').read_text().splitlines() if line.count(':') >= 6] - return [row[0] for row in rows if 1000 <= int(row[2]) < 65534 and row[5].startswith('/home/')] - - -def clear_recovery_history(snapshot): - """Keep only the factory point: every other point holds the old owner's system.""" - layout = snapshot.detect_layout() - if not layout.usable: - return - with snapshot.opened_store(layout, create=False) as store: - if store is None: - return - factory = store.factory_point() - for point in store.ids(): - if point != factory: - snapshot.delete_point(store, point) - running = layout.running.lstrip('/') - for entry in sorted(store.top.iterdir()): - if snapshot.REPLACED_PATTERN.match(entry.name) and entry.name != running: - snapshot.run(['btrfs', 'subvolume', 'delete', '--recursive', str(entry)]) - for directory in (store.replaced, store.path / 'transactions'): - shutil.rmtree(directory, ignore_errors=True) - store.replaced.mkdir(parents=True, exist_ok=True) - if factory: - snapshot.atomic_write(store.path / 'latest', factory + '\n', 0o600) - snapshot.regenerate_quietly(layout, store) - - -def preserved_choices(root, policy): - """The feature choices of the reset installation, in config.yml's schema.""" - try: - saved = yaml.safe_load((root / 'etc/cybexos/config.yml').read_text()) - except (OSError, yaml.YAMLError): - return None, None - if not isinstance(saved, dict): - return None, None - selected = {key: saved[key] for key in policy.CHOICE_BOOLEANS if type(saved.get(key)) is bool} - features = saved.get('features') - if isinstance(features, dict): - selected['features'] = {key: value for key, value in features.items() - if key in policy.FEATURES and type(value) is bool} - wheel = saved.get('passwordless_wheel') - return policy.choices(selected), wheel if type(wheel) is bool else None - - -def installation_policy(): - sys.path.insert(0, '/usr/share/cybexos/lib') - import installation_policy as policy - return policy - - -def write(path, text, mode=0o644): - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name('.' + path.name + '.factory-reset') - temporary.write_text(text) - temporary.chmod(mode) - temporary.replace(path) - - -def finish(root=Path('/'), run=subprocess.run, snapshot=None, policy=None, device_of=device_of): - """Complete a reset in the restored root; returns whether one was pending.""" - marker = root / MARKER - if not marker.is_file(): - return False - snapshot = snapshot or snapshot_module() - policy = policy or installation_policy() - clear_recovery_history(snapshot) - wipe_home(root / 'home', run, device_of) - for name in desktop_accounts(root): - # Exit 12 only reports that the (already removed) home was missing. - run(['userdel', '--force', '--remove', name], check=False, capture_output=True, timeout=120) - for leftover in (root / 'var/lib/AccountsService/users' / name, root / 'var/lib/systemd/linger' / name): - leftover.unlink(missing_ok=True) - cybexos = root / 'etc/cybexos' - choices, wheel = preserved_choices(root, policy) - if choices is not None: - write(cybexos / 'install-choices.json', json.dumps({'version': 1, 'choices': choices}, sort_keys=True) + '\n') - try: - previous = json.loads((cybexos / 'oem.json').read_text()) - except (OSError, ValueError): - previous = {} - if wheel is None: - wheel = previous.get('passwordless_wheel', False) is True - write(cybexos / 'oem.json', json.dumps({'version': 1, 'passwordless_wheel': wheel}) + '\n') - write(cybexos / 'login.json', json.dumps({'version': 1, 'user': '', 'autologin': False, 'live': False}) + '\n') - (cybexos / 'config.yml').unlink(missing_ok=True) - for leftover in [root / 'etc/sudoers.d/10-wheel-nopasswd', root / 'etc/sddm.conf', - root / 'var/lib/sddm/state.conf', *sorted((root / 'etc/ssh').glob('ssh_host_*'))]: - leftover.unlink(missing_ok=True) - shutil.rmtree(root / 'var/lib/cybexos/first-boot', ignore_errors=True) - try: - details = json.loads((cybexos / 'installation.json').read_text()) - except (OSError, ValueError): - details = {} - details.update(username='', autologin=False, oem=True, - factoryReset=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())) - write(cybexos / 'installation.json', json.dumps(details) + '\n') - marker.unlink() - run(['restorecon', '-RF', '/etc/cybexos', '/home', '/var/lib/cybexos'], check=False, timeout=300) - return True - - -def main(argv): - if argv not in (['reset'], ['finish']): - print('Usage: cybexos-factory-reset reset|finish', file=sys.stderr) - return 2 - os.umask(0o077) - try: - if argv == ['finish']: - finish() - return 0 - return reset() - except Failure as error: - print(f'cybex factory-reset: {error}', file=sys.stderr) - return 1 - except (KeyboardInterrupt, EOFError): - print('\nCancelled.', file=sys.stderr) - return 1 - - -if __name__ == '__main__': - with contextlib.suppress(BrokenPipeError): - sys.exit(main(sys.argv[1:])) diff --git a/image/rootfs/usr/libexec/cybexos-first-boot b/image/rootfs/usr/libexec/cybexos-first-boot deleted file mode 100755 index 65f55c3a..00000000 --- a/image/rootfs/usr/libexec/cybexos-first-boot +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/python3 -"""Finish an installation, or a factory reset, at the start before any login. - -cybexos-first-boot.service runs this only when the installer or a factory -reset left work for this boot: - -1. A pending factory reset is completed (cybexos-factory-reset finish). -2. A seed's Tailscale auth key moves from the disk to memory-backed /run, - so it is never part of a recovery point and is gone after this boot. -3. The installation's factory recovery point is taken once, outside the - normal five-point rotation. - -`tailscale` (cybexos-tailscale-enroll.service, started from step 2) joins -the tailnet once with that key and deletes it, whether or not it worked. -""" -import importlib.machinery -import importlib.util -import os -from pathlib import Path -import shutil -import subprocess -import sys - -LIBEXEC = Path(__file__).resolve().parent -STATE = Path('/var/lib/cybexos') -RUNTIME = Path('/run/cybexos/first-boot') -PENDING = 'factory-point.pending' -SECRETS = 'first-boot' -DESCRIPTION = 'factory state' - - -def log(message): - print(f'cybexos-first-boot: {message}', file=sys.stderr, flush=True) - - -def load(name, filename): - loader = importlib.machinery.SourceFileLoader(name, str(LIBEXEC / filename)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def move_secrets(state=STATE, runtime=RUNTIME): - """Move staged one-time credentials off the disk; return what moved.""" - source = state / SECRETS - if not source.is_dir(): - return [] - runtime.mkdir(mode=0o700, parents=True, exist_ok=True) - os.chmod(runtime, 0o700) - moved = [] - for path in sorted(source.iterdir()): - if path.is_file() and not path.is_symlink(): - target = runtime / path.name - descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, 'wb') as stream: - stream.write(path.read_bytes()) - moved.append(path.name) - path.unlink() - shutil.rmtree(source, ignore_errors=True) - return moved - - -def factory_point(snapshot, state=STATE, run=subprocess.run): - """Take the factory recovery point once; return what happened.""" - marker = state / PENDING - if not marker.is_file(): - return 'none' - layout = snapshot.detect_layout() - if not layout.usable or layout.pending_reboot: - # Without the managed Btrfs layout there are no recovery points at all. - log(f'no factory recovery point: {layout.message or "the root is not ready"}') - marker.unlink() - return 'unsupported' - with snapshot.opened_store(layout, create=False) as store: - existing = store.factory_point() if store is not None else '' - if existing: - # A factory reset restored the point; it still carries this marker. - marker.unlink() - return 'exists' - result = run([str(LIBEXEC / 'cybexos-system-snapshot'), 'create', '--factory', DESCRIPTION], check=False) - if result.returncode != 0: - # Kept for the next start; the unit's failure is visible meanwhile. - raise RuntimeError('the factory recovery point could not be created; it is retried at the next start') - marker.unlink() - return 'created' - - -def enroll_tailscale(runtime=RUNTIME, run=subprocess.run): - key = runtime / 'tailscale-authkey' - if not key.is_file(): - return False - try: - # file: keeps the key off every command line. - result = run(['tailscale', 'up', f'--auth-key=file:{key}', '--timeout=120s'], check=False, timeout=300) - finally: - key.unlink(missing_ok=True) - if result.returncode != 0: - log('Tailscale enrollment failed; the one-time key was deleted. Run `sudo tailscale up` to join manually.') - return result.returncode == 0 - - -def main(argv=None, run=subprocess.run): - arguments = sys.argv[1:] if argv is None else argv - if arguments == ['tailscale']: - return 0 if enroll_tailscale(run=run) else 1 - if arguments: - print('Usage: cybexos-first-boot [tailscale]', file=sys.stderr) - return 2 - if os.geteuid() != 0: - raise SystemExit('cybexos-first-boot must run as root.') - os.umask(0o077) - try: - if (STATE / 'factory-reset.json').is_file(): - if load('cybexos_factory_reset', 'cybexos-factory-reset').finish(): - log('factory reset completed; the new-owner setup starts next') - moved = move_secrets() - status = factory_point(load('cybexos_snapshot', 'cybexos-system-snapshot'), run=run) - except Exception as error: - # Markers stay in place, so the next start retries; the unit shows failed. - log(f'{type(error).__name__}: {error}') - return 1 - if status == 'created': - log('took the factory recovery point') - if 'tailscale-authkey' in moved: - run(['systemctl', 'start', '--no-block', 'cybexos-tailscale-enroll.service'], check=False) - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-new-owner b/image/rootfs/usr/libexec/cybexos-new-owner deleted file mode 100755 index 720ff1d2..00000000 --- a/image/rootfs/usr/libexec/cybexos-new-owner +++ /dev/null @@ -1,992 +0,0 @@ -#!/usr/bin/python3 -"""Set up a computer prepared for a new owner (OEM mode), before any login. - -An installation made with "Prepare for a new owner", an unattended seed -with `oem: true`, or `cybex factory-reset` leaves /etc/cybexos/oem.json and -no desktop account. cybexos-new-owner.service runs this on the first console -before the login screen starts. The owner chooses a language, keyboard, -account, password and time zone. On an encrypted disk the password also -replaces the temporary disk passphrase: the new passphrase is added, every -other key slot and token is removed, and the old passphrase is checked to no -longer open the disk. The account receives the same provisioning as an -interactive installation, the OEM state is removed, and the computer -restarts into the normal first login and welcome window. - -The questions are asked by a graphical window (cybexos-new-owner-gui) shown -in cage. The window runs as an unprivileged transient user and only returns -answers, as JSON lines on a pipe; this root process checks every answer again -and does all the work. When cage or the window cannot start, stops answering -or breaks the protocol, the same questions are asked as text on this console. - -No password is written to a file, logged, passed in the environment or on a -command line. -""" -import contextlib -import fcntl -import getpass -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import pwd -import re -import select -import struct -import subprocess -import sys -import termios -import time -import unicodedata - -OEM = Path('/etc/cybexos/oem.json') -LIBEXEC = Path(__file__).resolve().parent -CONFIGURE = '/usr/libexec/cybexos-configure-installed' -SEED_USERS = '/usr/libexec/cybexos-seed-installed-users' -RESERVED = {'root', 'liveuser', 'bin', 'daemon', 'adm', 'mail', 'ftp', 'nobody', - 'dbus', 'systemd', 'gdm', 'sddm', 'sshd', 'polkitd', 'chrony'} -USERNAME = re.compile(r'[a-z_][a-z0-9_-]{0,30}') -# `layout` or `layout (variant)`, as the installer and this console write it. -KEYBOARD = re.compile(r'([A-Za-z0-9_-]+)(?: \(([A-Za-z0-9_-]+)\))?') -XKB_RULES = Path('/usr/share/X11/xkb/rules/evdev.lst') -# Written before the account exists and removed with the OEM state, so an -# attempt cut short by a crash or power loss is undone at the next start. -PENDING = 'etc/cybexos/new-owner-pending.json' - -# The graphical window and the program that shows it. -GUI = LIBEXEC / 'cybexos-new-owner-gui' -QML = LIBEXEC.parent / 'share/cybexos/new-owner/Main.qml' -CAGE = Path('/usr/bin/cage') -SYSTEMD_RUN = Path('/usr/bin/systemd-run') -GUI_UNIT = 'cybexos-new-owner-gui.service' -GUI_RUNTIME = '/run/cybexos-new-owner-gui' -CONSOLE_ONLY = 'cybexos.new-owner=console' -# A window that has not drawn by then has failed to start; one that never -# sees a key press is not usable (the console keyboard still is). -READY_TIMEOUT = 60 -INPUT_TIMEOUT = 300 -START_ATTEMPTS = 2 -# The longest line the window may send: generous for 512-character -# passwords escaped as JSON, far below anything that could exhaust memory. -MAX_MESSAGE = 32768 -# What the window may say, with the longest text each field may hold. -MESSAGES = { - 'ready': {}, - 'active': {}, - 'restart': {}, - 'keyboard': {'keyboard': 80, 'locale': 80}, - 'submit': {'locale': 80, 'timezone': 80, 'full_name': 200, 'username': 64, - 'password': 600, 'confirm': 600, 'startup': 600}, -} -# English text for the problem codes the window translates itself. -PROBLEMS = { - 'full-name': 'Use at most 128 characters, without colons.', - 'username-format': 'Use 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter.', - 'username-reserved': 'That name is reserved for the system.', - 'username-taken': 'That name is already in use.', - 'password-length': 'Use a password of 12–512 characters.', - 'password-control': 'The password cannot contain control characters.', - 'password-mismatch': 'The passwords do not match.', - 'startup-wrong': 'That passphrase does not unlock this disk.', - 'boot-keymap': 'This layout cannot be used at the disk unlock screen. Choose another layout.', - 'choice': 'Choose an item from the list.', -} - - -class SetupError(Exception): - """A step failed; the message is safe to show and holds no secret.""" - - -class Unavailable(Exception): - """The graphical setup cannot be used; the console asks instead.""" - - -class NotStarted(Unavailable): - """The window never appeared; a second start may still work.""" - - -class ProtocolError(Unavailable): - """The window sent something it never would; it is not trusted further.""" - - -class FieldError(Exception): - """An answer the window should ask for again; carries no answer text.""" - - def __init__(self, field, code): - super().__init__(f'{field}: {code}') - self.field, self.code = field, code - - -def log(message): - # The unit sends standard error to the journal, never to the console. - print(f'cybexos-new-owner: {message}', file=sys.stderr, flush=True) - - -def load(name, filename): - loader = importlib.machinery.SourceFileLoader(name, str(LIBEXEC / filename)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def assignments(path): - """KEY=value pairs of a systemd file such as vconsole.conf.""" - try: - lines = path.read_text().splitlines() - except OSError: - return {} - values = {} - for line in lines: - key, separator, value = line.strip().partition('=') - if separator and not key.startswith('#'): - values[key.strip()] = value.strip().strip('"\'') - return values - - -def output(run, command): - return run(command, check=True, capture_output=True, text=True, timeout=60).stdout - - -# ---- disk encryption ----------------------------------------------------------- - -def luks_devices(run=subprocess.run): - """LUKS devices backing the root filesystem, every Btrfs member included.""" - login = load('cybexos_login_prepare', 'cybexos-login-prepare') - mounts = json.loads(output(run, ['findmnt', '--json', '--mountpoint', '/', '--output', - 'SOURCE,TARGET,FSTYPE']))['filesystems'] - source = mounts[0]['source'].split('[', 1)[0] - members = (login.btrfs_members(output(run, ['btrfs', 'filesystem', 'show', '--raw', '/'])) - if mounts[0]['fstype'] == 'btrfs' else [source]) - found = [] - - def visit(nodes, below_crypt): - for node in nodes: - if below_crypt and node.get('fstype') == 'crypto_LUKS' and node.get('path') not in found: - found.append(node['path']) - visit(node.get('children', []), node.get('type') == 'crypt') - - for member in members: - tree = json.loads(output(run, ['lsblk', '--inverse', '--json', '--output', 'PATH,TYPE,FSTYPE', member])) - visit(tree['blockdevices'], False) - return found - - -def opens(device, passphrase, run=subprocess.run, slot=None): - command = ['cryptsetup', 'open', '--test-passphrase', '--key-file=-'] - command += [f'--key-slot={slot}'] if slot is not None else [] - result = run(command + [device], input=passphrase.encode(), capture_output=True, timeout=120, check=False) - return result.returncode == 0 - - -def metadata(device, run=subprocess.run): - """Active LUKS2 key slots and tokens, from the header's JSON metadata.""" - document = json.loads(output(run, ['cryptsetup', 'luksDump', '--dump-json-metadata', device])) - return sorted(document.get('keyslots', {}), key=int), sorted(document.get('tokens', {}), key=int) - - -def rekey(device, current, new, run=subprocess.run): - """Leave exactly one key slot, opened by `new`, and no tokens. - - Re-running after an interruption is safe: an existing `new` slot is kept - and the remaining slots are removed. The volume key itself is unchanged. - """ - if current != new and not opens(device, new, run): - reader, writer = os.pipe() - try: - os.write(writer, new.encode()) - os.close(writer) - writer = None - result = run(['cryptsetup', 'luksAddKey', '--batch-mode', '--key-file=-', device, f'/dev/fd/{reader}'], - input=current.encode(), pass_fds=(reader,), capture_output=True, timeout=300, check=False) - finally: - os.close(reader) - if writer is not None: - os.close(writer) - if result.returncode != 0: - raise SetupError(f'The new passphrase could not be added to {device}. Nothing was removed.') - slots, tokens = metadata(device, run) - keep = next((slot for slot in slots if opens(device, new, run, slot)), None) - if keep is None: - raise SetupError(f'The new passphrase does not open {device}. Nothing was removed.') - for slot in slots: - if slot != keep: - result = run(['cryptsetup', 'luksKillSlot', '--batch-mode', '--key-file=-', device, slot], - input=new.encode(), capture_output=True, timeout=120, check=False) - if result.returncode != 0: - raise SetupError(f'Key slot {slot} of {device} could not be removed.') - for token in tokens: - run(['cryptsetup', 'token', 'remove', '--token-id', token, device], - capture_output=True, timeout=60, check=True) - if current != new and opens(device, current, run): - raise SetupError(f'The previous passphrase still opens {device}.') - - -def initramfs_keymap(root=Path('/'), run=subprocess.run): - """The console keymap in the running kernel's initramfs, or None.""" - image = root / 'boot' / f'initramfs-{os.uname().release}.img' - try: - result = run(['lsinitrd', '--file', 'etc/vconsole.conf', str(image)], - capture_output=True, text=True, timeout=120, check=False) - except (OSError, subprocess.SubprocessError): - return None - if result.returncode != 0: - return None - values = dict(line.strip().partition('=')[::2] for line in result.stdout.splitlines() if '=' in line) - return values.get('KEYMAP', '').strip().strip('"\'') - - -def refresh_boot_keymap(root=Path('/'), run=subprocess.run): - """Rebuild the initramfs when its keymap differs from the chosen one. - - The disk unlock prompt uses the keymap inside the initramfs. Once the - owner's password is the only passphrase, it must be typed with the - layout it was chosen with, so this runs before the re-key. - """ - wanted = assignments(root / 'etc/vconsole.conf').get('KEYMAP', '') - if initramfs_keymap(root, run) == wanted: - return False - run(['dracut', '--regenerate-all', '--force'], check=True, timeout=1800) - return True - - -# ---- the rules every answer follows -------------------------------------------- - -def password_problem(password, confirm): - if not 12 <= len(password) <= 512: - return 'password-length' - if any(ord(char) < 32 or ord(char) == 127 for char in password): - return 'password-control' - if password != confirm: - return 'password-mismatch' - return '' - - -def validate_password(password, confirm): - problem = password_problem(password, confirm) - return PROBLEMS[problem] if problem else '' - - -def full_name_problem(full_name): - if len(full_name) <= 128 and ':' not in full_name and not any(ord(char) < 32 for char in full_name): - return '' - return 'full-name' - - -def username_problem(username, taken=()): - if not USERNAME.fullmatch(username): - return 'username-format' - if username in RESERVED: - return 'username-reserved' - if username in taken: - return 'username-taken' - return '' - - -def derive_username(full_name): - """A suggestion from the first word of a full name: "Jörg Smit" → "jorg".""" - plain = unicodedata.normalize('NFKD', full_name).encode('ascii', 'ignore').decode().lower() - first = next(iter(plain.split()), '') - return re.sub(r'^[^a-z_]+', '', re.sub(r'[^a-z0-9_-]', '', first))[:31] - - -def existing_names(passwd=Path('/etc/passwd')): - return {line.split(':', 1)[0] for line in passwd.read_text().splitlines() if line} - - -def split_keyboard(value): - match = KEYBOARD.fullmatch(value) if isinstance(value, str) else None - return (match[1], match[2] or '') if match else None - - -def keyboard_choices(rules=None, run=subprocess.run): - """Layouts and their variants as `layout`/`layout (variant)` with labels.""" - try: - text = (rules or XKB_RULES).read_text() - except OSError: - text = '' - section, layouts, variants = '', {}, [] - for line in text.splitlines(): - if line.startswith('! '): - section = line[2:].strip() - continue - name, _, description = line.strip().partition(' ') - description = description.strip() - if section == 'layout' and name and description: - layouts[name] = description - elif section == 'variant' and ':' in description: - layout, _, label = description.partition(':') - if layout in layouts and split_keyboard(f'{layout} ({name})'): - variants.append({'id': f'{layout} ({name})', 'label': label.strip()}) - entries = [{'id': name, 'label': label} for name, label in layouts.items() if split_keyboard(name)] + variants - if not entries: - # Without xkeyboard-config's list, localed's own names stand in. - entries = [{'id': name, 'label': name} - for name in output(run, ['localectl', 'list-x11-keymap-layouts']).split() if split_keyboard(name)] - return sorted(entries, key=lambda entry: (entry['label'].casefold(), entry['id'])) - - -def locale_choices(run=subprocess.run): - """The installed UTF-8 locales, the languages the installer offers.""" - return [name for name in output(run, ['localectl', 'list-locales']).split() - if '.utf8' in name.lower().replace('-', '') and not name.startswith('C.')] - - -def timezone_choices(run=subprocess.run): - zones = output(run, ['timedatectl', 'list-timezones']).split() - return sorted(set(zones) | {'UTC'}) - - -def current_settings(root=Path('/'), config=None): - """The installation's keyboard, language and time zone, as defaults.""" - config = config or load('cybexos_config', 'cybexos-config') - layout, variant = config.keyboard(root) - keyboard = f'{layout} ({variant})' if layout and variant else layout - locale = assignments(root / 'etc/locale.conf').get('LANG', '') - try: - timezone = os.readlink(root / 'etc/localtime').partition('zoneinfo/')[2] - except OSError: - timezone = '' - return dict(keyboard=keyboard, locale=locale, timezone=timezone) - - -# ---- the console questions ------------------------------------------------------- - -class Console: - """Terminal input and output; tests replace it with scripted answers.""" - - def say(self, text=''): - print(text, flush=True) - - def ask(self, prompt): - return input(prompt) - - def secret(self, prompt): - return getpass.getpass(prompt) - - -def pick(console, title, values, default, optional=False): - """Choose from a long list by typing a value or the start of one.""" - while True: - answer = console.ask(f'{title} [{default or "none"}]: ').strip() or default - if answer in values or (optional and answer == ''): - return answer - matches = [value for value in values if value.lower().startswith(answer.lower())][:40] - if len(matches) == 1: - return matches[0] - console.say(' ' + (' '.join(matches) if matches else 'No match. Type the start of a name.')) - - -def collect(console, run=subprocess.run, devices=(), passwd=Path('/etc/passwd')): - """Ask the questions; keyboard first, so every later answer is typed with it.""" - console.say('\nWelcome to CybexOS. Set up this computer for its new owner.\n') - layouts = output(run, ['localectl', 'list-x11-keymap-layouts']).split() - layout = pick(console, '1/4 Keyboard layout, for example us, de or nl', layouts, 'us') - listed = run(['localectl', 'list-x11-keymap-variants', layout], capture_output=True, text=True, - timeout=60, check=False) - variants = listed.stdout.split() if listed.returncode == 0 else [] - variant = pick(console, ' Variant, for example dvorak', variants, '', optional=True) if variants else '' - # localed also converts the layout to the console keymap and applies it now. - run(['localectl', 'set-x11-keymap', layout, '', variant], check=True, timeout=60) - keyboard = f'{layout} ({variant})' if variant else layout - locales = output(run, ['localectl', 'list-locales']).split() - locale = pick(console, '2/4 Language, for example en_US.UTF-8 or nl_NL.UTF-8', locales, 'en_US.UTF-8') - console.say('\n3/4 Your account. You will use it to sign in and to run administrator commands.') - full_name = '' - while True: - full_name = console.ask('Full name (optional): ').strip() - if not full_name_problem(full_name): - break - console.say(' ' + PROBLEMS['full-name']) - taken = existing_names(passwd) - while True: - username = console.ask('Username: ').strip() - if not username_problem(username, taken): - break - console.say(' Use 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter. ' - 'System names are reserved.') - while True: - password = console.secret('Password: ') - problem = validate_password(password, console.secret('Confirm password: ')) - if not problem: - break - console.say(' ' + problem) - startup = '' - if devices: - console.say('Your password also unlocks the disk at startup. To replace the temporary disk') - console.say('passphrase, type the passphrase you used to start this computer.') - for _attempt in range(3): - startup = console.secret('Passphrase used at startup: ') - if all(opens(device, startup, run) or opens(device, password, run) for device in devices): - break - console.say(' That passphrase does not unlock this disk.') - else: - raise SetupError('The startup passphrase was not accepted. Starting again.') - zones = output(run, ['timedatectl', 'list-timezones']).split() - timezone = pick(console, '4/4 Timezone, for example Europe/Amsterdam', zones + ['UTC'], 'UTC') - return dict(keyboard=keyboard, locale=locale, timezone=timezone, full_name=full_name, - username=username, password=password, startup=startup) - - -def summary(console, answers, encrypted): - console.say('\nReady to set up:') - console.say(f" Keyboard {answers['keyboard']}") - console.say(f" Language {answers['locale']}") - console.say(f" Timezone {answers['timezone']}") - console.say(f" Account {answers['username']}" + (f" ({answers['full_name']})" if answers['full_name'] else '')) - if encrypted: - console.say(' Disk your password replaces the temporary passphrase') - return console.ask('Create this account? [y/N]: ').strip().lower() in ('y', 'yes') - - -# ---- applying them ----------------------------------------------------------------- - -def read_state(path=OEM): - value = json.loads(path.read_text()) - if not isinstance(value, dict) or value.get('version') != 1 or type(value.get('passwordless_wheel', False)) is not bool: - raise SetupError('The new-owner state is not valid.') - return value - - -def write_text(path, text, mode=0o644): - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name('.' + path.name + '.new-owner') - temporary.write_text(text) - temporary.chmod(mode) - temporary.replace(path) - - -def discard_account(username, saved_choices, root=Path('/'), run=subprocess.run): - """Undo a half-created account so the next attempt starts clean.""" - pending = root / 'etc/cybexos/install-choices.json' - run(['userdel', '--force', '--remove', username], check=False, timeout=120) - (root / 'var/lib/AccountsService/users' / username).unlink(missing_ok=True) - (root / 'etc/cybexos/config.yml').unlink(missing_ok=True) - if saved_choices is not None and not pending.exists(): - write_text(pending, saved_choices) - - -def recover_interrupted(root=Path('/'), run=subprocess.run, passwd=None): - """Remove an account a crash or power loss left half-provisioned. - - apply() records the account before creating it; only a finished setup - removes the OEM state, so this runs while the setup is still pending. - """ - marker = root / PENDING - if not marker.is_file() or not (root / 'etc/cybexos/oem.json').is_file(): - return '' - try: - record = json.loads(marker.read_text()) - except (OSError, ValueError): - record = {} - username = record.get('username') if isinstance(record, dict) else None - saved = record.get('choices') if isinstance(record, dict) else None - if isinstance(username, str) and USERNAME.fullmatch(username) and username not in RESERVED: - try: - entry = passwd(username) if passwd else pwd.getpwnam(username) - except KeyError: - entry = None - # Only an ordinary account in /home; never a system account. - if entry is None or (entry.pw_uid >= 1000 and entry.pw_dir == f'/home/{username}'): - discard_account(username, saved if isinstance(saved, str) else None, root, run) - log('removed an account an interrupted setup left behind') - else: - username = '' - marker.unlink(missing_ok=True) - return username or '' - - -def apply(answers, state, devices, run=subprocess.run, root=Path('/'), encrypted_root=None, config=None, - progress=None): - """Create and provision the account, then move the disk to its password. - - Any failure removes the new account again, so the next attempt starts - clean. Re-keying is the last disk step and is safe to repeat. `progress` - hears each stage: account, desktop, disk (encrypted only) and finish. - """ - progress = progress or (lambda _stage: None) - username = answers['username'] - pending = root / 'etc/cybexos/install-choices.json' - # Provisioning consumes the saved choices; a failed attempt restores them. - saved_choices = pending.read_text() if pending.is_file() else None - progress('account') - run(['localectl', 'set-locale', f"LANG={answers['locale']}"], check=True, timeout=60) - run(['timedatectl', 'set-timezone', answers['timezone']], check=True, timeout=60) - marker = root / PENDING - write_text(marker, json.dumps({'version': 1, 'username': username, 'choices': saved_choices}) + '\n', 0o600) - try: - run(['useradd', '--create-home', '--groups', 'wheel', '--comment', answers['full_name'], username], - check=True, timeout=120) - except BaseException: - marker.unlink(missing_ok=True) - raise - try: - # chpasswd hashes with the system's configured method (yescrypt). - run(['chpasswd'], input=f"{username}:{answers['password']}\n", text=True, check=True, timeout=60) - # The installer's post-install hook writes the same session record. - write_text(root / 'var/lib/AccountsService/users' / username, - '[User]\nSession=hyprland-quickshell\nXSession=hyprland-quickshell\nSystemAccount=false\n', 0o600) - # Fresh-account provisioning, exactly as Anaconda's in-target hook runs it: - # config.yml is recorded from the installation's saved choices first. - progress('desktop') - run([CONFIGURE, '--offline', '--user', username], check=True) - run([SEED_USERS], check=True) - sudo = root / 'etc/sudoers.d/10-wheel-nopasswd' - if state.get('passwordless_wheel'): - write_text(sudo, '%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n', 0o440) - else: - sudo.unlink(missing_ok=True) - config = config or load('cybexos_config', 'cybexos-config') - if devices: - progress('disk') - # The unlock prompt must read the password with the chosen layout - # before it becomes the only passphrase. - refresh_boot_keymap(root, run) - for device in devices: - rekey(device, answers['startup'], answers['password'], run) - progress('finish') - if encrypted_root is None: - encrypted_root = load('cybexos_login_prepare', 'cybexos-login-prepare').encrypted_root - # As at installation: automatic login only on a verified encrypted root. - autologin = bool(devices) and encrypted_root() is True - values = config.current(root) - values.update(desktop_autologin=autologin, passwordless_wheel=state.get('passwordless_wheel', False)) - config.write(config.render(values), root / 'etc/cybexos/config.yml') - write_text(root / 'etc/cybexos/login.json', - json.dumps({'version': 1, 'user': username, 'autologin': autologin, 'live': False}) + '\n') - except BaseException: - # A half-created account must not survive into the next attempt. - discard_account(username, saved_choices, root, run) - marker.unlink(missing_ok=True) - raise - record = root / 'etc/cybexos/installation.json' - try: - details = json.loads(record.read_text()) - except (OSError, ValueError): - details = {} - details.update(username=username, autologin=autologin, oem=False, - passwordless_wheel=state.get('passwordless_wheel', False), - passwordsInitiallyShared=bool(devices), - newOwnerSetup=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())) - write_text(record, json.dumps(details) + '\n') - # The OEM state goes last: until here, the next start asks again. - (root / 'etc/cybexos/oem.json').unlink(missing_ok=True) - marker.unlink(missing_ok=True) - run(['restorecon', '-RF', '/etc/cybexos', '/etc/sudoers.d', '/var/lib/AccountsService/users', - f'/home/{username}'], check=False, timeout=300) - answers.pop('password', None) - answers.pop('startup', None) - return autologin - - -# ---- the graphical window ------------------------------------------------------- - -# linux/kd.h: the console's display and keyboard modes. -KDSETMODE, KD_TEXT, KD_GRAPHICS = 0x4B3A, 0x00, 0x01 -KDGKBMODE, KDSKBMODE, K_OFF, K_UNICODE = 0x4B44, 0x4B45, 0x04, 0x03 - - -@contextlib.contextmanager -def graphics_mode(fd=0, ioctl=fcntl.ioctl, flush=termios.tcflush): - """Hand the first console's screen and keyboard to the compositor. - - seatd or logind would do this for a login session; the window runs - without one. While the console is in graphics mode with its keyboard - off, nothing typed in the window (a password included) reaches the - console's input buffer. Both are restored afterwards, even after a crash. - """ - try: - saved = struct.unpack('i', ioctl(fd, KDGKBMODE, struct.pack('i', 0)))[0] - ioctl(fd, KDSETMODE, KD_GRAPHICS) - except OSError as error: - raise Unavailable('the first console is not a virtual terminal') from error - try: - ioctl(fd, KDSKBMODE, K_OFF) - yield - finally: - for request, value in ((KDSKBMODE, saved if saved != K_OFF else K_UNICODE), (KDSETMODE, KD_TEXT)): - with contextlib.suppress(OSError): - ioctl(fd, request, value) - with contextlib.suppress(OSError, termios.error): - flush(fd, termios.TCIFLUSH) - - -def parse_message(line): - """One line from the window as a dict, or ProtocolError. Never echoes it.""" - if len(line) > MAX_MESSAGE: - raise ProtocolError('the window sent an oversized message') - try: - message = json.loads(line.decode('utf-8')) - except (UnicodeDecodeError, ValueError): - raise ProtocolError('the window sent a malformed message') from None - if not isinstance(message, dict) or message.get('type') not in MESSAGES: - raise ProtocolError('the window sent an unknown message') - fields = MESSAGES[message['type']] - if set(message) != {'type', *fields}: - raise ProtocolError(f'the window sent an incomplete {message["type"]} message') - for name, limit in fields.items(): - if not isinstance(message[name], str) or len(message[name]) > limit: - raise ProtocolError(f'the window sent an invalid {message["type"]} message') - return message - - -class LineReader: - """Messages from a file descriptor, one JSON object per line.""" - - def __init__(self, fd, limit=MAX_MESSAGE, wait=select.select, read=os.read, clock=time.monotonic): - self.fd, self.limit, self.wait, self.read, self.clock = fd, limit, wait, read, clock - self.buffer = b'' - - def receive(self, timeout=None): - """The next message, None after `timeout` seconds, EOFError at the end.""" - deadline = None if timeout is None else self.clock() + timeout - while b'\n' not in self.buffer: - if len(self.buffer) > self.limit: - raise ProtocolError('the window sent an oversized message') - remaining = None if deadline is None else deadline - self.clock() - if remaining is not None and remaining <= 0: - return None - if not self.wait([self.fd], [], [], remaining)[0]: - return None - chunk = self.read(self.fd, 4096) - if not chunk: - raise EOFError - self.buffer += chunk - line, _, self.buffer = self.buffer.partition(b'\n') - return parse_message(line) - - -def gui_command(keyboard): - """systemd-run for cage and the window as an unprivileged transient user. - - DynamicUser gives a fresh user that owns nothing on the disk, with a - read-only system, private /tmp and no new privileges. The video, input - and render groups open the screen and keyboard (libseat's noop backend: - there is no login session yet). cage reads the keyboard layout from the - XKB variables when it starts, so a new layout means a new session. The - answers travel on the inherited standard input and output, never in - this command or its environment. - """ - layout, variant = split_keyboard(keyboard) or ('us', '') - environment = { - 'XDG_RUNTIME_DIR': GUI_RUNTIME, 'HOME': GUI_RUNTIME, - 'XDG_CACHE_HOME': f'{GUI_RUNTIME}/cache', 'XDG_CONFIG_HOME': f'{GUI_RUNTIME}/config', - 'LIBSEAT_BACKEND': 'noop', 'XKB_DEFAULT_LAYOUT': layout, - # Software rendering works on every graphics device, before any driver - # setup; the window is simple enough not to need more. - 'QT_QPA_PLATFORM': 'wayland', 'QT_QUICK_BACKEND': 'software', - 'QT_WAYLAND_DISABLE_WINDOWDECORATION': '1', 'LANG': 'C.UTF-8', - } - if variant: - environment['XKB_DEFAULT_VARIANT'] = variant - properties = ['DynamicUser=yes', 'SupplementaryGroups=video input render', - f'RuntimeDirectory={Path(GUI_RUNTIME).name}', 'RuntimeDirectoryMode=0700', 'UMask=0077', - 'RestrictAddressFamilies=AF_UNIX AF_NETLINK', 'NoNewPrivileges=yes'] - return [str(SYSTEMD_RUN), '--quiet', '--wait', '--pipe', '--collect', f'--unit={GUI_UNIT}', - '--description=CybexOS setup window for a new owner', - *(f'--property={item}' for item in properties), - *(f'--setenv={name}={value}' for name, value in environment.items()), - '--', str(CAGE), '-d', '--', '/usr/bin/python3', '-I', str(GUI)] - - -class Session: - """One run of cage and the setup window, talking over two pipes.""" - - def __init__(self, keyboard, run=subprocess.run, popen=subprocess.Popen, terminal=graphics_mode): - self.keyboard, self.run, self.popen, self.terminal = keyboard, run, popen, terminal - self.process = self.reader = self.mode = None - - def start(self): - for verb in ('stop', 'reset-failed'): - self.run(['systemctl', verb, GUI_UNIT], check=False, capture_output=True, timeout=60) - mode = self.terminal() - mode.__enter__() - self.mode = mode - try: - self.process = self.popen(gui_command(self.keyboard), stdin=subprocess.PIPE, stdout=subprocess.PIPE) - except OSError as error: - self.stop() - raise NotStarted('systemd-run could not be started') from error - except BaseException: - self.stop() - raise - with contextlib.suppress(OSError, AttributeError): - # The first message carries every list; never block on it. - fcntl.fcntl(self.process.stdin.fileno(), fcntl.F_SETPIPE_SZ, 1 << 20) - self.reader = LineReader(self.process.stdout.fileno()) - - def send(self, message): - """Tell the window something; it may already be gone.""" - if self.process is None: - return - try: - self.process.stdin.write((json.dumps(message, separators=(',', ':')) + '\n').encode()) - self.process.stdin.flush() - except (OSError, ValueError): - pass - - def receive(self, timeout=None): - return self.reader.receive(timeout) - - def stop(self): - if self.process is not None: - with contextlib.suppress(OSError, ValueError): - self.process.stdin.close() - try: - # Closing standard input asks the window to quit. - self.process.wait(timeout=5) - except subprocess.TimeoutExpired: - self.run(['systemctl', 'stop', GUI_UNIT], check=False, capture_output=True, timeout=60) - try: - self.process.wait(timeout=10) - except subprocess.TimeoutExpired: - self.process.kill() - self.process.wait() - with contextlib.suppress(OSError, ValueError): - self.process.stdout.close() - self.process = None - if self.mode is not None: - self.mode.__exit__(None, None, None) - self.mode = None - - -def graphical_unavailable(cmdline=Path('/proc/cmdline'), dev=Path('/dev'), find_spec=importlib.util.find_spec): - """Why the window cannot be shown, or '' when it can.""" - try: - arguments = cmdline.read_text().split() - except OSError: - arguments = [] - if CONSOLE_ONLY in arguments: - return f'{CONSOLE_ONLY} is on the kernel command line' - for program in (CAGE, SYSTEMD_RUN, GUI, QML): - if not program.is_file(): - return f'{program} is missing' - if find_spec('PySide6') is None: - return 'PySide6 is missing' - if not any((dev / 'dri').glob('card[0-9]*')): - return 'there is no graphics device' - if not any((dev / 'input').glob('event[0-9]*')): - return 'there is no input device' - return '' - - -class GraphicalSetup: - """Ask through the window; check and apply its answers as root.""" - - def __init__(self, state, run=subprocess.run, session=Session, devices=None, passwd=Path('/etc/passwd'), - apply=apply, settings=None, root=Path('/'), config=None): - self.state, self.run, self.session_factory, self.passwd = state, run, session, passwd - self.apply, self.root, self.config = apply, root, config - self.devices = luks_devices(run) if devices is None else list(devices) - self.keyboards = keyboard_choices(run=run) - self.locales = locale_choices(run) - self.timezones = timezone_choices(run) - current = settings if settings is not None else current_settings(root, config) - ids = {entry['id'] for entry in self.keyboards} - self.keyboard = current.get('keyboard') if current.get('keyboard') in ids else 'us' - self.locale = next((value for value in (current.get('locale'), 'en_US.UTF-8') if value in self.locales), - next(iter(self.locales), 'en_US.UTF-8')) - self.timezone = current.get('timezone') if current.get('timezone') in self.timezones else 'UTC' - self.active = False - self.done = False - - def init_message(self, focus=''): - return {'type': 'init', 'version': 1, 'encrypted': bool(self.devices), - 'keyboard': self.keyboard, 'locale': self.locale, 'timezone': self.timezone, - 'focus': focus, 'keyboards': self.keyboards, 'locales': self.locales, - 'timezones': self.timezones, 'taken': sorted(existing_names(self.passwd))} - - def run_window(self): - """True once the account exists; Unavailable when the console must ask.""" - focus, failures = '', 0 - while True: - session = self.session_factory(self.keyboard, self.run) - outcome = 'retry' - try: - session.start() - session.send(self.init_message(focus)) - outcome = self.converse(session) - except NotStarted as error: - failures += 1 - # A window that never appeared gets one more try: a graphics - # driver can still be replacing the boot framebuffer. - if failures >= START_ATTEMPTS: - raise - log(f'the setup window did not start ({error}); trying once more') - except Unavailable: - if not self.done: - raise - outcome = 'done' - finally: - session.stop() - if outcome == 'done': - return True - if outcome == 'retry': - time.sleep(2) - else: - # After a new keyboard layout, the window opens where it was. - focus = 'keyboard-test' - - def converse(self, session): - try: - message = session.receive(READY_TIMEOUT) - except EOFError: - message = None - if message is None or message['type'] != 'ready': - raise NotStarted('the setup window did not appear') - while True: - # After setup the window restarts the computer, or this does. - timeout = 60 if self.done else None if self.active else INPUT_TIMEOUT - try: - message = session.receive(timeout) - except EOFError: - if self.done: - return 'done' - raise Unavailable('the setup window closed') from None - if message is None: - if self.done: - return 'done' - raise Unavailable('no key press reached the setup window') - kind = message['type'] - if kind == 'active': - self.active = True - elif kind == 'keyboard' and not self.done: - if self.choose_keyboard(session, message): - return 'restart' - elif kind == 'submit' and not self.done: - self.active = True - if self.submit(session, message): - self.done = True - elif kind == 'restart' and self.done: - return 'done' - - def choose_keyboard(self, session, message): - """Apply a layout to the system now; True when cage must restart.""" - self.locale = message['locale'] if message['locale'] in self.locales else self.locale - keyboard = message['keyboard'] - parts = split_keyboard(keyboard) - if keyboard not in {entry['id'] for entry in self.keyboards} or parts is None: - session.send({'type': 'error', 'field': 'keyboard', 'code': 'choice'}) - return False - if keyboard == self.keyboard: - session.send({'type': 'keyboard', 'keyboard': keyboard}) - return False - try: - # localed also converts the layout to the console keymap. - self.run(['localectl', 'set-x11-keymap', parts[0], '', parts[1]], check=True, timeout=60) - except (OSError, subprocess.SubprocessError): - log('the keyboard layout could not be applied') - session.send({'type': 'error', 'field': 'keyboard', 'code': 'failed'}) - return False - if self.devices and parts[0] != 'us' and not assignments(self.root / 'etc/vconsole.conf').get('KEYMAP'): - # The unlock prompt could not type this password; keep the previous layout. - previous = split_keyboard(self.keyboard) or ('us', '') - self.run(['localectl', 'set-x11-keymap', previous[0], '', previous[1]], check=False, timeout=60) - session.send({'type': 'error', 'field': 'keyboard', 'code': 'boot-keymap'}) - return False - self.keyboard = keyboard - session.send({'type': 'restart'}) - return True - - def validate(self, message): - """The answers apply() takes, or FieldError naming what to ask again.""" - full_name, username = message['full_name'].strip(), message['username'].strip() - password = message['password'] - if full_name_problem(full_name): - raise FieldError('full_name', 'full-name') - problem = username_problem(username, existing_names(self.passwd)) - if problem: - raise FieldError('username', problem) - problem = password_problem(password, message['confirm']) - if problem: - raise FieldError('confirm' if problem == 'password-mismatch' else 'password', problem) - if message['locale'] not in self.locales: - raise FieldError('locale', 'choice') - if message['timezone'] not in self.timezones: - raise FieldError('timezone', 'choice') - startup = message['startup'] if self.devices else '' - if self.devices and not all(opens(device, startup, self.run) or opens(device, password, self.run) - for device in self.devices): - raise FieldError('startup', 'startup-wrong') - return dict(keyboard=self.keyboard, locale=message['locale'], timezone=message['timezone'], - full_name=full_name, username=username, password=password, startup=startup) - - def submit(self, session, message): - try: - answers = self.validate(message) - except FieldError as error: - session.send({'type': 'error', 'field': error.field, 'code': error.code}) - return False - self.locale = answers['locale'] - try: - self.apply(answers, self.state, self.devices, self.run, - progress=lambda stage: session.send({'type': 'progress', 'stage': stage})) - except (SetupError, subprocess.SubprocessError, OSError, ValueError) as error: - # SetupError text is written for people and holds no secret; the - # others are summarized by type, never by their arguments. - detail = str(error) if isinstance(error, SetupError) else '' - log(f'setup did not finish: {detail or type(error).__name__}; nothing was kept') - session.send({'type': 'error', 'field': '', 'code': 'failed', 'detail': detail}) - return False - session.send({'type': 'done'}) - return True - - -def restart(run): - run(['systemctl', '--no-block', 'reboot'], check=False) - return 0 - - -def main(console=None, run=subprocess.run, graphical=GraphicalSetup, unavailable=graphical_unavailable, - root=Path('/')): - oem = root / 'etc/cybexos/oem.json' - if os.geteuid() != 0: - raise SystemExit('cybexos-new-owner must run as root.') - if not oem.exists(): - return 0 - state = read_state(oem) - recover_interrupted(root, run) - reason = unavailable() - if not reason: - try: - if graphical(state, run).run_window(): - return restart(run) - except Unavailable as error: - reason = str(error) - except Exception as error: - # A fault in the window's path must never leave nobody able to sign in. - reason = f'unexpected {type(error).__name__}' - if not oem.exists(): - return restart(run) - log(f'asking on the console: {reason}') - console = console or Console() - failures = 0 - while True: - try: - devices = luks_devices(run) - answers = collect(console, run, devices) - if not summary(console, answers, bool(devices)): - console.say('Nothing was changed. Starting again.') - continue - console.say('\nSetting up your account. This takes a few minutes…') - apply(answers, state, devices, run) - break - except (KeyboardInterrupt, EOFError): - # Never leave the console: without an account nobody could sign in. - failures += 1 - console.say('\nStarting again.') - time.sleep(min(failures, 5)) - except (SetupError, subprocess.SubprocessError, OSError, ValueError) as error: - failures += 1 - console.say(f'\nSetup did not finish: {error}\nNothing was kept. Starting again.') - time.sleep(min(failures, 5)) - except Exception as error: - failures += 1 - console.say(f'\nSetup did not finish ({type(error).__name__}). Nothing was kept. Starting again.') - time.sleep(min(failures, 5)) - console.say('\nDone. Restarting into your new desktop…') - return restart(run) - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-new-owner-gui b/image/rootfs/usr/libexec/cybexos-new-owner-gui deleted file mode 100755 index c3547c5c..00000000 --- a/image/rootfs/usr/libexec/cybexos-new-owner-gui +++ /dev/null @@ -1,413 +0,0 @@ -#!/usr/bin/python3 -"""The new owner's setup window, shown in cage before the login screen. - -cybexos-new-owner (root) starts this as an unprivileged transient user and -sends one `init` message, with every list to choose from, on standard input. -The window only asks; it changes nothing itself. Answers go back as JSON -lines on the standard output it inherited, and the root service checks each -one again before doing any work. Nothing typed here is written to a file, -a log, the environment or a command line. -""" -import functools -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import select -import sys - -from PySide6.QtCore import QEvent, QLocale, QObject, Property, QSocketNotifier, QUrl, Signal, Slot -from PySide6.QtGui import QFont, QGuiApplication -from PySide6.QtQml import QQmlApplicationEngine -from PySide6.QtQuickControls2 import QQuickStyle - -HERE = Path(__file__).resolve().parent -# Beside this program in both layouts: /usr/libexec → /usr/share when -# installed, image/rootfs/usr/libexec → image/rootfs/usr/share in a checkout. -QML = HERE.parent / 'share/cybexos/new-owner/Main.qml' -MAX_INIT = 4 << 20 -INIT_TIMEOUT = 30 -STAGES = ('account', 'desktop', 'disk', 'finish') - - -def load_rules(): - """The root service's own answer rules, so both check the same way.""" - loader = importlib.machinery.SourceFileLoader('cybexos_new_owner_rules', str(HERE / 'cybexos-new-owner')) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -rules = load_rules() - - -def capitalized(text): - return text[:1].upper() + text[1:] - - -# glibc's script modifiers, as Qt's script codes. -SCRIPTS = {'latin': 'Latn', 'cyrillic': 'Cyrl'} - - -@functools.cache -def language_name(language, script): - """The language in its own words, without a region: "English", not - "American English"; "Nederlands", not "Vlaams". CLDR names some regional - variants, so the name most of the language's locales use wins.""" - names = [locale.nativeLanguageName() for locale in - QLocale.matchingLocales(language, script, QLocale.Country.AnyCountry)] - names = [name for name in names if name] - if not names: - return QLocale.languageToString(language) - return capitalized(max(sorted(set(names)), key=lambda name: (names.count(name), -len(name)))) - - -def locale_entries(names): - """Each locale by its own name, e.g. "Nederlands (Nederland)", English beside it.""" - entries = [] - for name in names: - base, _, modifier = name.partition('@') - code = base.split('.', 1)[0] - if modifier in SCRIPTS and '_' in code: - language, territory = code.split('_', 1) - code, modifier = f'{language}_{SCRIPTS[modifier]}_{territory}', '' - locale = QLocale(code) - if locale.language() in (QLocale.Language.C, QLocale.Language.AnyLanguage): - label, english = name, '' - else: - label = language_name(locale.language(), locale.script()) - english = QLocale.languageToString(locale.language()) - if '_' in code: - label += f' ({locale.nativeTerritoryName() or QLocale.territoryToString(locale.territory())})' - english += f' ({QLocale.territoryToString(locale.territory())})' - if modifier: - label += f' · {modifier}' - english += f' · {modifier}' if english else '' - entries.append({'id': name, 'label': label, 'detail': english if english != label else '', - 'search': f'{label} {english} {name}'.casefold()}) - return sorted(entries, key=lambda entry: (entry['label'].casefold(), entry['id'])) - - -def timezone_entries(zones): - """"Amsterdam" with "Europe" beside it; searchable by either.""" - entries = [] - for zone in zones: - region, _, place = zone.rpartition('/') - label, detail = place.replace('_', ' '), region.replace('_', ' ').replace('/', ' / ') - entries.append({'id': zone, 'label': label, 'detail': detail, - 'search': f'{zone} {label} {detail}'.casefold()}) - return entries - - -def keyboard_entries(keyboards): - return [{'id': item['id'], 'label': item['label'], 'detail': item['id'], - 'search': f"{item['label']} {item['id']}".casefold()} for item in keyboards] - - -def valid_init(message): - def strings(value): - return isinstance(value, list) and all(isinstance(item, str) for item in value) - - return (isinstance(message, dict) and message.get('type') == 'init' and message.get('version') == 1 - and isinstance(message.get('encrypted'), bool) - and all(isinstance(message.get(key), str) for key in ('keyboard', 'locale', 'timezone', 'focus')) - and strings(message.get('locales')) and strings(message.get('timezones')) - and strings(message.get('taken')) and isinstance(message.get('keyboards'), list) - and all(isinstance(item, dict) and isinstance(item.get('id'), str) and isinstance(item.get('label'), str) - for item in message['keyboards'])) - - -def read_init(fd, limit=MAX_INIT, timeout=INIT_TIMEOUT, wait=select.select, read=os.read): - """The first message, and whatever followed it; None when it is unusable.""" - buffer = b'' - while b'\n' not in buffer: - if len(buffer) > limit or not wait([fd], [], [], timeout)[0]: - return None - chunk = read(fd, 65536) - if not chunk: - return None - buffer += chunk - line, _, rest = buffer.partition(b'\n') - try: - message = json.loads(line) - except ValueError: - return None - return (message, rest) if valid_init(message) else None - - -class Channel(QObject): - """JSON lines from the root service on one descriptor, to it on another.""" - - def __init__(self, read_fd, write_fd, buffer=b''): - super().__init__() - self.read_fd, self.write_fd, self.buffer = read_fd, write_fd, buffer - self.on_message = lambda message: None - self.on_closed = lambda: None - self.notifier = QSocketNotifier(read_fd, QSocketNotifier.Type.Read, self) - self.notifier.activated.connect(self.readable) - - def readable(self, *_): - try: - chunk = os.read(self.read_fd, 65536) - except BlockingIOError: - return - except OSError: - chunk = b'' - if not chunk: - self.notifier.setEnabled(False) - self.on_closed() - return - self.buffer += chunk - self.dispatch() - - def dispatch(self): - while b'\n' in self.buffer: - line, _, self.buffer = self.buffer.partition(b'\n') - try: - message = json.loads(line) - except ValueError: - continue - if isinstance(message, dict): - self.on_message(message) - - def send(self, message): - data = (json.dumps(message, separators=(',', ':')) + '\n').encode() - try: - while data: - data = data[os.write(self.write_fd, data):] - except OSError: - self.on_closed() - - -class Setup(QObject): - """What the window shows and may ask; QML reads it as `setup`.""" - - changed = Signal() - languageChanged = Signal() - finished = Signal() - - def __init__(self, init, send): - super().__init__() - self.send = send - self._encrypted = init['encrypted'] - self._keyboards = keyboard_entries(init['keyboards']) - self._locales = locale_entries(init['locales']) - self._timezones = timezone_entries(init['timezones']) - self.taken = set(init['taken']) - self._keyboard = init['keyboard'] - self._locale = init['locale'] - self._timezone = init['timezone'] - self._focus = init['focus'] - # idle | switching (a new keyboard layout) | working | failed | done - self._state = 'idle' - self._stage = '' - self._error = ('', '', '') - self.ready_sent = self.active_sent = False - - def update(self, **values): - for name, value in values.items(): - setattr(self, '_' + name, value) - self.changed.emit() - - @Property(bool, constant=True) - def encrypted(self): - return self._encrypted - - @Property('QVariantList', constant=True) - def keyboards(self): - return self._keyboards - - @Property('QVariantList', constant=True) - def locales(self): - return self._locales - - @Property('QVariantList', constant=True) - def timezones(self): - return self._timezones - - @Property(str, constant=True) - def timezone(self): - return self._timezone - - @Property(str, constant=True) - def focus(self): - return self._focus - - @Property('QVariantList', constant=True) - def stages(self): - return [stage for stage in STAGES if stage != 'disk' or self._encrypted] - - @Property(str, notify=changed) - def keyboard(self): - return self._keyboard - - def get_locale(self): - return self._locale - - def set_locale(self, value): - if value != self._locale and any(entry['id'] == value for entry in self._locales): - self._locale = value - self.languageChanged.emit() - - locale = Property(str, get_locale, set_locale, notify=languageChanged) - - # The window's own text: Dutch for a Dutch locale, English otherwise. - @Property(str, notify=languageChanged) - def language(self): - return 'nl' if self._locale.startswith('nl_') else 'en' - - @Property(str, notify=changed) - def state(self): - return self._state - - @Property(str, notify=changed) - def stage(self): - return self._stage - - @Property(str, notify=changed) - def errorField(self): - return self._error[0] - - @Property(str, notify=changed) - def errorCode(self): - return self._error[1] - - @Property(str, notify=changed) - def errorDetail(self): - return self._error[2] - - # ---- the rules, shared with the root service -------------------------- - - @Slot(str, result=str) - def deriveUsername(self, full_name): - return rules.derive_username(full_name) - - @Slot(str, result=str) - def fullNameProblem(self, full_name): - return rules.full_name_problem(full_name.strip()) - - @Slot(str, result=str) - def usernameProblem(self, username): - return rules.username_problem(username.strip(), self.taken) - - @Slot(str, str, result=str) - def passwordProblem(self, password, confirm): - return rules.password_problem(password, confirm) - - # ---- requests ----------------------------------------------------------- - - @Slot(str) - def chooseKeyboard(self, keyboard): - if self._state != 'idle' or keyboard == self._keyboard: - return - if not any(entry['id'] == keyboard for entry in self._keyboards): - return - self.update(state='switching', error=('', '', '')) - self.send({'type': 'keyboard', 'keyboard': keyboard, 'locale': self._locale}) - - @Slot(str, str, str, str, str, str) - def submit(self, full_name, username, password, confirm, startup, timezone): - if self._state not in ('idle', 'failed'): - return - self.update(state='working', stage='', error=('', '', '')) - self.send({'type': 'submit', 'locale': self._locale, 'timezone': timezone, 'full_name': full_name, - 'username': username, 'password': password, 'confirm': confirm, - 'startup': startup if self._encrypted else ''}) - - @Slot() - def restartNow(self): - if self._state == 'done': - self.send({'type': 'restart'}) - - @Slot(str) - def clearError(self, field): - if self._error[0] == field or (field == '' and self._state == 'failed'): - self.update(state='idle' if self._state == 'failed' else self._state, error=('', '', '')) - - @Slot() - def noteReady(self): - if not self.ready_sent: - self.ready_sent = True - self.send({'type': 'ready'}) - - def noteActive(self): - if not self.active_sent: - self.active_sent = True - self.send({'type': 'active'}) - - # ---- answers from the root service ------------------------------------- - - def receive(self, message): - kind = message.get('type') - if kind == 'restart': - # A new keyboard layout: cage starts again with it. - self.finished.emit() - elif kind == 'keyboard': - self.update(state='idle') - elif kind == 'progress' and isinstance(message.get('stage'), str): - self.update(state='working', stage=message['stage']) - elif kind == 'done': - self.update(state='done', stage='') - elif kind == 'error': - field, code = str(message.get('field', '')), str(message.get('code', '')) - self.update(state='failed' if not field else 'idle', - error=(field, code, str(message.get('detail', '')))) - - -class Activity(QObject): - """Reports the first key press once: proof that the keyboard reaches the window.""" - - def __init__(self, notice): - super().__init__() - self.notice = notice - - def eventFilter(self, watched, event): - if event.type() == QEvent.Type.KeyPress: - self.notice() - return False - - -def main(): - os.umask(0o077) - # Standard output carries answers only. Anything else printed, by Qt or a - # library, goes to standard error, which is the journal. - answers = os.dup(1) - os.dup2(2, 1) - received = read_init(0) - if received is None: - print('cybexos-new-owner-gui: no usable setup data', file=sys.stderr) - return 1 - init, rest = received - app = QGuiApplication(sys.argv[:1]) - app.setApplicationName('cybexos-new-owner') - # Plain QML text takes the application font: the welcome window's face. - app.setFont(QFont('Figtree')) - QQuickStyle.setStyle('Basic') - channel = Channel(0, answers, rest) - setup = Setup(init, channel.send) - channel.on_message = setup.receive - channel.on_closed = app.quit - setup.finished.connect(app.quit) - - def active(): - setup.noteActive() - app.removeEventFilter(activity) - - activity = Activity(active) - app.installEventFilter(activity) - engine = QQmlApplicationEngine() - engine.rootContext().setContextProperty('setup', setup) - engine.load(QUrl.fromLocalFile(str(QML))) - if not engine.rootObjects(): - return 1 - # Ready only once a frame is on the screen; until then the service - # still falls back to the console. - engine.rootObjects()[0].frameSwapped.connect(setup.noteReady) - channel.dispatch() - return app.exec() - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-reconcile b/image/rootfs/usr/libexec/cybexos-reconcile deleted file mode 100755 index aefc7326..00000000 --- a/image/rootfs/usr/libexec/cybexos-reconcile +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/python3 -"""Deferred, bounded baseline/account convergence for installed RPM upgrades.""" -import argparse -from contextlib import contextmanager -import fcntl -import hashlib -import json -import os -from pathlib import Path -import pwd -import subprocess -import tempfile -import time - -VENDOR = Path('/usr/share/cybexos') -STATE = Path('/var/lib/cybexos/reconcile') -CONFIG = Path('/etc/cybexos/config.yml') -MAX_ATTEMPTS = 3 - - -def write(path, value): - path.parent.mkdir(parents=True, exist_ok=True, mode=0o755) - fd, temporary = tempfile.mkstemp(prefix='.reconcile-', dir=path.parent) - try: - with os.fdopen(fd, 'w') as stream: - json.dump(value, stream, sort_keys=True) - stream.write('\n') - stream.flush() - os.fsync(stream.fileno()) - os.fchmod(stream.fileno(), 0o644) - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - - -def read(path, default): - return json.loads(path.read_text()) if path.exists() else default - - -def version(): - value = (VENDOR / 'reconcile-version').read_text().strip() - if len(value) != 64 or any(c not in '0123456789abcdef' for c in value): - raise ValueError('Invalid packaged reconciliation version') - return value - - -def accounts(): - return [entry for entry in pwd.getpwall() if 1000 <= entry.pw_uid < 65534 - and entry.pw_name != 'liveuser' and Path(entry.pw_dir).parent == Path('/home')] - - -def identity(account, release): - config = CONFIG.read_bytes() if CONFIG.is_file() else b'' - return hashlib.sha256(json.dumps([release, hashlib.sha256(config).hexdigest(), - account.pw_uid, account.pw_gid, account.pw_dir]).encode()).hexdigest() - - -def status(): - value = read(STATE / 'status.json', {'state': 'pending', 'accounts': {}}) - desired = version() - value['desiredVersion'] = desired - value['pending'] = value.get('version') != desired or value.get('state') != 'ready' - if value.get('version') != desired: - value['state'] = 'pending' - return value - - -@contextmanager -def rpm_idle(): - """Share RPM's POSIX transaction lock; never race a package payload change. - - Baseline reconciliation deliberately contains no package operations. The - lock also prevents DNF from replacing our helper/payload halfway through. - """ - location = next((p for p in (Path('/usr/lib/sysimage/rpm/.rpm.lock'), - Path('/var/lib/rpm/.rpm.lock')) if p.is_file()), None) - if location is None: - raise BlockingIOError('RPM transaction lock is unavailable; retry after boot') - with location.open('rb') as lock: - fcntl.lockf(lock, fcntl.LOCK_SH | fcntl.LOCK_NB) - yield - - -def apply(account): - env = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', - 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'} - # The helper bounds and drains its systemd cgroup while holding - # provision.lock. Never kill just that supervisor and begin recovery while - # its Ansible workers still mutate the system. - try: - subprocess.run(['/usr/libexec/cybexos-configure-installed', '--reconcile', - '--user', account.pw_name], env=env, check=True) - finally: - subprocess.run(['/usr/libexec/cybexos-configure-installed', '--drain'], env=env, check=True) - # New packaged unit definitions take effect at the next service start; - # do not disrupt a running desktop or the user's in-flight app state. - if Path(f'/run/user/{account.pw_uid}/bus').exists(): - # The local bus needs no machine bridge (and no caller pipe FDs - # transferred to PID 1 from this confined system service). - user_env = {**env, 'HOME': account.pw_dir, - 'XDG_RUNTIME_DIR': f'/run/user/{account.pw_uid}', - 'DBUS_SESSION_BUS_ADDRESS': f'unix:path=/run/user/{account.pw_uid}/bus'} - # timeout signals the whole process group, including runuser's client; - # killing only runuser can leave that client holding our output pipe. - subprocess.run(['/usr/bin/timeout', '--kill-after=5s', '30s', - '/usr/sbin/runuser', '-u', account.pw_name, '--', - '/usr/bin/env', '-i', *[f'{key}={value}' for key, value in user_env.items()], - '/usr/bin/systemctl', '--user', 'daemon-reload'], - env=env, check=True) - - -def reconcile(retry=False): - release = version() - previous = read(STATE / 'status.json', {'accounts': {}}) - state = {'version': release, 'state': 'pending', 'accounts': {}, 'lastAttempt': int(time.time())} - users = accounts() - for account in users: - key = identity(account, release) - old = previous.get('accounts', {}).get(account.pw_name, {}) - entry = dict(old) if old.get('identity') == key else { - 'identity': key, 'uid': account.pw_uid, 'home': account.pw_dir, - 'version': release, 'attempts': 0, 'state': 'pending'} - if retry and entry['state'] != 'ready': - entry.update(attempts=0, state='pending') - state['accounts'][account.pw_name] = entry - # Empty installations still need their first account reconciled later. - if not users: - state.update(state='pending', error='Waiting for an installed desktop account') - write(STATE / 'status.json', state) - return state - if all(entry['state'] == 'ready' for entry in state['accounts'].values()): - state['state'] = 'ready' - (STATE / 'pending.json').unlink(missing_ok=True) - if previous.get('version') == release and previous.get('state') == 'ready' and previous.get('accounts') == state['accounts']: - return previous - write(STATE / 'status.json', state) - (STATE / 'pending.json').unlink(missing_ok=True) - return state - write(STATE / 'status.json', state) - try: - with rpm_idle(): - for account in users: - entry = state['accounts'][account.pw_name] - if entry['state'] == 'ready' or entry['attempts'] >= MAX_ATTEMPTS: - continue - entry.update(state='running', attempts=entry['attempts'] + 1) - state['state'] = 'running' - write(STATE / 'status.json', state) - try: - apply(account) - except (OSError, subprocess.SubprocessError) as error: - entry.update(state='error', error=str(error)) - else: - entry.update(state='ready', identity=identity(account, release)) - entry.pop('error', None) - write(STATE / 'status.json', state) - except BlockingIOError as error: - state.update(state='pending', error=str(error)) - write(STATE / 'status.json', state) - return state - unfinished = [entry for entry in state['accounts'].values() if entry['state'] != 'ready'] - state['state'] = ('blocked' if all(entry['attempts'] >= MAX_ATTEMPTS for entry in unfinished) - else 'error') if unfinished else 'ready' - if unfinished: - state['error'] = 'Reconciliation failed; inspect journalctl -u cybexos-reconcile and retry with sudo /usr/libexec/cybexos-reconcile --retry' - else: - state.pop('error', None) - (STATE / 'pending.json').unlink(missing_ok=True) - write(STATE / 'status.json', state) - return state - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - choice = parser.add_mutually_exclusive_group() - choice.add_argument('--queue', action='store_true', help='Record pending work only; safe in RPM posttrans') - choice.add_argument('--status', action='store_true', help='Print read-only JSON status') - choice.add_argument('--retry', action='store_true', help='Reset failed attempt budgets and retry now') - args = parser.parse_args() - if args.status: - print(json.dumps(status(), sort_keys=True)) - return - if os.geteuid() != 0: - parser.error('Run as root') - if args.queue: - write(STATE / 'pending.json', {'version': version()}) - return - if Path('/run/cybexos-live').exists(): - return - STATE.mkdir(parents=True, exist_ok=True, mode=0o755) - with (STATE / 'lock').open('a') as lock: - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - return - result = reconcile(args.retry) - if result['state'] in ('error', 'blocked'): - raise SystemExit(1) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/libexec/cybexos-seed-installed-users b/image/rootfs/usr/libexec/cybexos-seed-installed-users deleted file mode 100755 index 3e8b2c91..00000000 --- a/image/rootfs/usr/libexec/cybexos-seed-installed-users +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Seed installed accounts during installation, before their first login.""" -import os -from pathlib import Path -import pwd -import subprocess - - -def accounts(entries): - return [account for account in entries if 1000 <= account.pw_uid < 65534 - and account.pw_name != "liveuser" - and Path(account.pw_dir).is_absolute() - and Path(account.pw_dir).parent == Path("/home")] - - -def main(): - if os.geteuid() != 0: - raise PermissionError("Run this helper in the installed target as root") - for account in accounts(pwd.getpwall()): - # Run as the owner with a clean environment. No recursive chown, no - # root writes through user-controlled symlinks and no live-user state. - subprocess.run([ - "runuser", "--user", account.pw_name, "--", "env", "-i", - "HOME=" + account.pw_dir, "USER=" + account.pw_name, - "PATH=/usr/local/bin:/usr/bin:/bin", - "/usr/libexec/cybexos-user-init", "--all", - ], check=True) - - -if __name__ == "__main__": - main() diff --git a/image/rootfs/usr/libexec/cybexos-update-channel b/image/rootfs/usr/libexec/cybexos-update-channel deleted file mode 100755 index 62a5fbc1..00000000 --- a/image/rootfs/usr/libexec/cybexos-update-channel +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/python3 -"""Inspect or explicitly enroll an ISO installation in a signed desktop channel.""" -import argparse -import configparser -from datetime import datetime, timezone -import fcntl -import json -import os -from pathlib import Path -import platform -import subprocess -import sys -import tempfile -from urllib.parse import urlsplit -from urllib.request import urlopen - -sys.path.insert(0, '/usr/share/cybexos/lib') -from release_metadata import fingerprint, public_key, repository_url # noqa: E402 - - -REPO = 'etc/yum.repos.d/cybexos-desktop.repo' -KEY = 'etc/pki/rpm-gpg/CYBEXOS-desktop.asc' -CONFIG = 'etc/cybexos/update-channel.json' - - -def read_json(path): - value = json.loads(path.read_text()) - if not isinstance(value, dict): - raise ValueError('Channel configuration must be a JSON object') - return value - - -def status(root=Path('/')): - record = {'available': False, 'availableVersion': '', 'currentVersion': '', - 'managed': True, 'enabled': False, 'configured': False, - 'channel': 'rpm', 'status': 'desktop-channel-disabled', - 'note': 'CybexOS desktop updates are not configured. Fedora and application updates remain available.'} - if root == Path('/'): - result = subprocess.run(['rpm', '-q', 'cybexos-desktop'], capture_output=True, text=True, timeout=10) - if result.returncode == 0: - record['currentVersion'] = result.stdout.strip() - try: - parser = configparser.ConfigParser(interpolation=None) - with (root / REPO).open() as stream: - parser.read_file(stream) - repo = parser['cybexos-desktop'] - if not repo.getboolean('enabled', fallback=True): - return record - record['enabled'] = True - if not all(repo.getboolean(key, fallback=False) for key in ('gpgcheck', 'repo_gpgcheck')): - raise ValueError('Both package and repository signature verification must be enabled') - configured = root / CONFIG - settings = read_json(configured if configured.exists() else root / 'usr/share/cybexos/update-channel.json') - expected = fingerprint(settings.get('fingerprint', '')) - baseurl = repository_url(repo.get('baseurl', '')) - if baseurl != repository_url(settings.get('baseurl', '')): - raise ValueError('Repository URL differs from the enrolled channel') - key_url = urlsplit(repo.get('gpgkey', '')) - if key_url.scheme != 'file' or key_url.netloc or key_url.query or key_url.fragment: - raise ValueError('The enrolled public key must be a local file') - if key_url.path not in ('/' + KEY, '/usr/share/cybexos/update-key.asc'): - raise ValueError('The repository uses an unrecognized public key path') - public_key(root / key_url.path.lstrip('/'), expected) - record.update(configured=True, status='desktop-channel-ready', baseurl=baseurl, - fingerprint=expected, note='CybexOS desktop updates are delivered with system packages.') - except FileNotFoundError: - if record['enabled']: - record.update(status='desktop-channel-invalid', note='The enabled desktop channel is missing its configuration or public key.') - except (OSError, ValueError, KeyError, configparser.Error, subprocess.SubprocessError): - record.update(status='desktop-channel-invalid', note='The desktop update channel is invalid. Run cybex update-channel enroll with its reviewed configuration.') - return record - - -def resolved_url(url, root): - release = {} - for line in (root / 'etc/os-release').read_text().splitlines(): - key, sep, value = line.partition('=') - if sep: - release[key] = value.strip('"\'') - version = release.get('VERSION_ID', '') - if release.get('ID') != 'fedora' or not version.isdecimal() or platform.machine() != 'x86_64': - raise ValueError('Desktop channels require a supported Fedora x86_64 installation') - return url.replace('$releasever', version).replace('$basearch', 'x86_64') - - -def verify_repository(url, armor): - """Verify bounded metadata in an isolated keyring before changing trust.""" - with tempfile.TemporaryDirectory(prefix='cybexos-channel-') as directory: - work = Path(directory) - key = work / 'public.asc' - key.write_bytes(armor) - for name in ('repomd.xml', 'repomd.xml.asc'): - with urlopen(url + '/repodata/' + name, timeout=30) as response: - if urlsplit(response.url).scheme != 'https': - raise ValueError('Repository metadata redirected to an insecure URL') - content = response.read(4 * 1024 * 1024 + 1) - if len(content) > 4 * 1024 * 1024: - raise ValueError('Repository metadata exceeds the size limit') - (work / name).write_bytes(content) - gpg = ['gpg', '--no-options', '--homedir', str(work), '--batch', '--no-autostart'] - subprocess.run([*gpg, '--import', str(key)], check=True, capture_output=True, timeout=15) - subprocess.run([*gpg, '--verify', str(work / 'repomd.xml.asc'), str(work / 'repomd.xml')], - check=True, capture_output=True, timeout=15) - - -def atomic_write(path, content): - path.parent.mkdir(parents=True, exist_ok=True) - if path.is_symlink() or (path.exists() and not path.is_file()): - raise ValueError(f'Refusing to replace a non-regular channel file: {path}') - fd, temporary = tempfile.mkstemp(prefix='.cybexos-channel-', dir=path.parent) - try: - with os.fdopen(fd, 'wb') as stream: - os.fchmod(stream.fileno(), 0o644) - stream.write(content) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - parent = os.open(path.parent, os.O_DIRECTORY) - try: - os.fsync(parent) - finally: - os.close(parent) - finally: - Path(temporary).unlink(missing_ok=True) - - -def enroll(config, expected, root=Path('/'), check=False): - expected = fingerprint(expected) - settings = read_json(config) - if set(settings) != {'baseurl', 'fingerprint', 'key_file'} or fingerprint(settings['fingerprint']) != expected: - raise ValueError('Configuration does not match the explicitly trusted fingerprint') - url = repository_url(settings['baseurl']) - armor = public_key(config.parent / settings['key_file'], expected) - # A broken or temporarily disabled repo does not discard previously pinned - # trust. Read the enrollment record independently of its readiness status. - for pinned in (root / CONFIG, root / 'usr/share/cybexos/update-channel.json'): - if pinned.is_file(): - recorded = read_json(pinned).get('fingerprint') - if recorded and fingerprint(recorded) != expected: - raise ValueError('Signing-key rotation requires a separate reviewed migration') - if recorded: - break - verify_repository(resolved_url(url, root), armor) - if check: - return {'checked': True, 'baseurl': url, 'fingerprint': expected} - repo = ('# Enrolled with cybex update-channel; local policy survives RPM updates.\n' - '[cybexos-desktop]\nname=CybexOS desktop updates\nenabled=1\n' - 'gpgcheck=1\nrepo_gpgcheck=1\nskip_if_unavailable=0\nmetadata_expire=6h\n' - f'baseurl={url}\ngpgkey=file:///{KEY}\n').encode() - values = {KEY: armor, CONFIG: (json.dumps({'enabled': True, 'baseurl': url, 'fingerprint': expected}, indent=2) + '\n').encode(), REPO: repo} - before = {} - for name in values: - path = root / name - if path.is_symlink() or (path.exists() and not path.is_file()): - raise ValueError('Refusing to replace a non-regular channel file') - before[name] = path.read_bytes() if path.exists() else None - if all(before[name] == value for name, value in values.items()): - return status(root) - stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') - backup = root / 'var/lib/cybexos/backups' / ('update-channel-' + stamp) - backup.mkdir(parents=True, mode=0o700) - for name, content in before.items(): - if content is not None: - atomic_write(backup / name, content) - written = [] - try: - for name, content in values.items(): - # atomic_write can fail after rename (for example directory fsync). - # Include the in-flight destination in rollback before publishing. - written.append(name) - atomic_write(root / name, content) - except BaseException: - for name in reversed(written): - if before[name] is None: - (root / name).unlink(missing_ok=True) - else: - atomic_write(root / name, before[name]) - raise - return {**status(root), 'backup': str(backup)} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - actions = parser.add_subparsers(dest='action', required=True) - state = actions.add_parser('status') - state.add_argument('--json', action='store_true') - enable = actions.add_parser('enroll') - enable.add_argument('configuration', type=Path) - enable.add_argument('--fingerprint', required=True, help='Independently reviewed complete signing fingerprint') - enable.add_argument('--check', action='store_true', help='Verify public key and signed repository without changing configuration') - args = parser.parse_args() - try: - if args.action == 'status': - value = status() - print(json.dumps(value) if args.json else value['note']) - return 0 - if not args.check and os.geteuid() != 0: - parser.error('Enrollment requires sudo; --check is available without root') - if args.check: - value = enroll(args.configuration.resolve(strict=True), args.fingerprint, check=True) - else: - lockdir = Path('/run/lock/cybexos') - lockdir.mkdir(mode=0o755, parents=True, exist_ok=True) - descriptor = os.open(lockdir / 'update-channel.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, 'w') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - value = enroll(args.configuration.resolve(strict=True), args.fingerprint) - print(json.dumps(value, indent=2)) - return 0 - except (OSError, ValueError, subprocess.SubprocessError) as error: - parser.exit(1, f'update-channel: {error}\n') - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-user-init b/image/rootfs/usr/libexec/cybexos-user-init deleted file mode 100755 index 00b807a8..00000000 --- a/image/rootfs/usr/libexec/cybexos-user-init +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env python3 -"""Initialize desktop essentials quickly; copy offline apps safely in background.""" -import argparse -import fcntl -import json -import importlib.machinery -import importlib.util -import os -from pathlib import Path -import shutil -import signal -import subprocess -import sys -import tempfile -import time - - -def directory(path): - """Never adopt a user's symlink or replace an existing non-directory.""" - if path.is_symlink(): - return False - if path.exists(): - return path.is_dir() - if not directory(path.parent): - return False - try: - path.mkdir() - except FileExistsError: - return not path.is_symlink() and path.is_dir() - return True - - -def copy_absent(source, target): - """Publish complete files atomically, retaining reflinks where supported. - - A crash must never leave a partial final pathname that a subsequent login - mistakes for a personal file. link() also refuses a concurrent user edit. - """ - if target.exists() or target.is_symlink(): - return - descriptor, temporary = tempfile.mkstemp(prefix=".cybexos-seed-", dir=target.parent) - temporary = Path(temporary) - try: - with os.fdopen(descriptor, "wb") as output, source.open("rb") as original: - try: - fcntl.ioctl(output.fileno(), 0x40049409, original.fileno()) # FICLONE - except OSError: - shutil.copyfileobj(original, output, length=1024 * 1024) - output.flush() - os.fchmod(output.fileno(), source.stat().st_mode & 0o777) - try: - os.link(temporary, target) - except FileExistsError: - pass - finally: - temporary.unlink(missing_ok=True) - - -def seed_applications(source, destination, progress=None): - if not directory(destination): - return - for item in sorted(source.iterdir()): - target = destination / item.name - if item.is_symlink(): - try: - target.symlink_to(os.readlink(item)) - except FileExistsError: - pass - elif item.is_dir(): - seed_applications(item, target, progress) - elif item.is_file(): - copy_absent(item, target) - if progress: - progress(item.stat().st_size) - - -def write_status(path, status): - descriptor, temporary = tempfile.mkstemp(prefix=".seed-progress-", dir=path.parent) - try: - with os.fdopen(descriptor, "w") as output: - json.dump(status, output) - output.write("\n") - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - - -def reconcile_defaults(home, vendor, state): - """Update owned fragments independently of the one-time application seed. - - Personal edits, symlinks and deletions survive; originals are backed up - before replacement. A failed publication leaves a recoverable ledger. - """ - manifest = vendor / "managed-defaults.json" - if not manifest.exists(): - return - spec = importlib.util.spec_from_file_location("cybexos_managed_files", vendor / "lib/managed_files.py") - managed = importlib.util.module_from_spec(spec) - spec.loader.exec_module(managed) - release = (vendor / "reconcile-version").read_text().strip() - status_path = state / "defaults-progress.json" - status = json.loads(status_path.read_text()) if status_path.exists() else {} - if status.get("version") == release and status.get("state") == "ready": - return - status = {"version": release, "state": "running", "preserved": []} - write_status(status_path, status) - try: - for relative in json.loads(manifest.read_text()): - path = Path(relative) - if path.is_absolute() or ".." in path.parts: - raise ValueError("Invalid managed default path") - source, target = vendor / "managed-seed" / path, home / path - if not directory(target.parent): - status["preserved"].append(relative) - continue - result = managed.manage(target, source.read_bytes(), state / "defaults/ownership.json", - mode=source.stat().st_mode & 0o777) - if result["preserved"]: - status["preserved"].append(relative) - status["state"] = "ready" - except BaseException: - status["state"] = "error" - write_status(status_path, status) - raise - write_status(status_path, status) - - -def seed_web_apps(vendor, data): - """Install the default web apps once, as `cybexos-webapp seed` does. - - The checkout's dotfiles role runs the same packaged helper. It records - every default it seeds, so a web app the user removed is never added back - by a later login. A failure must never keep the desktop from starting. - """ - helper = vendor / "bin/cybexos-webapp" - # The helper's record of what it seeded: once it exists, a login need not - # load the helper at all (a binding seeds a missing default on first use). - # A release that adds a default must revisit this marker. - if not helper.is_file() or (data / "webapps/defaults.json").exists(): - return - try: - loader = importlib.machinery.SourceFileLoader("cybexos_webapp", str(helper)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - module.seed() - except Exception as error: - print(f"cybexos-user-init: default web apps were not seeded: {error}", file=sys.stderr) - - -def notify(message, urgency="normal"): - if shutil.which("notify-send"): - try: - subprocess.run(["notify-send", "--app-name=CybexOS", "--urgency=" + urgency, - "Offline applications", message], check=False, timeout=3, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (OSError, subprocess.TimeoutExpired): - pass - - -def initialize(home, vendor=Path("/usr/share/cybexos"), mode="all", notifications=False): - config = Path(os.environ.get("XDG_CONFIG_HOME", str(home / ".config"))) / "cybexos" - data = Path(os.environ.get("XDG_DATA_HOME", str(home / ".local/share"))) / "cybexos" - state = Path(os.environ.get("XDG_STATE_HOME", str(home / ".local/state"))) / "cybexos" - for target in (config / "hypr", data / "themes", data / "plugins", home / ".local/bin", state): - if not directory(target): - raise RuntimeError(f"Preserved existing path; cannot initialize directory: {target}") - # Concurrent manual retries and login initialization share one lock. - descriptor = os.open(state / "seed.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, "w") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - reconcile_defaults(home, vendor, state) - essential = vendor / "essential-seed" - if essential.is_dir(): - seed_applications(essential, home) - links = {data / "runtime": vendor / "runtime"} - for helper in (vendor / "bin").iterdir(): - if helper.is_file(): - links[home / ".local/bin" / helper.name] = helper - links[home / ".local/bin/cybex"] = Path("/usr/bin/cybex") - for destination, source in links.items(): - try: - destination.symlink_to(source) - except FileExistsError: - pass - seed_web_apps(vendor, data) - seed = vendor / "user-seed" - marker = state / "offline-apps-seeded" - status_path = state / "seed-progress.json" - if marker.exists() or not seed.is_dir(): - write_status(status_path, {"state": "ready", "label": "Offline applications are ready"}) - return - if mode == "essential": - write_status(status_path, {"state": "pending", "label": "Preparing offline applications"}) - return - manifest = vendor / "seed-groups.json" - total = json.loads(manifest.read_text())["totalBytes"] if manifest.exists() else 0 - status = {"state": "copying", "label": "Preparing offline applications", - "completedBytes": 0, "totalBytes": total} - write_status(status_path, status) - if notifications: - notify("The desktop is ready. Offline developer tools are being prepared.") - last_update = 0 - - def progress(size): - nonlocal last_update - status["completedBytes"] += size - now = time.monotonic() - if now - last_update >= 0.5: - write_status(status_path, status) - last_update = now - - try: - seed_applications(seed, home, progress) - # LazyVim's config and rustup/npm/user command shims become - # available after their offline dependencies. Launching them - # during live setup must never start an automatic download. - final = vendor / "final-seed" - if final.is_dir(): - seed_applications(final, home, progress) - marker.touch() - write_status(status_path, {**status, "state": "ready", "label": "Offline applications are ready"}) - except BaseException: - write_status(status_path, {**status, "state": "error", "label": - "Application preparation was interrupted. Run cybex prepare-apps to retry."}) - if notifications: - notify("Preparation was interrupted. Run cybex prepare-apps to retry.", "critical") - raise - if notifications: - notify("Your offline applications and developer tools are ready.") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - group = parser.add_mutually_exclusive_group() - group.add_argument("--essential", action="store_true") - group.add_argument("--background", action="store_true") - group.add_argument("--all", action="store_true") - args = parser.parse_args() - - def interrupted(*_): - raise InterruptedError("Session ended") - - signal.signal(signal.SIGTERM, interrupted) - initialize(Path.home(), mode="essential" if args.essential else "all", notifications=args.background) - - -if __name__ == "__main__": - main() diff --git a/image/rootfs/usr/share/applications/cybex.desktop b/image/rootfs/usr/share/applications/cybex.desktop deleted file mode 100644 index 6c66c0f6..00000000 --- a/image/rootfs/usr/share/applications/cybex.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Type=Application -Name=cybex -Comment=Install CybexOS or explore your desktop -Exec=cybex welcome -Icon=computer -Terminal=false -Categories=System; -StartupNotify=true diff --git a/image/rootfs/usr/share/cybexos/new-owner/Main.qml b/image/rootfs/usr/share/cybexos/new-owner/Main.qml deleted file mode 100644 index abbe1fe8..00000000 --- a/image/rootfs/usr/share/cybexos/new-owner/Main.qml +++ /dev/null @@ -1,962 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -// The new owner's first-start setup, shown in cage before the login screen. -// Four steps with one primary action each: language and keyboard, account, -// time zone, finishing. The window only asks: `setup` sends the answers to -// the root service, which checks them again and does the work. The window's -// text follows the chosen language (Dutch or English). -ApplicationWindow { - id: window - - // The welcome window's and installer's palette. - readonly property color ink: "#f4f4f5" - readonly property color inkMid: "#b4b4bb" - readonly property color inkFaint: "#80808a" - readonly property color surface: "#17171a" - readonly property color surfaceHover: "#202024" - readonly property color stroke: "#2a2a30" - readonly property color accent: "#dd0034" - readonly property color accentHover: "#f0164a" - readonly property color accentDown: "#b8002b" - readonly property color accentText: "#ff6b86" - - // cage shows the window on the whole screen at scale 1. One unit grows - // with the screen, so text stays readable on high-density panels. - readonly property real u: Math.max(1, Math.min(3, Math.min(width / 1440, height / 900))) - readonly property real rowHeight: Math.round(42 * u) - - readonly property var pages: ["welcome", "account", "timezone", "finish"] - readonly property var steps: ["Welcome", "Your account", "Time zone", "Finishing"] - property int page: 0 - property bool showErrors: false - property bool usernameEdited: false - property string timezone: setup.timezone - property int countdown: 15 - - width: 1280 - height: 800 - visible: true - title: tr("Set up CybexOS") - color: "#0e0e10" - font.family: "Figtree" - font.pixelSize: Math.round(16 * u) - palette.windowText: ink - palette.text: ink - palette.buttonText: ink - palette.button: surface - palette.base: surface - palette.highlight: accent - palette.highlightedText: "#ffffff" - - // ---- language ----------------------------------------------------------- - - function tr(text) { - return setup.language === "nl" && dutch[text] !== undefined ? dutch[text] : text; - } - - readonly property var dutch: ({ - "Set up CybexOS": "CybexOS instellen", - "Make it yours.": "Maak hem van jou.", - "A few choices and your desktop is ready.": "Een paar keuzes en je bureaublad is klaar.", - "Welcome": "Welkom", - "Your account": "Je account", - "Time zone": "Tijdzone", - "Finishing": "Afronden", - "Welcome.": "Welkom.", - "Let's set up this computer for you. First, choose your language and keyboard.": - "We stellen deze computer voor je in. Kies eerst je taal en toetsenbord.", - "Language": "Taal", - "Keyboard layout": "Toetsenbordindeling", - "Search": "Zoeken", - "No matches": "Geen resultaten", - "Try your keyboard": "Probeer je toetsenbord", - "Type here to test your keyboard": "Typ hier om je toetsenbord te testen", - "Switching keyboard layout…": "Toetsenbordindeling wisselen…", - "Continue": "Doorgaan", - "Back": "Terug", - "Your account.": "Je account.", - "You use it to sign in and to approve administrator tasks.": - "Hiermee meld je je aan en keur je beheerderstaken goed.", - "Full name": "Volledige naam", - "Username": "Gebruikersnaam", - "Lowercase letters, numbers, dashes and underscores.": "Kleine letters, cijfers, streepjes en underscores.", - "Password": "Wachtwoord", - "Confirm password": "Bevestig wachtwoord", - "Use at least 12 characters.": "Gebruik minstens 12 tekens.", - "This password also unlocks the disk when the computer starts.": - "Dit wachtwoord ontgrendelt ook de schijf als de computer start.", - "Temporary disk passphrase": "Tijdelijk schijfwachtwoord", - "The passphrase you typed to start this computer. It stops working once setup is done.": - "Het wachtwoord dat je typte om deze computer te starten. Het werkt niet meer zodra het instellen klaar is.", - "Your time zone.": "Je tijdzone.", - "Choose where you are, so the clock is right.": "Kies waar je bent, zodat de klok klopt.", - "Search for a city": "Zoek een stad", - "Set up": "Instellen", - "Setting up…": "Bezig met instellen…", - "This takes a few minutes. Keep the computer on.": "Dit duurt een paar minuten. Laat de computer aan.", - "Creating your account": "Je account aanmaken", - "Preparing your desktop": "Je bureaublad voorbereiden", - "Securing the disk": "De schijf beveiligen", - "Finishing up": "Afronden", - "Setup did not finish.": "Het instellen is niet gelukt.", - "Nothing was kept. You can try again.": "Er is niets bewaard. Je kunt het opnieuw proberen.", - "Try again": "Opnieuw proberen", - "Ready.": "Klaar.", - "Your account is ready. The computer restarts to finish setting up.": - "Je account is klaar. De computer start opnieuw om het instellen af te ronden.", - "When it starts, unlock the disk with your new password.": - "Ontgrendel de schijf daarna met je nieuwe wachtwoord.", - "Restart now": "Nu opnieuw starten", - "Restarting in %1 seconds": "Opnieuw starten over %1 seconden", - "Use at most 128 characters, without colons.": "Gebruik maximaal 128 tekens, zonder dubbele punt.", - "Choose a username.": "Kies een gebruikersnaam.", - "Use lowercase letters, numbers, dashes or underscores, starting with a letter.": - "Gebruik kleine letters, cijfers, streepjes of underscores, en begin met een letter.", - "That name is reserved for the system.": "Die naam is gereserveerd voor het systeem.", - "That name is already in use.": "Die naam is al in gebruik.", - "Use a password of 12–512 characters.": "Gebruik een wachtwoord van 12 tot 512 tekens.", - "The password cannot contain control characters.": "Het wachtwoord mag geen besturingstekens bevatten.", - "The passwords do not match.": "De wachtwoorden komen niet overeen.", - "Type the passphrase you used at startup.": "Typ het wachtwoord dat je bij het starten gebruikte.", - "That passphrase does not unlock this disk.": "Dit wachtwoord ontgrendelt de schijf niet.", - "This layout cannot be used at the disk unlock screen. Choose another layout.": - "Deze indeling werkt niet op het ontgrendelscherm van de schijf. Kies een andere indeling.", - "Choose an item from the list.": "Kies een item uit de lijst.", - "Something went wrong. Try again.": "Er ging iets mis. Probeer het opnieuw." - }) - - // Problem codes from the shared rules and the root service. - readonly property var problems: ({ - "full-name": "Use at most 128 characters, without colons.", - "username-empty": "Choose a username.", - "username-format": "Use lowercase letters, numbers, dashes or underscores, starting with a letter.", - "username-reserved": "That name is reserved for the system.", - "username-taken": "That name is already in use.", - "password-length": "Use a password of 12–512 characters.", - "password-control": "The password cannot contain control characters.", - "password-mismatch": "The passwords do not match.", - "startup-missing": "Type the passphrase you used at startup.", - "startup-wrong": "That passphrase does not unlock this disk.", - "boot-keymap": "This layout cannot be used at the disk unlock screen. Choose another layout.", - "choice": "Choose an item from the list.", - "failed": "Something went wrong. Try again." - }) - - readonly property var stageLabels: ({ - "account": "Creating your account", - "desktop": "Preparing your desktop", - "disk": "Securing the disk", - "finish": "Finishing up" - }) - - function problemText(code) { - return code === "" ? "" : tr(problems[code] !== undefined ? problems[code] : problems.failed); - } - - // The service's answer about one field, until that field is edited. - function serviceProblem(field) { - return setup.errorField === field ? setup.errorCode : ""; - } - - // ---- the account rules (shared with the service) ------------------------ - - function fullNameProblem() { - return setup.fullNameProblem(fullNameField.text); - } - function usernameProblem() { - return usernameField.text.trim() === "" ? "username-empty" : setup.usernameProblem(usernameField.text); - } - function passwordProblem() { - const code = setup.passwordProblem(passwordField.text, confirmField.text); - return code === "password-mismatch" ? "" : code; - } - function confirmProblem() { - return setup.passwordProblem(passwordField.text, confirmField.text) === "password-mismatch" - ? "password-mismatch" : ""; - } - function startupProblem() { - return setup.encrypted && startupField.text === "" ? "startup-missing" : ""; - } - - function firstInvalid() { - const checks = [[fullNameField, fullNameProblem()], [usernameField, usernameProblem()], - [passwordField, passwordProblem()], [confirmField, confirmProblem()], - [startupField, startupProblem()]]; - for (const check of checks) { - if (check[1] !== "") - return check[0]; - } - return null; - } - - // ---- navigation --------------------------------------------------------- - - function next() { - if (page === 0) { - if (setup.state !== "idle") - return; - page = 1; - fullNameField.input.forceActiveFocus(); - } else if (page === 1) { - showErrors = true; - const invalid = firstInvalid(); - if (invalid !== null) { - invalid.input.forceActiveFocus(); - return; - } - page = 2; - timezoneList.field.forceActiveFocus(); - } else if (page === 2 || (page === 3 && setup.state === "failed")) { - submit(); - } else if (page === 3 && setup.state === "done") { - setup.restartNow(); - } - } - - function back() { - if (page === 3 && setup.state === "failed") { - setup.clearError(""); - page = 1; - } else if (page === 1 || page === 2) { - page -= 1; - } - } - - function submit() { - page = 3; - setup.submit(fullNameField.text, usernameField.text, passwordField.text, confirmField.text, - startupField.text, timezone); - } - - Component.onCompleted: { - if (setup.focus === "keyboard-test") - keyboardTest.forceActiveFocus(); - else - languageList.field.forceActiveFocus(); - } - - Connections { - target: setup - function onChanged() { - const field = setup.errorField; - if (field === "") - return; - // Back to the question the service asks again. - if (["full_name", "username", "password", "confirm", "startup"].indexOf(field) >= 0) { - window.page = 1; - window.showErrors = true; - const inputs = { full_name: fullNameField, username: usernameField, password: passwordField, - confirm: confirmField, startup: startupField }; - inputs[field].input.forceActiveFocus(); - } else if (field === "timezone") { - window.page = 2; - } else { - window.page = 0; - } - } - } - - Timer { - running: setup.state === "done" - interval: 1000 - repeat: true - onTriggered: { - window.countdown -= 1; - if (window.countdown <= 0) { - stop(); - setup.restartNow(); - } - } - } - - // ---- building blocks ---------------------------------------------------- - - component ActionButton: Button { - id: button - property bool primary: false - implicitHeight: Math.round(48 * window.u) - leftPadding: Math.round(26 * window.u) - rightPadding: Math.round(26 * window.u) - font.weight: Font.DemiBold - font.pixelSize: Math.round(16 * window.u) - Accessible.name: text - // Return and Enter press a focused button too, not only Space. - Keys.onReturnPressed: clicked() - Keys.onEnterPressed: clicked() - background: Rectangle { - radius: Math.round(12 * window.u) - color: button.primary - ? (button.down ? window.accentDown : button.hovered ? window.accentHover : window.accent) - : (button.hovered ? window.surfaceHover : window.surface) - opacity: button.enabled ? 1 : 0.5 - border.width: button.visualFocus ? Math.round(2 * window.u) : 0 - border.color: "#ffffff" - } - contentItem: Text { - text: button.text - color: button.primary ? "#ffffff" : window.ink - font: button.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - - component Input: TextField { - id: input - Layout.fillWidth: true - implicitHeight: Math.round(48 * window.u) - leftPadding: Math.round(12 * window.u) - rightPadding: Math.round(12 * window.u) - color: window.ink - placeholderTextColor: window.inkFaint - selectionColor: window.accent - selectedTextColor: "#ffffff" - font.pixelSize: Math.round(16 * window.u) - background: Rectangle { - radius: Math.round(12 * window.u) - color: window.surface - border.width: input.activeFocus ? Math.round(2 * window.u) : 1 - border.color: input.activeFocus ? window.ink : input.hovered ? window.inkFaint : window.stroke - } - } - - component Caption: Text { - Layout.fillWidth: true - color: window.ink - font.pixelSize: Math.round(14 * window.u) - wrapMode: Text.WordWrap - } - - component Hint: Text { - Layout.fillWidth: true - color: window.inkMid - font.pixelSize: Math.round(13 * window.u) - wrapMode: Text.WordWrap - lineHeight: 1.3 - } - - component Problem: Text { - Layout.fillWidth: true - visible: text !== "" - color: window.accentText - font.pixelSize: Math.round(13 * window.u) - wrapMode: Text.WordWrap - Accessible.role: Accessible.AlertMessage - Accessible.name: text - } - - // A labelled field with its hint or problem below it. - component Field: ColumnLayout { - id: field - property string label: "" - property string hint: "" - property string problem: "" - property alias text: entry.text - property alias input: entry - property alias echoMode: entry.echoMode - property alias maximumLength: entry.maximumLength - property alias inputMethodHints: entry.inputMethodHints - signal edited() - Layout.fillWidth: true - spacing: Math.round(8 * window.u) - Caption { text: field.label } - Input { - id: entry - objectName: field.objectName + "Input" - Accessible.name: field.label - Accessible.description: field.problem !== "" ? field.problem : field.hint - onTextEdited: field.edited() - Keys.onReturnPressed: window.next() - Keys.onEnterPressed: window.next() - } - Problem { text: field.problem } - Hint { visible: field.problem === "" && field.hint !== ""; text: field.hint } - } - - // A search box over a list; Enter or a click chooses. Long lists - // (languages, layouts, time zones) stay usable from the keyboard alone. - component SearchList: ColumnLayout { - id: list - property string label: "" - property string placeholder: "" - property var entries: [] - property string current: "" - property alias field: search - property alias view: view - readonly property var shown: { - const words = search.text.trim().toLowerCase().split(/\s+/).filter(word => word !== ""); - return words.length === 0 ? entries - : entries.filter(entry => words.every(word => entry.search.indexOf(word) >= 0)); - } - signal chosen(string id) - spacing: Math.round(8 * window.u) - - function choose(index) { - if (index >= 0 && index < shown.length) - chosen(shown[index].id); - } - function reveal() { - const index = shown.findIndex(entry => entry.id === current); - view.currentIndex = index >= 0 ? index : 0; - if (view.count > 0) - view.positionViewAtIndex(view.currentIndex, ListView.Center); - } - onShownChanged: reveal() - Component.onCompleted: reveal() - - Caption { text: list.label } - Input { - id: search - placeholderText: list.placeholder - inputMethodHints: Qt.ImhNoPredictiveText - Accessible.name: list.label - Keys.onDownPressed: view.forceActiveFocus() - Keys.onReturnPressed: list.choose(view.currentIndex) - Keys.onEnterPressed: list.choose(view.currentIndex) - } - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: 4 * window.rowHeight + Math.round(12 * window.u) - radius: Math.round(12 * window.u) - color: window.surface - border.width: view.activeFocus ? Math.round(2 * window.u) : 1 - border.color: view.activeFocus ? window.ink : window.stroke - - ListView { - id: view - anchors.fill: parent - anchors.margins: Math.round(6 * window.u) - clip: true - model: list.shown - boundsBehavior: Flickable.StopAtBounds - keyNavigationEnabled: true - activeFocusOnTab: true - highlightMoveDuration: 0 - Accessible.role: Accessible.List - Accessible.name: list.label - ScrollBar.vertical: ScrollBar {} - Keys.onReturnPressed: list.choose(currentIndex) - Keys.onEnterPressed: list.choose(currentIndex) - Keys.onSpacePressed: list.choose(currentIndex) - Keys.onUpPressed: event => { - if (currentIndex <= 0) - search.forceActiveFocus(); - else - decrementCurrentIndex(); - } - - delegate: Rectangle { - id: row - required property var modelData - required property int index - readonly property bool selected: modelData.id === list.current - readonly property bool focused: view.activeFocus && view.currentIndex === index - width: view.width - height: window.rowHeight - radius: Math.round(8 * window.u) - color: selected ? Qt.rgba(0.867, 0, 0.204, 0.24) - : rowMouse.containsMouse || focused ? window.surfaceHover : "transparent" - border.width: focused ? Math.round(2 * window.u) : 0 - border.color: window.ink - Accessible.role: Accessible.ListItem - Accessible.name: modelData.label - Accessible.selected: selected - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Math.round(12 * window.u) - anchors.rightMargin: Math.round(12 * window.u) - spacing: Math.round(12 * window.u) - Text { - Layout.fillWidth: true - text: row.modelData.label - color: window.ink - font.pixelSize: Math.round(15 * window.u) - font.weight: row.selected ? Font.DemiBold : Font.Normal - elide: Text.ElideRight - } - Text { - Layout.maximumWidth: row.width * 0.45 - text: row.modelData.detail - color: window.inkFaint - font.pixelSize: Math.round(13 * window.u) - elide: Text.ElideRight - } - } - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - view.currentIndex = row.index; - view.forceActiveFocus(); - list.chosen(row.modelData.id); - } - } - } - } - - Text { - anchors.centerIn: parent - visible: list.shown.length === 0 - text: window.tr("No matches") - color: window.inkFaint - font.pixelSize: Math.round(15 * window.u) - } - } - } - - // ---- the window ----------------------------------------------------------- - - RowLayout { - anchors.fill: parent - spacing: 0 - - Rectangle { - Layout.fillHeight: true - Layout.preferredWidth: Math.round(Math.max(280 * window.u, window.width * 0.3)) - color: window.surface - - Rectangle { - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - width: 1 - color: window.stroke - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Math.round(40 * window.u) - spacing: 0 - - Image { - source: "../welcome/cybex-wordmark.svg" - Layout.preferredHeight: Math.round(30 * window.u) - Layout.preferredWidth: Math.round(30 * window.u * 198 / 46.6) - sourceSize.height: Math.round(90 * window.u) - fillMode: Image.PreserveAspectFit - smooth: true - Accessible.role: Accessible.Graphic - Accessible.name: "Cybex" - } - Item { Layout.preferredHeight: Math.round(64 * window.u) } - Text { - Layout.fillWidth: true - text: window.tr("Make it yours.") - color: window.ink - font.pixelSize: Math.round(44 * window.u) - font.weight: Font.DemiBold - font.letterSpacing: -1 - wrapMode: Text.WordWrap - } - Item { Layout.preferredHeight: Math.round(18 * window.u) } - Text { - Layout.fillWidth: true - text: window.tr("A few choices and your desktop is ready.") - color: window.inkMid - font.pixelSize: Math.round(16 * window.u) - wrapMode: Text.WordWrap - lineHeight: 1.5 - } - Item { Layout.preferredHeight: Math.round(30 * window.u) } - Repeater { - model: window.steps - delegate: RowLayout { - id: step - required property string modelData - required property int index - Layout.topMargin: Math.round(17 * window.u) - spacing: Math.round(12 * window.u) - Text { - text: "0" + (step.index + 1) - color: step.index === window.page ? window.accentText : window.inkMid - font.pixelSize: Math.round(12 * window.u) - font.letterSpacing: 1 - } - Text { - text: window.tr(step.modelData) - color: step.index === window.page ? window.accentText : window.inkMid - font.pixelSize: Math.round(15 * window.u) - } - } - } - Item { Layout.fillHeight: true } - Text { - text: "CYBEX OPINIONATED SYSTEM" - color: window.inkFaint - font.pixelSize: Math.round(10 * window.u) - font.letterSpacing: 1.6 - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ColumnLayout { - id: main - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: Math.round(44 * window.u) - anchors.bottomMargin: Math.round(32 * window.u) - width: Math.min(parent.width - Math.round(2 * 56 * window.u), Math.round(860 * window.u)) - spacing: 0 - - Text { - text: "0" + (window.page + 1) + " / " + window.tr(window.steps[window.page]) - color: window.inkFaint - font.pixelSize: Math.round(12 * window.u) - font.weight: Font.DemiBold - font.letterSpacing: 1.2 - font.capitalization: Font.AllUppercase - } - Text { - objectName: "heading" - Layout.fillWidth: true - Layout.topMargin: Math.round(12 * window.u) - text: window.tr(window.page === 0 ? "Welcome." - : window.page === 1 ? "Your account." - : window.page === 2 ? "Your time zone." - : setup.state === "done" ? "Ready." - : setup.state === "failed" ? "Setup did not finish." : "Setting up…") - color: window.ink - font.pixelSize: Math.round(38 * window.u) - font.weight: Font.DemiBold - font.letterSpacing: -1 - wrapMode: Text.WordWrap - Accessible.role: Accessible.Heading - Accessible.name: text - } - Text { - Layout.fillWidth: true - Layout.topMargin: Math.round(10 * window.u) - text: window.tr(window.page === 0 - ? "Let's set up this computer for you. First, choose your language and keyboard." - : window.page === 1 ? "You use it to sign in and to approve administrator tasks." - : window.page === 2 ? "Choose where you are, so the clock is right." - : setup.state === "done" ? "Your account is ready. The computer restarts to finish setting up." - : setup.state === "failed" ? "Nothing was kept. You can try again." - : "This takes a few minutes. Keep the computer on.") - color: window.inkMid - font.pixelSize: Math.round(16 * window.u) - wrapMode: Text.WordWrap - lineHeight: 1.45 - } - Item { Layout.preferredHeight: Math.round(22 * window.u) } - - // One page at a time, each sized to this area. (A StackLayout - // would take its height from the hidden pages' wrapped text.) - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - // ---- 01 welcome: language and keyboard ---------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 0 - spacing: Math.round(16 * window.u) - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: Math.round(24 * window.u) - SearchList { - id: languageList - objectName: "languageList" - Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredWidth: 1 - label: window.tr("Language") - placeholder: window.tr("Search") - entries: setup.locales - current: setup.locale - onChosen: id => setup.locale = id - } - SearchList { - id: keyboardList - objectName: "keyboardList" - Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredWidth: 1 - enabled: setup.state === "idle" - label: window.tr("Keyboard layout") - placeholder: window.tr("Search") - entries: setup.keyboards - current: setup.keyboard - onChosen: id => setup.chooseKeyboard(id) - } - } - Problem { - objectName: "keyboardProblem" - text: setup.state === "switching" ? "" - : window.problemText(window.serviceProblem("keyboard")) - } - Hint { - visible: setup.state === "switching" - text: window.tr("Switching keyboard layout…") - Accessible.role: Accessible.StaticText - } - Caption { text: window.tr("Try your keyboard") } - Input { - id: keyboardTest - objectName: "keyboardTest" - placeholderText: window.tr("Type here to test your keyboard") - inputMethodHints: Qt.ImhNoPredictiveText - Accessible.name: window.tr("Try your keyboard") - } - } - - // ---- 02 account --------------------------------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 1 - spacing: Math.round(16 * window.u) - RowLayout { - Layout.fillWidth: true - spacing: Math.round(16 * window.u) - Field { - id: fullNameField - objectName: "fullName" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Full name") - maximumLength: 128 - problem: window.problemText((window.showErrors ? window.fullNameProblem() : "") - || window.serviceProblem("full_name")) - onTextChanged: { - if (!window.usernameEdited) - usernameField.text = setup.deriveUsername(text); - } - onEdited: setup.clearError("full_name") - } - Field { - id: usernameField - objectName: "username" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Username") - hint: window.tr("Lowercase letters, numbers, dashes and underscores.") - maximumLength: 31 - inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText | Qt.ImhPreferLowercase - problem: window.problemText((window.showErrors ? window.usernameProblem() : "") - || window.serviceProblem("username")) - onEdited: { - window.usernameEdited = text !== ""; - setup.clearError("username"); - } - } - } - RowLayout { - Layout.fillWidth: true - spacing: Math.round(16 * window.u) - Field { - id: passwordField - objectName: "password" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Password") - echoMode: TextInput.Password - maximumLength: 512 - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - hint: window.tr("Use at least 12 characters.") - problem: window.problemText((window.showErrors ? window.passwordProblem() : "") - || window.serviceProblem("password")) - onEdited: setup.clearError("password") - } - Field { - id: confirmField - objectName: "confirm" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Confirm password") - echoMode: TextInput.Password - maximumLength: 512 - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - problem: window.problemText((window.showErrors ? window.confirmProblem() : "") - || window.serviceProblem("confirm")) - onEdited: setup.clearError("confirm") - } - } - // On an encrypted disk the account password replaces the - // temporary passphrase, which authorizes the change. - Rectangle { - visible: setup.encrypted - Layout.fillWidth: true - Layout.topMargin: Math.round(4 * window.u) - Layout.preferredHeight: diskColumn.implicitHeight + Math.round(32 * window.u) - radius: Math.round(16 * window.u) - color: window.surface - border.width: 1 - border.color: window.stroke - ColumnLayout { - id: diskColumn - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Math.round(16 * window.u) - spacing: Math.round(10 * window.u) - Text { - Layout.fillWidth: true - text: window.tr("This password also unlocks the disk when the computer starts.") - color: window.ink - font.pixelSize: Math.round(15 * window.u) - font.weight: Font.DemiBold - wrapMode: Text.WordWrap - } - Field { - id: startupField - objectName: "startup" - label: window.tr("Temporary disk passphrase") - echoMode: TextInput.Password - maximumLength: 512 - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - hint: window.tr("The passphrase you typed to start this computer. It stops working once setup is done.") - problem: window.problemText((window.showErrors ? window.startupProblem() : "") - || window.serviceProblem("startup")) - onEdited: setup.clearError("startup") - } - } - } - Item { Layout.fillHeight: true } - } - - // ---- 03 time zone ------------------------------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 2 - spacing: Math.round(12 * window.u) - SearchList { - id: timezoneList - objectName: "timezoneList" - Layout.fillWidth: true - Layout.fillHeight: true - label: window.tr("Time zone") - placeholder: window.tr("Search for a city") - entries: setup.timezones - current: window.timezone - onChosen: id => window.timezone = id - } - Problem { text: window.problemText(window.serviceProblem("timezone")) } - } - - // ---- 04 finishing ------------------------------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 3 - objectName: "finishing" - spacing: Math.round(14 * window.u) - Repeater { - model: setup.state === "working" ? setup.stages : [] - delegate: RowLayout { - id: stageRow - required property string modelData - required property int index - readonly property int reached: setup.stages.indexOf(setup.stage) - spacing: Math.round(14 * window.u) - Rectangle { - implicitWidth: Math.round(14 * window.u) - implicitHeight: implicitWidth - radius: implicitWidth / 2 - color: stageRow.index < stageRow.reached ? window.accent : "transparent" - border.width: Math.round(2 * window.u) - border.color: stageRow.index <= stageRow.reached ? window.accent : window.stroke - } - Text { - text: window.tr(window.stageLabels[stageRow.modelData]) - color: stageRow.index === stageRow.reached ? window.ink : window.inkMid - font.pixelSize: Math.round(16 * window.u) - font.weight: stageRow.index === stageRow.reached ? Font.DemiBold : Font.Normal - } - } - } - ProgressBar { - id: progressBar - visible: setup.state === "working" - Layout.fillWidth: true - Layout.topMargin: Math.round(10 * window.u) - indeterminate: true - background: Rectangle { - implicitHeight: Math.round(4 * window.u) - radius: height / 2 - color: window.stroke - } - contentItem: Item { - implicitHeight: Math.round(4 * window.u) - clip: true - Rectangle { - id: runner - width: parent.width * 0.3 - height: parent.height - radius: height / 2 - color: window.accent - NumberAnimation on x { - from: -runner.width - to: progressBar.width - duration: 1600 - loops: Animation.Infinite - running: progressBar.visible - } - } - } - } - Problem { - objectName: "failure" - text: setup.state === "failed" ? window.problemText("failed") - + (setup.errorDetail !== "" ? "\n" + setup.errorDetail : "") : "" - } - Hint { - visible: setup.state === "done" && setup.encrypted - text: window.tr("When it starts, unlock the disk with your new password.") - font.pixelSize: Math.round(16 * window.u) - color: window.ink - } - Hint { - visible: setup.state === "done" - text: window.tr("Restarting in %1 seconds").arg(window.countdown) - Accessible.role: Accessible.StaticText - } - Item { Layout.fillHeight: true } - } - } - - Rectangle { - Layout.fillWidth: true - Layout.topMargin: Math.round(20 * window.u) - Layout.preferredHeight: 1 - color: window.stroke - visible: primaryButton.visible - } - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Math.round(16 * window.u) - spacing: Math.round(16 * window.u) - Item { Layout.fillWidth: true } - ActionButton { - objectName: "backButton" - visible: window.page === 1 || window.page === 2 - || (window.page === 3 && setup.state === "failed") - text: window.tr("Back") - onClicked: window.back() - } - ActionButton { - id: primaryButton - objectName: "primaryButton" - primary: true - visible: window.page < 3 || setup.state === "failed" || setup.state === "done" - enabled: window.page !== 0 || setup.state === "idle" - text: window.tr(window.page === 2 ? "Set up" - : window.page === 3 && setup.state === "failed" ? "Try again" - : window.page === 3 ? "Restart now" : "Continue") - onClicked: window.next() - } - } - } - } - } -} diff --git a/image/rootfs/usr/share/cybexos/welcome/Main.qml b/image/rootfs/usr/share/cybexos/welcome/Main.qml deleted file mode 100644 index 8c0bbe41..00000000 --- a/image/rootfs/usr/share/cybexos/welcome/Main.qml +++ /dev/null @@ -1,771 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import QtQuick.Layouts -import QtQuick.Shapes - -// The first-login welcome. An installed system opens on a row of popular -// Wallhaven wallpapers (the shell searches, downloads and applies them; this -// window only asks over IPC) and one way onward, Settings. Closing the window -// finishes the welcome. A live session keeps its install-or-explore choice. -ApplicationWindow { - id: window - - readonly property color ink: "#f4f4f5" - readonly property color inkMid: "#b4b4bb" - readonly property color inkFaint: "#80808a" - readonly property color surface: "#17171a" - readonly property color surfaceHover: "#202024" - readonly property color stroke: "#2a2a30" - // Cybex red, from the wordmark. - readonly property color accent: "#dd0034" - readonly property color accentHover: "#f0164a" - readonly property color accentDown: "#b8002b" - readonly property color accentText: "#ff6b86" - - // Available geometry is logical (after display scaling) and excludes - // reserved desktop space. Keep the familiar size on roomy displays. - readonly property var workArea: { - // Re-evaluate when the screen or its reserved geometry changes. - void Screen.width; void Screen.height; - void Screen.desktopAvailableWidth; void Screen.desktopAvailableHeight; - return welcome.availableWorkArea(screen ? screen.name : ""); - } - readonly property real availableWidth: workArea.width - readonly property real availableHeight: workArea.height - width: Math.min(availableWidth, Math.max(760, Math.round(availableWidth * 0.5))) - height: Math.min(availableHeight, Math.max(660, Math.round(availableHeight * 0.48))) - minimumWidth: Math.min(360, availableWidth) - minimumHeight: Math.min(280, availableHeight) - visible: true - title: "Welcome to CybexOS" - color: "#0e0e10" - font.family: "Figtree" - font.pixelSize: 16 - palette.windowText: ink - palette.text: ink - palette.buttonText: ink - palette.button: surface - palette.highlight: accent - - readonly property real contentMargin: Math.min(40, Math.max(16, width * 0.05)) - - // Sequential focus reveals controls in a long, scrolled welcome too. - onActiveFocusItemChanged: Qt.callLater(revealFocus) - function revealFocus() { - const item = activeFocusItem; - if (!item) return; - let ancestor = item; - while (ancestor && ancestor !== content) ancestor = ancestor.parent; - if (!ancestor) return; - const top = item.mapToItem(viewport.contentItem, 0, 0).y; - const last = top + item.height; - const maxY = Math.max(0, viewport.contentHeight - viewport.height); - if (top < viewport.contentY) - viewport.contentY = Math.max(0, top - 8); - else if (last > viewport.contentY + viewport.height) - viewport.contentY = Math.min(maxY, last - viewport.height + 8); - } - - onClosing: close => { - // Installation-only mode has no desktop behind this window. - if (welcome.busy || welcome.isInstallMode) { - close.accepted = false; - return; - } - welcome.markComplete(); - } - - // A wallpaper picked elsewhere (Settings, rotation) while this window - // was in the background shows up as current when it comes back. - onActiveChanged: { - if (active && !welcome.isLive) - welcome.refreshWallpapers() - } - - Component.onCompleted: { - if (welcome.isInstallMode) - welcome.install(); - else if (!welcome.isLive) - welcome.loadWallpapers(); - } - - Shortcut { - sequence: "Escape" - onActivated: window.close() - } - - Connections { - target: welcome - function onChanged() { - // Keep the process alive to report failures, while giving the - // installer the full desktop instead of tiling two windows. - if (welcome.busy) window.hide(); - else window.show(); - } - function onActivate() { - window.show(); - window.raise(); - window.requestActivate(); - } - } - - component ActionButton: Button { - id: button - property bool primary: false - implicitHeight: 48 - leftPadding: 26 - rightPadding: 26 - font.weight: Font.DemiBold - background: Rectangle { - radius: 12 - color: button.primary - ? (button.down ? window.accentDown : button.hovered ? window.accentHover : window.accent) - : (button.hovered ? window.surfaceHover : window.surface) - opacity: button.enabled ? 1 : 0.5 - border.width: button.visualFocus ? 2 : 0 - border.color: "#ffffff" - } - contentItem: Text { - text: button.text - color: button.primary ? "#ffffff" : window.ink - font: button.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - - component SectionLabel: Text { - color: window.inkFaint - font.pixelSize: 12 - font.weight: Font.DemiBold - font.letterSpacing: 1.6 - font.capitalization: Font.AllUppercase - } - - // A wallpaper card: the thumbnail with rounded corners, a red ring and - // check on the current one, and a scrim with progress while it downloads. - component WallpaperCard: Item { - id: card - property string thumb: "" - property bool current: false - property bool downloading: false - property int progress: 0 - // Keyboard focus lives on the row; the row says which card has it. - property bool focused: false - property bool hovered: cardMouse.containsMouse - signal picked() - - width: 256 - height: 144 - readonly property real radius: Math.round(height / 12) - - Rectangle { - anchors.fill: parent - radius: card.radius - color: window.surface - Text { - anchors.centerIn: parent - visible: thumbImage.status !== Image.Ready - text: thumbImage.status === Image.Error ? "Could not load" : "Loading…" - color: window.inkFaint - font.pixelSize: 13 - } - } - - Image { - id: thumbImage - anchors.fill: parent - source: card.thumb - sourceSize.width: 512 - sourceSize.height: 288 - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: true - visible: false - layer.enabled: true - } - - Rectangle { - id: cardMask - anchors.fill: parent - radius: card.radius - visible: false - layer.enabled: true - } - - MultiEffect { - anchors.fill: parent - source: thumbImage - maskEnabled: true - maskSource: cardMask - visible: thumbImage.status === Image.Ready - // A little lift on hover, without moving the card. - brightness: card.hovered && !card.downloading ? 0.06 : 0 - } - - Rectangle { - anchors.fill: parent - radius: card.radius - visible: card.downloading - color: Qt.rgba(0, 0, 0, 0.55) - Text { - anchors.centerIn: parent - text: "Downloading " + card.progress + "%" - color: "#ffffff" - font.pixelSize: 14 - font.weight: Font.DemiBold - } - Rectangle { - anchors.left: parent.left - anchors.bottom: parent.bottom - anchors.margins: 10 - height: 4 - radius: 2 - width: (parent.width - 20) * card.progress / 100 - color: window.accent - } - } - - Rectangle { - anchors.fill: parent - radius: card.radius - color: "transparent" - border.width: card.current ? 3 : card.focused ? 2 : card.hovered ? 1 : 0 - border.color: card.current ? window.accent : "#ffffff" - } - - Rectangle { - visible: card.current - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: 10 - width: 26 - height: 26 - radius: 13 - color: window.accent - // Drawn rather than typed: Figtree has no check mark, and the - // fallback font drew a "V". - Shape { - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - ShapePath { - strokeColor: "#ffffff" - strokeWidth: 2.4 - fillColor: "transparent" - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - startX: 7.5; startY: 13.5 - PathLine { x: 11.2; y: 17.2 } - PathLine { x: 18.5; y: 9.2 } - } - } - } - - MouseArea { - id: cardMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: card.picked() - } - } - - ScrollView { - id: scrollView - objectName: "welcomeScroll" - anchors.fill: parent - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - - Flickable { - id: viewport - objectName: "welcomeViewport" - contentWidth: width - contentHeight: content.height + window.contentMargin * 2 - boundsBehavior: Flickable.StopAtBounds - } - } - - ColumnLayout { - id: content - parent: viewport.contentItem - x: window.contentMargin - y: window.contentMargin - width: viewport.width - window.contentMargin * 2 - height: Math.max(implicitHeight, viewport.height - window.contentMargin * 2) - spacing: 0 - - RowLayout { - Layout.fillWidth: true - Image { - source: "cybex-wordmark.svg" - Layout.preferredHeight: 30 - Layout.preferredWidth: 30 * 198 / 46.6 - sourceSize.height: 90 - fillMode: Image.PreserveAspectFit - smooth: true - Accessible.role: Accessible.Graphic - Accessible.name: "Cybex" - } - Item { Layout.fillWidth: true } - SectionLabel { - text: welcome.isLive ? "Live preview · Alpha" : "Your desktop" - } - } - - Item { Layout.preferredHeight: 44 } - - Text { - Layout.fillWidth: true - text: welcome.isInstallMode ? "Let’s install CybexOS." : welcome.isLive ? "Make yourself at home." : "Welcome to your new desktop." - color: window.ink - font.pixelSize: 38 - font.weight: Font.DemiBold - wrapMode: Text.WordWrap - } - - Item { Layout.preferredHeight: 10 } - - Text { - Layout.fillWidth: true - text: welcome.isInstallMode - ? "The installer opens by itself. If you close it, open it again here, or start the live desktop instead." - : welcome.isLive - ? "Explore the desktop from your USB drive, or install it when you’re ready." - : "Pick a wallpaper to start: the desktop’s colors follow it. You can come back here any time from the app launcher." - color: window.inkMid - wrapMode: Text.WordWrap - lineHeight: 1.35 - } - - Item { Layout.preferredHeight: welcome.isLive ? 34 : 20 } - - Rectangle { - visible: !welcome.isLive - Layout.fillWidth: true - Layout.preferredHeight: setupRows.implicitHeight + 24 - radius: 12 - color: window.surface - ColumnLayout { - id: setupRows - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 12 - spacing: 7 - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: welcome.networkState === "connected" ? "Network connected" - : welcome.networkState === "checking" ? "Checking network…" : "Network connection needed" - color: welcome.networkState === "connected" ? window.inkMid : window.accentText - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - Button { - visible: welcome.networkState === "offline" - text: "Open network settings" - flat: true - onClicked: welcome.openSettings("network") - } - } - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: welcome.hardwareState === "completed" ? "Hardware setup complete" - : welcome.hardwareState === "skipped" ? "Hardware setup: no special device support needed" - : welcome.hardwareState === "pending" && welcome.hardwareNote.indexOf("reboot") >= 0 - ? "Camera update installed — reboot to finish validation" - : welcome.hardwareState === "pending" - && (welcome.nvidiaState === "reboot" || welcome.nvidiaState === "enroll") - ? "Hardware setup continues after a restart" - : welcome.hardwareState === "running" ? "Hardware setup in progress" - : welcome.hardwareState === "failed" ? "Hardware setup needs another attempt" - : "Hardware setup pending" - color: welcome.hardwareState === "failed" || welcome.hardwareState === "pending" - ? window.accentText : window.inkMid - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - Button { - visible: welcome.hardwareState === "failed" - text: "Retry hardware" - flat: true - onClicked: welcome.retryHardware() - } - } - // Only on NVIDIA systems. A failure also marks the hardware - // row above as failed, which carries the retry button. - Text { - Layout.fillWidth: true - visible: welcome.nvidiaState !== "" - text: welcome.nvidiaState === "ready" ? "NVIDIA driver ready" - : welcome.nvidiaState === "checking" ? "Checking the NVIDIA driver…" - : welcome.nvidiaState === "reboot" ? "NVIDIA driver installed — restart to start using it" - : welcome.nvidiaState === "enroll" - ? "Secure Boot: restart, choose Enroll MOK at the blue screen, then Continue and Yes, and enter " - + (welcome.nvidiaPassword !== "" ? "the one-time password " + welcome.nvidiaPassword - : "the one-time password from the notification") - : welcome.nvidiaState === "skipped" ? "Secure Boot key not enrolled — the open-source driver is in use" - : welcome.nvidiaState === "failed" ? "NVIDIA driver setup failed — the open-source driver is in use" - : "NVIDIA driver installs when the network is connected" - color: welcome.nvidiaState === "ready" || welcome.nvidiaState === "checking" - ? window.inkMid : window.accentText - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: welcome.seedState === "ready" ? "Offline applications ready" - : welcome.seedState === "copying" ? "Preparing offline applications…" - : welcome.seedState === "error" ? "Offline application preparation interrupted" - : "Offline applications waiting to prepare" - color: welcome.seedState === "error" ? window.accentText : window.inkMid - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - Button { - visible: welcome.seedState === "error" - text: "Retry apps" - flat: true - onClicked: welcome.retryApps() - } - } - Text { - Layout.fillWidth: true - visible: welcome.reconcileState !== "ready" - text: welcome.reconcileState === "error" || welcome.reconcileState === "blocked" - ? "Desktop policy update needs attention. Run cybex doctor for details." - : "Applying desktop policy updates…" - color: welcome.reconcileState === "error" || welcome.reconcileState === "blocked" - ? window.accentText : window.inkMid - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - } - } - - Item { Layout.preferredHeight: welcome.isLive ? 0 : 14 } - - // ---- installed: make it yours --------------------------------- - ColumnLayout { - visible: !welcome.isLive - Layout.fillWidth: true - // The row takes the free height (up to its largest cards). - Layout.fillHeight: true - spacing: 14 - - RowLayout { - Layout.fillWidth: true - SectionLabel { text: "Make it yours" } - Item { Layout.fillWidth: true } - Text { - text: welcome.wallpaperSource === "online" ? "Popular on wallhaven.cc" - : welcome.wallpaperSource === "local" ? "On this computer" : "" - color: window.inkFaint - font.pixelSize: 13 - } - } - - Item { - id: rowArea - // Cards grow into the window's free height, from 144 to 234 - // pixels tall, so a large window shows larger previews rather - // than an empty band above the footer. - readonly property real cardHeight: Math.max(144, Math.min(234, height - 18)) - readonly property real cardWidth: Math.round(cardHeight * 16 / 9) - Layout.fillWidth: true - Layout.fillHeight: true - // Takes the free height before the spacer below does. - Layout.verticalStretchFactor: 1000 - Layout.minimumHeight: 144 + 18 - Layout.maximumHeight: 234 + 18 - - ListView { - id: wallpaperRow - anchors.fill: parent - orientation: ListView.Horizontal - spacing: 14 - clip: true - boundsBehavior: Flickable.StopAtBounds - model: welcome.wallpapers - activeFocusOnTab: count > 0 - keyNavigationEnabled: true - highlightFollowsCurrentItem: false - Accessible.role: Accessible.List - Accessible.name: "Wallpapers" - // Shown whenever there is more to the side, so the row - // reads as scrollable before anyone tries. - ScrollBar.horizontal: ScrollBar { - policy: wallpaperRow.contentWidth > wallpaperRow.width - ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff - } - - Keys.onReturnPressed: currentItem && currentItem.picked() - Keys.onEnterPressed: currentItem && currentItem.picked() - Keys.onSpacePressed: currentItem && currentItem.picked() - - delegate: WallpaperCard { - required property var modelData - required property int index - width: rowArea.cardWidth - height: rowArea.cardHeight - thumb: modelData.thumb - current: modelData.fileName === welcome.currentFile - downloading: modelData.key === welcome.downloadingKey - progress: welcome.downloadProgress - focused: wallpaperRow.activeFocus && wallpaperRow.currentIndex === index - Accessible.role: Accessible.Button - Accessible.name: "Use wallpaper " + modelData.label - onPicked: { - wallpaperRow.currentIndex = index; - welcome.pickWallpaper(modelData.key); - } - } - - // The rest of Wallhaven lives in Settings → Wallpaper → Online. - footer: Item { - width: moreCard.width + 14 - height: rowArea.cardHeight - Rectangle { - id: moreCard - x: 14 - width: Math.round(rowArea.cardWidth * 0.7) - height: rowArea.cardHeight - radius: Math.round(height / 12) - color: moreMouse.containsMouse ? window.surfaceHover : window.surface - border.width: 1 - border.color: window.stroke - Column { - anchors.centerIn: parent - spacing: 6 - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: "More online →" - color: window.ink - font.weight: Font.DemiBold - } - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: "Search in Settings" - color: window.inkFaint - font.pixelSize: 13 - } - } - MouseArea { - id: moreMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: welcome.openSettings("wallpaper") - } - } - } - } - - // A vertical wheel scrolls the row sideways; clicks and - // hovers still reach the cards beneath. - MouseArea { - anchors.fill: wallpaperRow - acceptedButtons: Qt.NoButton - onWheel: wheel => { - const delta = wheel.pixelDelta.x !== 0 || wheel.pixelDelta.y !== 0 - ? (wheel.pixelDelta.x !== 0 ? wheel.pixelDelta.x : wheel.pixelDelta.y) - : (wheel.angleDelta.x !== 0 ? wheel.angleDelta.x : wheel.angleDelta.y) / 120 * rowArea.cardWidth / 2; - const start = wallpaperRow.originX; - const end = start + Math.max(0, wallpaperRow.contentWidth - wallpaperRow.width); - wallpaperRow.contentX = Math.max(start, Math.min(end, wallpaperRow.contentX - delta)); - } - } - - // Loading and waiting states stand in for the row until it - // has something to show. - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - height: rowArea.cardHeight - radius: 12 - visible: wallpaperRow.count === 0 - color: window.surface - Text { - anchors.centerIn: parent - width: parent.width - 48 - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - text: welcome.wallpaperSource === "waiting" ? "Waiting for the desktop to start…" - : welcome.wallpaperSource === "unavailable" ? welcome.wallpaperNote - : "Finding popular wallpapers…" - color: window.inkMid - } - } - } - - RowLayout { - Layout.fillWidth: true - visible: welcome.wallpaperNote !== "" && wallpaperRow.count > 0 - || welcome.wallpaperSource === "local" || welcome.wallpaperSource === "unavailable" - spacing: 12 - Text { - Layout.fillWidth: true - visible: wallpaperRow.count > 0 - text: welcome.wallpaperNote - color: window.inkMid - font.pixelSize: 14 - wrapMode: Text.WordWrap - Accessible.role: Accessible.AlertMessage - } - Item { Layout.fillWidth: wallpaperRow.count === 0 } - Button { - visible: welcome.wallpaperSource === "local" || welcome.wallpaperSource === "unavailable" - text: "Try again" - flat: true - onClicked: welcome.loadWallpapers() - } - } - - // Height the largest cards cannot use collects here, under the - // row, instead of spreading gaps between the heading and the row. - Item { - Layout.fillHeight: true - Layout.verticalStretchFactor: 1 - } - } - - // ---- live: guided installation -------------------------------- - Rectangle { - visible: welcome.isLive - Layout.fillWidth: true - Layout.preferredHeight: details.implicitHeight + 40 - radius: 16 - color: window.surface - ColumnLayout { - id: details - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 20 - spacing: 14 - Text { - text: "A guided installation" - color: window.ink - font.weight: Font.DemiBold - } - Text { - Layout.fillWidth: true - text: "Create your account, choose a disk, and review. Encryption and automatic login are selected by default." - color: window.inkMid - wrapMode: Text.WordWrap - lineHeight: 1.3 - } - Text { - Layout.fillWidth: true - text: "Your disk and account passwords start out the same. Changing the account password later does not update the disk password." - color: window.accentText - wrapMode: Text.WordWrap - font.pixelSize: 14 - } - } - } - - Text { - Layout.fillWidth: true - Layout.topMargin: 14 - visible: welcome.status.length > 0 - text: welcome.status - color: window.accentText - wrapMode: Text.WordWrap - font.pixelSize: 14 - Accessible.role: Accessible.AlertMessage - } - - Item { Layout.fillHeight: welcome.isLive } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: window.stroke - } - - Item { Layout.preferredHeight: 20 } - - GridLayout { - Layout.fillWidth: true - columns: window.width < 700 ? 1 : 2 - columnSpacing: 16 - rowSpacing: 16 - - ColumnLayout { - visible: !welcome.isLive - Layout.fillWidth: true - spacing: 4 - Text { - Layout.fillWidth: true - text: "Press Super + K to see every keyboard shortcut." - color: window.inkMid - font.pixelSize: 14 - wrapMode: Text.WordWrap - } - Text { - Layout.fillWidth: true - text: "Your app keyring stays encrypted and unlocks with the disk password when they match." - color: window.inkFaint - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - } - - Flow { - Layout.fillWidth: true - Layout.columnSpan: welcome.isLive ? parent.columns : 1 - Layout.minimumWidth: window.width < 700 ? 0 : 170 - Layout.preferredWidth: welcome.isLive ? content.width : 170 - spacing: 8 - layoutDirection: Qt.RightToLeft - ActionButton { - objectName: "welcomePrimaryAction" - primary: true - text: welcome.isLive ? (welcome.busy ? "Installer is open…" : "Install CybexOS") : "Settings" - enabled: !welcome.busy - onClicked: welcome.isLive ? welcome.install() : welcome.openSettings("appearance") - } - - Button { - visible: welcome.isInstallMode - text: "Power off" - enabled: !welcome.busy - flat: true - onClicked: welcome.power("poweroff") - } - - Button { - visible: welcome.isInstallMode - text: "Restart" - enabled: !welcome.busy - flat: true - onClicked: welcome.power("reboot") - } - - Button { - visible: welcome.isInstallMode - text: "Start the live desktop" - enabled: !welcome.busy - flat: true - onClicked: welcome.startDesktop() - } - - Button { - visible: welcome.isLive && !welcome.isInstallMode - text: "Try the desktop first →" - enabled: !welcome.busy - flat: true - onClicked: welcome.finish() - } - } - } - } -} diff --git a/image/rootfs/usr/share/cybexos/welcome/cybex-wordmark.svg b/image/rootfs/usr/share/cybexos/welcome/cybex-wordmark.svg deleted file mode 100644 index be3df348..00000000 --- a/image/rootfs/usr/share/cybexos/welcome/cybex-wordmark.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Cybex wordmark — for dark backgrounds - - - - - - - - diff --git a/image/rootfs/usr/share/wayland-sessions/hyprland-quickshell.desktop b/image/rootfs/usr/share/wayland-sessions/hyprland-quickshell.desktop deleted file mode 100644 index c067dc5a..00000000 --- a/image/rootfs/usr/share/wayland-sessions/hyprland-quickshell.desktop +++ /dev/null @@ -1,7 +0,0 @@ -[Desktop Entry] -Name=CybexOS -Comment=Hyprland with the Quickshell desktop -Exec=/usr/bin/hyprland-quickshell -TryExec=/usr/bin/hyprland-quickshell -Type=Application -DesktopNames=Hyprland diff --git a/image/sbom.py b/image/sbom.py deleted file mode 100644 index 4427d622..00000000 --- a/image/sbom.py +++ /dev/null @@ -1,865 +0,0 @@ -"""SPDX 2.3 software bill of materials for the CybexOS live ISO payload. - -The image compose records raw inventories of the finished image root: every -installed RPM and Flatpak ref. This module combines them with the -checksum-pinned downloads in the inventory and the build provenance into one -deterministic SPDX JSON document. It uses only the standard library; reading -the Ansible inventory additionally needs PyYAML, which every builder and -release host already installs for the rest of the image tooling. -""" -import argparse -from datetime import datetime, timezone -import hashlib -import json -import os -from pathlib import Path -import re -import subprocess -import sys -from urllib.parse import quote, urlsplit -import uuid - -ROOT = Path(__file__).resolve().parents[1] -REPOSITORY = "DigitalPals/CybexOS" -SUPPLIER = "Organization: DigitalPals" -ROOT_LICENSE = "MIT" -ROOT_ID = "SPDXRef-CybexOS" -DOCUMENT_ID = "SPDXRef-DOCUMENT" -DESKTOP_PACKAGE = "cybexos-desktop" -OFFLINE_PACKAGE = "cybexos-offline-apps" -# GitHub's SBOM attestation rejects predicates above 16 MiB; fail at build time. -SIZE_LIMIT = 16 * 1024 * 1024 - -# Raw inventories written beside the build artifacts by image/compose. -RPM_INVENTORY = "rpm-inventory.tsv" -FLATPAK_INVENTORY = "flatpak-inventory.tsv" -RPM_FIELDS = ("name", "epoch", "version", "release", "arch", "license", "sourcerpm", - "vendor", "packager", "url") -# One package per line, tab-separated in RPM_FIELDS order. rpm prints "(none)" -# for an absent tag; the parser treats it as an empty value. -RPM_QUERYFORMAT = ("%{NAME}\t%{EPOCH}\t%{VERSION}\t%{RELEASE}\t%{ARCH}\t%{LICENSE}\t" - "%{SOURCERPM}\t%{VENDOR}\t%{PACKAGER}\t%{URL}\n") -FLATPAK_COLUMNS = "application,arch,branch,origin,version" -FLATPAK_FIELDS = ("kind", "application", "arch", "branch", "origin", "commit", "version") - -# Copies of URLs that live in role scripts rather than the inventory; the -# tests keep them equal to the installers that actually download them. -OMAWRITE_REPOSITORY = "omacom/omawrite" -LAZYVIM_STARTER = "https://github.com/LazyVim/starter.git" - -# purl namespaces for known RPM vendors; the order matters (Copr before Fedora). -RPM_NAMESPACES = ( - (r"fedora copr", "copr"), - (r"^fedora( project)?$", "fedora"), - (r"rpm ?fusion", "rpmfusion"), - (r"brave", "brave"), - (r"1password|agilebits", "1password"), - (r"openai", "openai"), - (r"tailscale", "tailscale"), - (r"cybexos|digitalpals", "cybexos"), -) - -NAME = re.compile(r"[A-Za-z0-9_+][A-Za-z0-9._+-]*") -EVR_PART = re.compile(r"[A-Za-z0-9._+~^]+") -FLATPAK_ID = re.compile(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+") -FLATPAK_PART = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*") -COMMIT = re.compile(r"[0-9a-f]{64}") -SHA256 = re.compile(r"[0-9a-f]{64}") -REVISION = re.compile(r"[0-9a-f]{40,64}") -SPDX_ID = re.compile(r"SPDXRef-[A-Za-z0-9.-]+") -LICENSE_REF = re.compile(r"LicenseRef-[A-Za-z0-9.-]+") -LICENSE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9.-]*\+?") -# Callaway-era Fedora names and vendor words are syntactically plausible but -# are not SPDX identifiers. Such tags fall back to a LicenseRef. -NOT_SPDX = re.compile(r"(?i)(?:a|l)?gplv\d.*|mplv\d.*|asl|bsd|gpl|lgpl|proprietary|commercial|" - r"freeware|freely|redistributable|distributable|unknown|other|restricted|" - r"shareware|copyright|public|domain") -FEDORA_LICENSE_DATA = "https://gitlab.com/fedora/legal/fedora-license-data" - - -def absent(value): - value = value.strip() - return "" if value in ("", "(none)") else value - - -def sha256_file(path): - with Path(path).open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def checksum(value): - value = str(value).lower().removeprefix("sha256:") - if not SHA256.fullmatch(value): - raise ValueError(f"Expected a SHA-256 digest: {value!r}") - return value - - -def spdx_text(value): - """Collapse whitespace so free-form vendor strings stay single-line.""" - return " ".join(str(value).split()) - - -# ── Raw inventories ─────────────────────────────────────────────────────── - -def parse_rpm_inventory(text): - """Parse RPM_QUERYFORMAT output. Imported gpg-pubkey entries are not packages.""" - packages = [] - for number, line in enumerate(text.split("\n"), 1): - if not line.strip(): - continue - fields = line.split("\t") - if len(fields) != len(RPM_FIELDS): - raise ValueError(f"RPM inventory line {number}: expected {len(RPM_FIELDS)} tab-separated fields") - record = {key: absent(value) for key, value in zip(RPM_FIELDS, fields)} - if record["name"] == "gpg-pubkey": - continue - if not NAME.fullmatch(record["name"]): - raise ValueError(f"RPM inventory line {number}: invalid package name") - if any(not EVR_PART.fullmatch(record[key]) for key in ("version", "release", "arch")): - raise ValueError(f"RPM inventory line {number}: invalid version, release or architecture") - if record["epoch"] and not record["epoch"].isdigit(): - raise ValueError(f"RPM inventory line {number}: invalid epoch") - packages.append(record) - if not packages: - raise ValueError("RPM inventory is empty") - return packages - - -def parse_flatpak_inventory(text): - refs = [] - for number, line in enumerate(text.split("\n"), 1): - if not line.strip(): - continue - fields = line.split("\t") - if len(fields) != len(FLATPAK_FIELDS): - raise ValueError(f"Flatpak inventory line {number}: expected {len(FLATPAK_FIELDS)} tab-separated fields") - record = {key: value.strip() for key, value in zip(FLATPAK_FIELDS, fields)} - if (record["kind"] not in ("app", "runtime") or not FLATPAK_ID.fullmatch(record["application"]) - or not FLATPAK_PART.fullmatch(record["arch"]) or not FLATPAK_PART.fullmatch(record["branch"]) - or not COMMIT.fullmatch(record["commit"]) - or (record["origin"] and not FLATPAK_PART.fullmatch(record["origin"]))): - raise ValueError(f"Flatpak inventory line {number}: invalid ref, origin or commit") - refs.append(record) - return refs - - -def active_commit(root, kind, application, arch, branch): - """Read the full deployed commit; `flatpak list` truncates it to 12 digits.""" - if not FLATPAK_ID.fullmatch(application) or not all( - FLATPAK_PART.fullmatch(part) and part != ".." for part in (arch, branch)): - raise ValueError(f"Unsafe Flatpak ref: {kind}/{application}/{arch}/{branch}") - link = Path(root) / "var/lib/flatpak" / kind / application / arch / branch / "active" - commit = os.readlink(link) if link.is_symlink() else "" - if not COMMIT.fullmatch(commit): - raise ValueError(f"No deployed commit for {kind}/{application}/{arch}/{branch}") - return commit - - -def capture_inventories(root, directory, run=subprocess.run): - """Record the composed image root in the formats parsed above.""" - root, directory = Path(root), Path(directory) - rpms = run(["rpm", "--root", str(root), "-qa", "--queryformat", RPM_QUERYFORMAT], - check=True, capture_output=True, text=True).stdout - lines = sorted(line for line in rpms.split("\n") if line.strip()) - rpm_text = "".join(line + "\n" for line in lines) - parse_rpm_inventory(rpm_text) # Fail the build now rather than at release time. - rows = [] - for kind in ("app", "runtime"): - listing = run(["chroot", str(root), "flatpak", "list", "--system", "--" + kind, - "--columns=" + FLATPAK_COLUMNS], check=True, capture_output=True, text=True, - env={**os.environ, "LC_ALL": "C"}).stdout - lines = [line for line in listing.split("\n") if line.strip()] - # Terminal output starts with a title row; piped output normally has none. - if lines and not FLATPAK_ID.fullmatch(lines[0].split("\t")[0].strip()): - lines.pop(0) - for line in lines: - fields = [field.strip() for field in line.split("\t")] - # Non-terminal output is tab-separated; an empty version may be dropped. - if not 4 <= len(fields) <= 5: - raise ValueError(f"Unexpected flatpak list output: {line!r}") - application, arch, branch, origin, version = fields + [""] * (5 - len(fields)) - commit = active_commit(root, kind, application, arch, branch) - rows.append("\t".join((kind, application, arch, branch, origin, commit, version))) - flatpak_text = "".join(row + "\n" for row in sorted(rows)) - parse_flatpak_inventory(flatpak_text) - (directory / RPM_INVENTORY).write_text(rpm_text) - (directory / FLATPAK_INVENTORY).write_text(flatpak_text) - return directory / RPM_INVENTORY, directory / FLATPAK_INVENTORY - - -# ── Licenses and identifiers ────────────────────────────────────────────── - -def _license_tokens(expression): - return re.findall(r"\(|\)|[^\s()]+", expression) - - -def valid_license_expression(expression): - """Loose SPDX 2.3 grammar check: listed-looking ids, LicenseRefs, AND/OR/WITH.""" - tokens = _license_tokens(expression) - position = 0 - - def peek(): - return tokens[position] if position < len(tokens) else None - - def identifier(token): - if token is None or token in ("AND", "OR", "WITH", "(", ")"): - return False - if token.startswith(("LicenseRef-", "DocumentRef-")): - # A LicenseRef cannot take "+"; external documents are not declared here. - return bool(LICENSE_REF.fullmatch(token)) - return bool(LICENSE_ID.fullmatch(token)) and not NOT_SPDX.fullmatch(token.rstrip("+")) - - def factor(): - nonlocal position - token = peek() - if token == "(": - position += 1 - if not compound() or peek() != ")": - return False - position += 1 - return True - if not identifier(token): - return False - position += 1 - if peek() == "WITH": - position += 1 - exception = peek() - if not identifier(exception) or exception.startswith("LicenseRef-") or exception.endswith("+"): - return False - position += 1 - return True - - def compound(): - nonlocal position - if not factor(): - return False - while peek() in ("AND", "OR"): - position += 1 - if not factor(): - return False - return True - - return bool(tokens) and compound() and position == len(tokens) - - -def license_ref_id(raw): - slug = re.sub(r"[^A-Za-z0-9.]+", "-", raw).strip("-.")[:48].strip("-.") or "unknown" - return f"LicenseRef-rpm-{slug}-{hashlib.sha256(raw.encode()).hexdigest()[:8]}" - - -def declared_license(raw, extracted): - """Return an SPDX expression; record any LicenseRef text in `extracted`.""" - raw = spdx_text(raw) - if raw in ("", "(none)"): - return "NOASSERTION" - if raw in ("NOASSERTION", "NONE"): - return raw - if valid_license_expression(raw): - expression = " ".join(_license_tokens(raw)).replace("( ", "(").replace(" )", ")") - for reference in sorted(set(LICENSE_REF.findall(expression))): - entry = {"licenseId": reference, "name": reference, - "extractedText": "The package's License tag references this identifier; " - "its text is in the package's license files."} - if reference.startswith(("LicenseRef-Fedora-", "LicenseRef-Callaway-")): - entry["seeAlsos"] = [FEDORA_LICENSE_DATA] - extracted.setdefault(reference, entry) - return expression - reference = license_ref_id(raw) - extracted.setdefault(reference, { - "licenseId": reference, "name": raw, "extractedText": raw, - "comment": "Verbatim RPM License tag that is not a valid SPDX license expression."}) - return reference - - -def spdx_id(prefix, *parts): - return re.sub(r"[^A-Za-z0-9.-]+", "-", "-".join(("SPDXRef", prefix, *parts))).rstrip("-") - - -def purl(kind, namespace, name, version=None, qualifiers=None): - """Canonical-enough package URL: lowercase namespace, sorted encoded qualifiers.""" - text = "pkg:" + kind + "/" - if namespace: - text += "/".join(quote(segment, safe="") for segment in namespace.lower().split("/")) + "/" - text += quote(name.lower() if kind == "github" else name, safe="") - if version: - text += "@" + quote(version, safe="") - pairs = sorted((key, str(value)) for key, value in (qualifiers or {}).items() if value) - if pairs: - text += "?" + "&".join(f"{key}={quote(value, safe=':/')}" for key, value in pairs) - return text - - -def purl_reference(locator): - return {"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": locator} - - -def http_url(value): - parts = urlsplit(value or "") - if parts.scheme in ("http", "https") and parts.netloc and not any(char.isspace() for char in value): - return value - return None - - -def rpm_namespace(record, owners): - vendor = record["vendor"].lower() - for pattern, namespace in RPM_NAMESPACES: - if vendor and re.search(pattern, vendor): - return namespace - if record["name"] == DESKTOP_PACKAGE: - return "cybexos" - if not vendor: - # Inventory-pinned GitHub RPMs carry no Vendor tag; their owner is the vendor. - return owners.get(record["name"]) - return re.sub(r"[^a-z0-9.]+", "-", vendor).strip("-.") or None - - -def supplier(record): - for key in ("vendor", "packager"): - value = spdx_text(record[key]) - if value: - match = re.fullmatch(r"(.*?)\s*<([^<>\s]+@[^<>\s]+)>", value) - return "Organization: " + (f"{match[1]} ({match[2]})" if match and match[1] else value) - return "NOASSERTION" - - -# ── Components ──────────────────────────────────────────────────────────── - -def rpm_package(record, fedora_release, owners, extracted): - evr = f"{record['version']}-{record['release']}" - version = f"{record['epoch']}:{evr}" if record["epoch"] else evr - package = { - "SPDXID": spdx_id("RPM", record["name"], version.replace(":", "-"), record["arch"]), - "name": record["name"], - "versionInfo": version, - "supplier": supplier(record), - # The URL tag is the upstream project, not where this binary came from. - "downloadLocation": "NOASSERTION", - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": declared_license(record["license"], extracted), - "copyrightText": "NOASSERTION", - "externalRefs": [purl_reference(purl( - "rpm", rpm_namespace(record, owners), record["name"], evr, - {"arch": record["arch"], "epoch": record["epoch"], "distro": f"fedora-{fedora_release}"}))], - } - if record["sourcerpm"]: - package["sourceInfo"] = f"built from source RPM {record['sourcerpm']}" - homepage = http_url(record["url"]) - if homepage: - package["homepage"] = homepage - return package - - -def flatpak_package(record): - ref = "/".join((record["kind"], record["application"], record["arch"], record["branch"])) - package = { - "SPDXID": spdx_id("Flatpak", record["kind"], record["application"], record["arch"], record["branch"]), - "name": record["application"], - "versionInfo": record["version"] or record["branch"], - "supplier": "Organization: Flathub" if record["origin"] == "flathub" else "NOASSERTION", - "downloadLocation": "NOASSERTION", - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": "NOASSERTION", - "copyrightText": "NOASSERTION", - "primaryPackagePurpose": "APPLICATION" if record["kind"] == "app" else "FRAMEWORK", - "sourceInfo": f"Flatpak {ref} at OSTree commit {record['commit']} from remote " - f"{record['origin'] or 'unknown'}", - # There is no purl type for Flatpak; the ref and commit identify it exactly. - "externalRefs": [ - {"referenceCategory": "OTHER", "referenceType": "flatpak-ref", "referenceLocator": ref}, - {"referenceCategory": "OTHER", "referenceType": "ostree-commit", "referenceLocator": record["commit"]}, - ], - } - return package - - -def literal_asset(pattern): - """Return the filename an anchored asset regex can match, if it is literal.""" - body = str(pattern).removeprefix("^").removesuffix("$").replace("[.]", ".").replace(r"\.", ".") - return body if re.fullmatch(r"[A-Za-z0-9_.+-]+", body) else None - - -def feature_enabled(value, features): - if isinstance(value, bool): - return value - match = re.fullmatch(r"\{\{\s*features\.([A-Za-z0-9_]+)(?:\s*\|\s*bool)?\s*\}\}", str(value).strip()) - if match: - value = features.get(match[1], False) - if isinstance(value, bool): - return value - text = str(value).strip().lower() - if text in ("true", "yes", "on", "1", "false", "no", "off", "0"): - return text in ("true", "yes", "on", "1") - # Fail closed: an unknown condition could add or hide a payload component. - raise ValueError(f"Unsupported enable condition for the SBOM: {value!r}") - - -def download_assets(manifest): - """Map pinned digests to the exact upstream filenames the builder downloaded.""" - if not manifest: - return {} - manifest = Path(manifest) - if not manifest.is_file(): - return {} - assets = {} - for entry in json.loads(manifest.read_text()).get("entries", []): - path = str(entry.get("path", "")) - name = path.rsplit("/", 1)[-1] - if path.startswith("/var/cache/cybexos-upstream/") and re.fullmatch(r"[A-Za-z0-9_.+-]+", name): - assets[checksum(entry.get("sha256", ""))] = name - return assets - - -def _github(url): - parts = urlsplit(url) - path = parts.path.removesuffix(".git").strip("/").split("/") - if parts.netloc == "github.com" and len(path) == 2: - return path - return None - - -def pinned_inputs(inventory, apps_defaults=None, assets=None): - """Checksum- or commit-pinned downloads that the image payload contains. - - Returns (package, placement) pairs. Placement "desktop" means the bytes - are inside the desktop RPM payload (/opt, /usr/local and the user seed), - "rpm" an inventory-pinned RPM installed as its own image package, and - "root" other files of the image. XPS camera sources, Distrobox images and - the source-build container are not part of the ISO payload: they are - fetched on matching hardware or used only as build environments. - """ - features, assets = inventory.get("features", {}), assets or {} - pins = [] - - def add(name, version, location, digest=None, reference=None, purpose="FILE", placement="desktop", - filename=None): - package = { - "SPDXID": spdx_id("Download", name, str(version)), - "name": name, - "versionInfo": str(version), - "supplier": "NOASSERTION", - "downloadLocation": location, - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": "NOASSERTION", - "copyrightText": "NOASSERTION", - "primaryPackagePurpose": purpose, - } - if digest: - package["checksums"] = [{"algorithm": "SHA256", "checksumValue": checksum(digest)}] - if filename: - package["packageFileName"] = filename - if reference: - package["externalRefs"] = [purl_reference(reference)] - pins.append((package, placement)) - - purposes = {"archive": "ARCHIVE", "binary": "APPLICATION", "rpm": "INSTALL", "font": "ARCHIVE"} - for app in inventory.get("github_release_apps", []) + inventory.get("nerd_font_apps", []): - if not feature_enabled(app.get("enabled", True), features): - continue - owner, name = app["repo"].split("/") - tag, digest = str(app["version"]), checksum(app["checksum"]) - asset = assets.get(digest) or literal_asset(app["asset_regex"]) - base = f"https://github.com/{app['repo']}/releases" - location = (f"{base}/download/{quote(tag, safe='')}/{quote(asset)}" if asset - else f"{base}/tag/{quote(tag, safe='')}") - add(app["name"], tag, location, digest, purl("github", owner, name, tag), - purposes.get(app["kind"], "OTHER"), "rpm" if app["kind"] == "rpm" else "desktop", asset) - for font in inventory.get("pinned_font_archives", []): - if feature_enabled(font.get("enabled", True), features): - add(font["name"], font["version"], font["url"], font["checksum"], - purl("generic", None, font["name"], str(font["version"]), - {"checksum": "sha256:" + checksum(font["checksum"]), "download_url": font["url"]}), - "ARCHIVE") - for font in inventory.get("pinned_font_files", []): - add(font["name"], font["version"], font["url"], font["checksum"], - purl("generic", None, font["name"], str(font["version"]), - {"checksum": "sha256:" + checksum(font["checksum"]), "download_url": font["url"]}), - "FILE", filename=font.get("filename")) - if inventory.get("omawrite_commit"): - commit = inventory["omawrite_commit"] - owner, name = OMAWRITE_REPOSITORY.split("/") - add("omawrite", inventory["omawrite_version"], - f"https://codeload.github.com/{OMAWRITE_REPOSITORY}/tar.gz/{commit}", - inventory["omawrite_checksum"], purl("github", owner, name, commit), "SOURCE") - if feature_enabled(features.get("source_builds", False), features): - for app in inventory.get("source_apps", []): - repository = _github(app["repo_url"]) - reference = (purl("github", *repository, app["commit"]) if repository else - purl("generic", None, app["name"], app["tag"], - {"vcs_url": f"git+{app['repo_url']}@{app['commit']}"})) - add(app["name"], app["tag"], f"git+{app['repo_url']}@{app['commit']}", - reference=reference, purpose="SOURCE") - if inventory.get("lazyvim_starter_commit"): - commit = inventory["lazyvim_starter_commit"] - add("lazyvim-starter", commit, f"git+{LAZYVIM_STARTER}@{commit}", - reference=purl("github", *_github(LAZYVIM_STARTER), commit), purpose="SOURCE") - for key in (apps_defaults or {}).get("apps_vendor_repo_keys", []): - if feature_enabled(key.get("enabled", True), features): - add(key["name"] + "-repository-key", checksum(key["checksum"])[:12], key["url"], key["checksum"], - purl("generic", None, key["name"] + "-repository-key", None, - {"checksum": "sha256:" + checksum(key["checksum"]), "download_url": key["url"]}), - "FILE", "root") - return pins - - -# ── Document ────────────────────────────────────────────────────────────── - -def _unique(packages): - seen = {} - for package in packages: - identifier = package["SPDXID"] - if identifier in seen: - identity = json.dumps(package, sort_keys=True).encode() - package["SPDXID"] = f"{identifier}-{hashlib.sha256(identity).hexdigest()[:8]}" - if package["SPDXID"] in seen: - raise ValueError(f"Duplicate SBOM component: {identifier}") - seen[package["SPDXID"]] = package - - -def created_time(provenance): - timestamp = datetime.fromisoformat(str(provenance["utc"]).replace("Z", "+00:00")) - if timestamp.tzinfo is None: - raise ValueError("Build time must include its timezone") - return timestamp.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def build_document(*, rpms, flatpaks, pins, provenance, version, fedora_release="44", - iso=None, desktop_rpm=None, offline_rpm=None, source_archive_sha256=None, owners=None, - source_build_provenance_sha256=None): - """Assemble the SPDX document. File arguments are (filename, sha256) pairs.""" - revision = str(provenance.get("source_revision", "")) - build = str(provenance.get("build_id", "")) - if not REVISION.fullmatch(revision): - raise ValueError("Build provenance needs the full source revision") - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", build): - raise ValueError("Build provenance needs a safe build identifier") - if not re.fullmatch(r"[0-9A-Za-z.+~-]+", version): - raise ValueError("Invalid CybexOS version") - extracted = {} - details = [f"Built from Git revision {revision}" - + (" with uncommitted changes" if provenance.get("source_dirty") else "")] - if provenance.get("source_content_sha256"): - details.append(f"source content SHA-256 {checksum(provenance['source_content_sha256'])} " - f"over {int(provenance.get('source_file_count', 0))} files") - if source_archive_sha256: - details.append(f"builder source archive SHA-256 {checksum(source_archive_sha256)}") - if source_build_provenance_sha256: - details.append("resolved source toolchains source-build-provenance.json SHA-256 " - + checksum(source_build_provenance_sha256)) - details.append(f"build {build}") - root = { - "SPDXID": ROOT_ID, - "name": "CybexOS", - "versionInfo": version, - "supplier": SUPPLIER, - "downloadLocation": f"git+https://github.com/{REPOSITORY}.git@{revision}", - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": ROOT_LICENSE, - "copyrightText": "NOASSERTION", - "primaryPackagePurpose": "OPERATING-SYSTEM", - "sourceInfo": "; ".join(details), - "externalRefs": [purl_reference(purl("github", *REPOSITORY.split("/"), revision))], - } - if iso: - root["packageFileName"] = iso[0] - root["checksums"] = [{"algorithm": "SHA256", "checksumValue": checksum(iso[1])}] - root["comment"] = "The live ISO built from this source; its components are listed below." - - components = [] # (package, inside the desktop RPM payload?) - installed = {} - desktop = None - offline = None - artifacts = {DESKTOP_PACKAGE: desktop_rpm, OFFLINE_PACKAGE: offline_rpm} - for record in sorted(rpms, key=lambda item: tuple(item[key] for key in RPM_FIELDS)): - package = rpm_package(record, fedora_release, owners or {}, extracted) - if record["name"] in artifacts: - name = record["name"] - if name in installed: - raise ValueError(f"The image contains more than one {name} RPM") - if name == DESKTOP_PACKAGE: - desktop = package - else: - offline = package - artifact = artifacts[name] - if artifact: - expected = f"{record['name']}-{record['version']}-{record['release']}.{record['arch']}.rpm" - if artifact[0] != expected: - raise ValueError(f"RPM artifact {artifact[0]} is not the installed {expected}") - package["packageFileName"] = artifact[0] - package["checksums"] = [{"algorithm": "SHA256", "checksumValue": checksum(artifact[1])}] - package["comment"] = ("SHA-256 of the unsigned build output; release signing adds a " - "signature header (release.json unsigned_input_sha256).") - installed.setdefault(record["name"], []).append(package) - components.append((package, False)) - if desktop_rpm and desktop is None: - raise ValueError("The image root does not contain the desktop RPM") - if offline_rpm and offline is None: - raise ValueError("The image root does not contain the offline applications RPM") - for record in sorted(flatpaks, key=lambda item: tuple(item[key] for key in FLATPAK_FIELDS)): - components.append((flatpak_package(record), False)) - for pin, placement in sorted(pins, key=lambda item: item[0]["SPDXID"]): - matches = installed.get(pin["name"], []) - if placement == "rpm" and len(matches) == 1: - # The installed package is exactly this checksum-pinned download. - matches[0].update({key: pin[key] for key in ("downloadLocation", "checksums", "packageFileName") - if key in pin}) - matches[0]["externalRefs"] = matches[0]["externalRefs"] + pin.get("externalRefs", []) - matches[0]["comment"] = "Installed from the inventory-pinned upstream release asset." - continue - components.append((pin, placement == "desktop")) - _unique([root] + [package for package, _ in components]) - - relationships = [] - for package, bundled in components: - container = offline or desktop - parent = container["SPDXID"] if bundled and container is not None else ROOT_ID - relationships.append({"spdxElementId": parent, "relationshipType": "CONTAINS", - "relatedSpdxElement": package["SPDXID"]}) - relationships.sort(key=lambda item: (item["spdxElementId"], item["relatedSpdxElement"])) - packages = [root] + sorted((package for package, _ in components), key=lambda item: item["SPDXID"]) - name = f"CybexOS-Live-{fedora_release}-{build}" - document = { - "spdxVersion": "SPDX-2.3", - "dataLicense": "CC0-1.0", - "SPDXID": DOCUMENT_ID, - "name": name, - "documentNamespace": (f"https://github.com/{REPOSITORY}/spdx/{name}-" - f"{uuid.uuid5(uuid.NAMESPACE_URL, f'{revision}/{name}')}"), - "creationInfo": {"created": created_time(provenance), - "creators": [f"Tool: cybexos-sbom-{version}"]}, - "comment": ("Installed RPMs and Flatpak refs of the CybexOS live image, and the " - "checksum-pinned downloads it bundles."), - "packages": packages, - "relationships": [{"spdxElementId": DOCUMENT_ID, "relationshipType": "DESCRIBES", - "relatedSpdxElement": ROOT_ID}] + relationships, - } - if extracted: - document["hasExtractedLicensingInfos"] = [extracted[key] for key in sorted(extracted)] - return verify_document(document) - - -def serialize(document): - data = (json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode() - if len(data) > SIZE_LIMIT: - raise ValueError("SBOM exceeds the 16 MiB attestation limit") - return data - - -def verify_document(document): - """Structural SPDX 2.3 checks that release tooling relies on.""" - if not isinstance(document, dict): - raise ValueError("SBOM must be a JSON object") - expected = {"spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": DOCUMENT_ID} - if any(document.get(key) != value for key, value in expected.items()): - raise ValueError("SBOM must be an SPDX 2.3 document with CC0-1.0 data license") - namespace = urlsplit(str(document.get("documentNamespace", ""))) - if not document.get("name") or namespace.scheme != "https" or not namespace.netloc or namespace.fragment: - raise ValueError("SBOM needs a name and a unique HTTPS document namespace") - creation = document.get("creationInfo") or {} - if (not re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", str(creation.get("created", ""))) - or not creation.get("creators") - or not all(re.match(r"(Tool|Organization|Person): \S", str(item)) for item in creation["creators"])): - raise ValueError("SBOM creation information is incomplete") - packages = document.get("packages") - if not isinstance(packages, list) or not packages: - raise ValueError("SBOM lists no packages") - identifiers = set() - for package in packages: - identifier = package.get("SPDXID", "") - if not SPDX_ID.fullmatch(identifier) or identifier in identifiers: - raise ValueError(f"Invalid or duplicate SPDX identifier: {identifier!r}") - identifiers.add(identifier) - if not package.get("name") or not package.get("downloadLocation"): - raise ValueError(f"{identifier} lacks a name or download location") - if package.get("filesAnalyzed") is not False and "packageVerificationCode" not in package: - raise ValueError(f"{identifier} must declare filesAnalyzed false") - for item in package.get("checksums", []): - if item.get("algorithm") != "SHA256" or not SHA256.fullmatch(str(item.get("checksumValue", ""))): - raise ValueError(f"{identifier} has an invalid checksum") - defined = {item.get("licenseId") for item in document.get("hasExtractedLicensingInfos", []) - if item.get("extractedText")} - for package in packages: - for key in ("licenseDeclared", "licenseConcluded"): - missing = set(LICENSE_REF.findall(str(package.get(key, "")))) - defined - if missing: - raise ValueError(f"{package['SPDXID']} uses undefined {', '.join(sorted(missing))}") - relationships = document.get("relationships") - if not isinstance(relationships, list): - raise ValueError("SBOM lacks relationships") - described, contained, seen = [], set(), set() - for item in relationships: - source, kind, target = (item.get(key) for key in ("spdxElementId", "relationshipType", "relatedSpdxElement")) - if source not in identifiers | {DOCUMENT_ID} or target not in identifiers: - raise ValueError(f"Relationship references an unknown element: {source} {kind} {target}") - if (source, kind, target) in seen: - raise ValueError("Repeated SBOM relationship") - seen.add((source, kind, target)) - if kind == "DESCRIBES" and source == DOCUMENT_ID: - described.append(target) - elif kind == "CONTAINS": - contained.add(target) - if len(described) != 1: - raise ValueError("SBOM must describe exactly one root package") - orphans = identifiers - contained - set(described) - if orphans: - raise ValueError(f"SBOM components are not related to the root: {', '.join(sorted(orphans)[:5])}") - return document - - -def described_package(document): - root = next(item["relatedSpdxElement"] for item in document["relationships"] - if item["spdxElementId"] == DOCUMENT_ID and item["relationshipType"] == "DESCRIBES") - return next(package for package in document["packages"] if package["SPDXID"] == root) - - -def file_checksum(package): - return next((item["checksumValue"] for item in package.get("checksums", []) - if item.get("algorithm") == "SHA256"), None) - - -def verify_release_sbom(path, iso_name, iso_sha256, rpm_sha256s=()): - """Bind an SBOM to the exact ISO and every unsigned release RPM.""" - path = Path(path) - if path.is_symlink() or not path.is_file() or path.stat().st_size > SIZE_LIMIT: - raise ValueError("Release SBOM must be a regular file below 16 MiB") - document = verify_document(json.loads(path.read_text())) - root = described_package(document) - if root.get("packageFileName") != iso_name or file_checksum(root) != iso_sha256: - raise ValueError("Release SBOM does not describe the exact ISO") - if rpm_sha256s: - expected = rpm_sha256s if isinstance(rpm_sha256s, dict) else {DESKTOP_PACKAGE: set(rpm_sha256s)} - if any(package['name'] == OFFLINE_PACKAGE for package in document['packages']) and OFFLINE_PACKAGE not in expected: - raise ValueError('Release SBOM requires the exact candidate cybexos-offline-apps RPM') - for name, digests in expected.items(): - packages = [package for package in document["packages"] if package["name"] == name] - digests = {digests} if isinstance(digests, str) else set(digests) - if len(packages) != 1 or file_checksum(packages[0]) not in digests: - label = 'desktop' if name == DESKTOP_PACKAGE else name - raise ValueError(f"Release SBOM does not identify the exact candidate {label} RPM") - return document - - -# ── Inputs and command line ─────────────────────────────────────────────── - -def load_yaml(path): - try: - import yaml - except ImportError as error: - raise RuntimeError("Reading the inventory needs PyYAML (python3-pyyaml or python3-yaml)") from error - return yaml.safe_load(Path(path).read_text()) or {} - - -def generate(*, rpm_list, flatpaks, provenance, output, inventory=None, apps_defaults=None, - version_file=None, iso=None, desktop_rpm=None, offline_rpm=None, source_archive_sha256=None, downloads=None, - source_build_provenance_sha256=None): - """Write the SBOM atomically. `iso` and `desktop_rpm` are (filename, sha256).""" - inventory = load_yaml(inventory or ROOT / "inventory/group_vars/all.yml") - defaults = load_yaml(apps_defaults or ROOT / "roles/apps/defaults/main.yml") - owners = {app["name"]: app["repo"].split("/")[0] - for app in inventory.get("github_release_apps", []) if app.get("kind") == "rpm"} - document = build_document( - rpms=parse_rpm_inventory(Path(rpm_list).read_text()), - flatpaks=parse_flatpak_inventory(Path(flatpaks).read_text()), - pins=pinned_inputs(inventory, defaults, download_assets(downloads)), - provenance=json.loads(Path(provenance).read_text()), - version=Path(version_file or ROOT / "VERSION").read_text().strip(), - fedora_release=str(inventory.get("fedora_release", "44")), - iso=iso, desktop_rpm=desktop_rpm, offline_rpm=offline_rpm, - source_archive_sha256=source_archive_sha256, owners=owners, - source_build_provenance_sha256=source_build_provenance_sha256) - output = Path(output) - data = serialize(document) - temporary = output.with_name(f".{output.name}.tmp") - try: - temporary.write_bytes(data) - temporary.replace(output) - finally: - temporary.unlink(missing_ok=True) - return output - - -def sbom_name(iso_name): - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*\.iso", iso_name): - raise ValueError("ISO filename must be ASCII without spaces") - return iso_name.removesuffix(".iso") + ".spdx.json" - - -def write_build_sbom(artifacts, iso_name, checksums=None, downloads=None, checkout=ROOT): - """Generate .spdx.json inside a verified artifact staging directory.""" - artifacts, checksums = Path(artifacts), dict(checksums or {}) - - def digest(name): - return checksums.get(name) or sha256_file(artifacts / name) - - rpms = sorted(path.name for path in artifacts.glob(DESKTOP_PACKAGE + "-*.rpm")) - if len(rpms) != 1: - raise ValueError("Artifacts must contain exactly one desktop RPM") - offline = sorted(path.name for path in artifacts.glob(OFFLINE_PACKAGE + "-*.rpm")) - if len(offline) > 1: - raise ValueError("Artifacts contain more than one offline applications RPM") - installed = parse_rpm_inventory((artifacts / RPM_INVENTORY).read_text()) - if any(record['name'] == OFFLINE_PACKAGE for record in installed) and not offline: - raise ValueError("Artifacts must contain the installed offline applications RPM") - archive = artifacts / "source.tar.gz" - return generate( - rpm_list=artifacts / RPM_INVENTORY, flatpaks=artifacts / FLATPAK_INVENTORY, - provenance=artifacts / "build-provenance.json", output=artifacts / sbom_name(iso_name), - inventory=checkout / "inventory/group_vars/all.yml", - apps_defaults=checkout / "roles/apps/defaults/main.yml", version_file=checkout / "VERSION", - iso=(iso_name, digest(iso_name)), desktop_rpm=(rpms[0], digest(rpms[0])), - offline_rpm=(offline[0], digest(offline[0])) if offline else None, - source_archive_sha256=digest(archive.name) if archive.is_file() else None, downloads=downloads, - source_build_provenance_sha256=digest("source-build-provenance.json")) - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - commands = parser.add_subparsers(dest="command", required=True) - make = commands.add_parser("generate", help="Write an SPDX 2.3 JSON SBOM from raw build inventories") - make.add_argument("--rpm-list", type=Path, required=True, help=f"{RPM_INVENTORY} (rpm --queryformat output)") - make.add_argument("--flatpaks", type=Path, required=True, help=FLATPAK_INVENTORY) - make.add_argument("--provenance", type=Path, required=True, help="build-provenance.json") - make.add_argument("--output", type=Path, required=True) - make.add_argument("--inventory", type=Path, default=ROOT / "inventory/group_vars/all.yml") - make.add_argument("--apps-defaults", type=Path, default=ROOT / "roles/apps/defaults/main.yml") - make.add_argument("--version-file", type=Path, default=ROOT / "VERSION") - make.add_argument("--iso", type=Path, help="Built ISO; recorded with its SHA-256") - make.add_argument("--iso-name", help="Delivered ISO filename when it differs from --iso") - make.add_argument("--desktop-rpm", type=Path, help="Unsigned cybexos-desktop RPM artifact") - make.add_argument("--offline-rpm", type=Path, help="Unsigned cybexos-offline-apps RPM artifact") - make.add_argument("--source-archive", type=Path, help="Builder source archive (source.tar.gz)") - make.add_argument("--downloads", type=Path, help="Download cache manifest naming fetched release assets") - check = commands.add_parser("verify", help="Check an SBOM's structure and its ISO binding") - check.add_argument("sbom", type=Path) - check.add_argument("--iso-checksum", type=Path, help="ISO .sha256 file the SBOM must describe") - args = parser.parse_args(argv) - try: - if args.command == "verify": - if args.iso_checksum: - match = re.fullmatch(r"([0-9a-f]{64}) [ *]([A-Za-z0-9._-]+\.iso)\n?", args.iso_checksum.read_text()) - if not match: - raise ValueError("Invalid ISO checksum file") - verify_release_sbom(args.sbom, match[2], match[1]) - else: - verify_document(json.loads(args.sbom.read_text())) - print(f"Valid SPDX 2.3 SBOM: {args.sbom}") - return 0 - iso = (args.iso_name or args.iso.name, sha256_file(args.iso)) if args.iso else None - rpm = (args.desktop_rpm.name, sha256_file(args.desktop_rpm)) if args.desktop_rpm else None - offline = (args.offline_rpm.name, sha256_file(args.offline_rpm)) if args.offline_rpm else None - archive = sha256_file(args.source_archive) if args.source_archive else None - print(generate(rpm_list=args.rpm_list, flatpaks=args.flatpaks, provenance=args.provenance, - output=args.output, inventory=args.inventory, apps_defaults=args.apps_defaults, - version_file=args.version_file, iso=iso, desktop_rpm=rpm, offline_rpm=offline, - source_archive_sha256=archive, downloads=args.downloads)) - return 0 - except (OSError, ValueError, KeyError, TypeError, RuntimeError, json.JSONDecodeError) as error: - print(f"sbom: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/image/source_build_provenance.py b/image/source_build_provenance.py deleted file mode 100644 index 1d1f2fac..00000000 --- a/image/source_build_provenance.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Export resolved compiler facts only for the source binaries actually shipped.""" -import hashlib -import json -from pathlib import Path -import re - - -def collect(inventory, builds=Path('/opt/cybexos-builds')): - if not inventory['features'].get('source_builds', False): - return [] - records = [] - for app in inventory['source_apps']: - current = builds / app['name'] / 'current' - record = json.loads((current / '.cybexos-build-provenance.json').read_text()) - expected = {'name': app['name'], 'repository': app['repo_url'], 'tag': app['tag'], - 'revision': app['commit'], 'base_image': inventory['source_build_container_image']} - if record.get('schema') != 1 or any(record.get(key) != value for key, value in expected.items()): - raise ValueError(f'Source provenance does not match authorized inputs: {app["name"]}') - builder = record.get('builder_image_id', '') - if (not re.fullmatch(r'sha256:[a-f0-9]{64}', builder) - or (current / '.cybexos-builder').read_text().strip() != builder - or not re.fullmatch(r'[a-f0-9]{64}', record.get('recipe_sha256', ''))): - raise ValueError(f'Incomplete source builder identity: {app["name"]}') - if (not record.get('packages') or not all( - isinstance(record.get('compilers', {}).get(tool), str) and record['compilers'][tool].strip() - for tool in ('rustc', 'cargo', 'gcc', 'g++'))): - raise ValueError(f'Incomplete resolved compiler inventory: {app["name"]}') - for package in record['packages']: - if not all(isinstance(package.get(key), str) and package[key] - for key in ('name', 'epoch', 'version', 'release', 'arch')): - raise ValueError('Incomplete resolved builder package') - if set(record.get('binaries', {})) != set(app['binary_names']): - raise ValueError('Incomplete source binary inventory') - for name, expected_digest in record['binaries'].items(): - path = current / 'bin' / name - if path.is_symlink() or not path.is_file() or not path.stat().st_mode & 0o111: - raise ValueError(f'Missing executable source binary: {name}') - if hashlib.sha256(path.read_bytes()).hexdigest() != expected_digest: - raise ValueError(f'Shipped source binary differs from its provenance: {name}') - records.append(record) - return records diff --git a/image/source_snapshot.py b/image/source_snapshot.py deleted file mode 100644 index eb0188f8..00000000 --- a/image/source_snapshot.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Exact reviewed checkout content shared by image and checkout qualification.""" -import hashlib -import json -from pathlib import Path, PurePosixPath -import subprocess -import tarfile - -GENERATED = {'image/update-channel.json', 'image/update-key.asc', 'image/build-provenance.json'} - - -def content_digest(records): - return hashlib.sha256(json.dumps(records, sort_keys=True, separators=(',', ':')).encode()).hexdigest() - - -def source_files(root): - result = subprocess.run(['git', '-C', str(root), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], - capture_output=True, check=True) - paths = [] - for name in sorted(set(result.stdout.decode().split('\0')) - {''} - GENERATED): - path = Path(root) / name - if '__pycache__' in path.parts or path.suffix == '.pyc': - continue - if path.is_symlink(): - raise ValueError(f'Source snapshot must contain regular files, not symlinks: {name}') - if path.is_file(): - paths.append((name, path)) - return paths - - -def tree_identity(root): - records = [{'path': name, 'executable': bool(path.stat().st_mode & 0o111), - 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for name, path in source_files(root)] - return {'source_content_sha256': content_digest(records), 'source_file_count': len(records)} - - -def write_archive(root, destination, additions=None): - with tarfile.open(destination, 'w:gz') as archive: - for name, path in source_files(root): - archive.add(path, arcname=name, recursive=False) - for name, path in (additions or {}).items(): - if name not in GENERATED: - raise ValueError('Unexpected generated source input') - archive.add(path, arcname=name, recursive=False) - - -def archive_identity(path): - """Validate before extraction: no traversal, duplicate names or links.""" - records, seen = [], set() - with tarfile.open(path, 'r:gz') as archive: - for member in archive: - name = member.name - if (not member.isfile() or PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts - or name in seen or name.startswith('./') or '\\' in name): - raise ValueError('Unsafe or duplicate source archive entry') - seen.add(name) - if name not in GENERATED: - stream = archive.extractfile(member) - records.append({'path': name, 'executable': bool(member.mode & 0o111), - 'sha256': hashlib.file_digest(stream, 'sha256').hexdigest()}) - for required in ('install', 'site.yml', 'inventory/group_vars/all.yml', 'image/build-provenance.json'): - if required not in seen: - raise ValueError(f'Source archive lacks required input: {required}') - records.sort(key=lambda item: item['path']) - return {'source_content_sha256': content_digest(records), 'source_file_count': len(records)} diff --git a/image/system_packages.py b/image/system_packages.py deleted file mode 100644 index f5050659..00000000 --- a/image/system_packages.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Read the ISO's system and desktop package selections for checkout installs.""" -import argparse -import json -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -LOCAL_PACKAGES = {'cybexos-desktop', 'cybexos-offline-apps', 'cybexos-offline-inputs'} -PACKAGE = re.compile(r'[A-Za-z0-9][A-Za-z0-9+_.:-]*\Z') -DEPENDENCY = re.compile(r'([A-Za-z0-9][A-Za-z0-9+_.:-]*)(?:\s*(>=|<=|=|>|<)\s*([^\s]+))?') - - -def contract(root=ROOT): - kickstart = (root / 'image/live.ks').read_text() - match = re.search(r'(?ms)^%packages([^\n]*)\n(.*?)^%end\s*$', kickstart) - if not match or '--exclude-weakdeps' not in match[1].split(): - raise ValueError('The shared ISO package policy must explicitly exclude weak dependencies') - packages, groups, excluded, constraints = set(), set(), set(), {} - for raw in match[2].splitlines(): - value = raw.split('#', 1)[0].strip() - if not value: - continue - if value.startswith('-') and PACKAGE.fullmatch(value[1:]): - excluded.add(value[1:]) - elif value.startswith('@') and PACKAGE.fullmatch(value[1:]): - groups.add(value) - elif PACKAGE.fullmatch(value): - if value not in LOCAL_PACKAGES: - packages.add(value) - else: - raise ValueError('Unsupported ISO package selection: ' + value) - spec = (root / 'image/cybexos-desktop.spec').read_text().split('%description', 1)[0] - for line in spec.splitlines(): - if not line.startswith('Requires:'): - continue - remaining = line.split(':', 1)[1].strip() - while remaining: - dependency = DEPENDENCY.match(remaining) - if not dependency: - raise ValueError('Unsupported desktop dependency: ' + remaining) - name, operator, version = dependency.groups() - if name not in LOCAL_PACKAGES: - if version and '%' in version: - raise ValueError('Unresolved system dependency macro') - packages.add(name) - if operator: - constraint = name + ' ' + operator + ' ' + version - if name in constraints and constraints[name] != constraint: - raise ValueError('Conflicting desktop dependency: ' + name) - constraints[name] = constraint - remaining = remaining[dependency.end():].strip() - if packages & excluded: - raise ValueError('A system package is both selected and excluded') - return {'packages': sorted(packages), 'groups': sorted(groups), 'excluded': sorted(excluded), - 'selectors': sorted(groups) + [constraints.get(name, name) for name in sorted(packages)], - 'install_weak_deps': False} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--root', type=Path, default=ROOT) - args = parser.parse_args() - print(json.dumps(contract(args.root))) - - -if __name__ == '__main__': - main() diff --git a/image/test-live b/image/test-live deleted file mode 100755 index ff5e1b6b..00000000 --- a/image/test-live +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""Explicit live ISO smoke test. No build, installation or PXE publication.""" -import argparse -from pathlib import Path -import signal -import subprocess -import sys -import time - -from build_support import atomic_json, digest -from vm_testing import TestVM, require_test_iso, run - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('iso', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--firmware', choices=('bios', 'uefi'), default='uefi') - parser.add_argument('--boot-wait', type=int, default=300, help='Bounded readiness timeout, not a fixed startup sleep') - parser.add_argument('--hold', action='store_true') - parser.add_argument('--keep-artifacts', action='store_true', help='Retain task-owned disk/logs for unresolved diagnostics') - parser.add_argument('--execute-vm', action='store_true', help='Required opt-in to starting a VM') - args = parser.parse_args() - if not args.execute_vm: - parser.error('VM execution requires --execute-vm') - iso = require_test_iso(args.iso) - vm = TestVM(args.output, args.firmware) - report = {'iso_sha256': digest(iso), 'firmware': args.firmware, 'status': 'failed'} - try: - vm.prepare() - vm.start(iso) - # The menu default is the installation-only session; this checks the live desktop. - vm.select_boot_entry('live') - vm.wait_ssh(args.boot_wait) - vm.audit() - run([*vm.ssh, 'test -f /run/cybexos-live && test -x /usr/bin/liveinst && XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cybexos-welcome.service']) - report['status'] = 'passed' - report['checks'] = ['live-boot', 'offline-applications', 'quickshell'] - print('Live desktop smoke check passed. No installation was performed.', flush=True) - if args.hold: - print(f"VM ready for interaction: {args.output / 'vm.json'}", flush=True) - try: - while vm.process.poll() is None: - time.sleep(1) - except KeyboardInterrupt: - pass # A held, already-passing test ends by interruption. - finally: - if vm.owned: - try: - vm.cleanup(args.keep_artifacts) - except BaseException: - report['status'] = 'failed' - raise - finally: - atomic_json(args.output / 'live-report.json', report) - - -if __name__ == '__main__': - def interrupt(number, _frame): - raise KeyboardInterrupt(number) - for name in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(name, interrupt) - try: - main() - except KeyboardInterrupt as error: - number = error.args[0] if error.args else signal.SIGINT - print(f"image/test-live: interrupted by {signal.Signals(number).name}", file=sys.stderr) - sys.exit(128 + number) - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: - print(f"image/test-live: {error}", file=sys.stderr) - sys.exit(1) diff --git a/image/test_build_tools.py b/image/test_build_tools.py deleted file mode 100644 index d7c5f137..00000000 --- a/image/test_build_tools.py +++ /dev/null @@ -1,512 +0,0 @@ -"""Source-only fixtures: no ISO filesystem, QEMU process or PXE server is used.""" -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import socket -from pathlib import Path -import tempfile -import tarfile -from types import SimpleNamespace -import unittest -import urllib.error -from unittest.mock import Mock, patch - -from build_support import (builder_cloud_config, checksum_entries, deliver_artifacts, - select_firmware, wait_for_builder_initialization) -from download_cache import import_cache, merge_cache, verified_entries -from pxe_publish import DEFAULT_CONTRACT, IVentoy, publish, staging_parent, validate_status - - -class SourceArchiveTests(unittest.TestCase): - def test_disposable_builder_receives_skill_license_and_provisioning_helper(self): - loader = importlib.machinery.SourceFileLoader('archive_build', str(Path(__file__).with_name('build'))) - spec = importlib.util.spec_from_loader(loader.name, loader) - builder = importlib.util.module_from_spec(spec) - loader.exec_module(builder) - from desktop_payload import prepare_session - from provision_payload import prepare_provision - import yaml - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - archive = root / 'source.tar.gz' - builder.source_archive(archive) - extracted = root / 'source' - with tarfile.open(archive) as stream: - stream.extractall(extracted, filter='data') - payload = root / 'payload' - inventory = yaml.safe_load((extracted / 'inventory/group_vars/all.yml').read_text()) - # Exercise the actual packager against only what crosses the VM - # boundary; testing against the full checkout hid missing inputs. - prepare_session(extracted, payload, inventory) - prepare_provision(extracted, payload) - self.assertTrue((extracted / 'LICENSE').is_file()) - self.assertTrue((payload / 'usr/share/cybexos/agent-skills/cybexos/SKILL.md').is_file()) - helper = payload / 'usr/share/cybexos/provision/scripts/manage-agent-skills' - self.assertTrue(helper.is_file()) - self.assertTrue(helper.stat().st_mode & 0o111) - - -class BuilderInitializationTests(unittest.TestCase): - def test_hostname_is_set_only_during_final_stage(self): - config = builder_cloud_config('ssh-ed25519 synthetic-public-fixture') - self.assertTrue(config['preserve_hostname']) - self.assertEqual(config['runcmd'], [['hostnamectl', 'set-hostname', 'image-builder']]) - # Package transfers use IPv4 only (stalled IPv6 downloads through QEMU user networking). - self.assertEqual(config['bootcmd'], [['sysctl', '-q', '-w', 'net.ipv6.conf.all.disable_ipv6=1', - 'net.ipv6.conf.default.disable_ipv6=1']]) - self.assertFalse(config['ssh_pwauth']) - self.assertTrue(config['disable_root']) - self.assertEqual(config['users'][0]['ssh_authorized_keys'], ['ssh-ed25519 synthetic-public-fixture']) - - def test_success_verifies_hostname_and_sudo_before_source_transfer(self): - result = SimpleNamespace(returncode=0, stdout='{"status":"done"}\n', stderr='') - with tempfile.TemporaryDirectory() as directory, patch('build_support.subprocess.run', return_value=result) as run: - wait_for_builder_initialization(['ssh', 'builder-fixture'], directory) - self.assertEqual(run.call_args_list[0].args[0][-1], 'cloud-init status --wait --format=json') - command = run.call_args_list[1].args[0][-1] - self.assertIn('test "$(hostname)" = image-builder', command) - self.assertIn('sudo -n true', command) - self.assertTrue(run.call_args_list[1].kwargs['check']) - self.assertEqual((Path(directory) / 'builder-cloud-init-status.log').read_text(), result.stdout) - - def test_degraded_or_failed_cloud_init_never_proceeds_and_keeps_evidence(self): - for code in (1, 2, 255): - result = SimpleNamespace(returncode=code, stdout='{"status":"done","recoverable_errors":["fixture"]}\n', stderr='fixture diagnostic\n') - with self.subTest(code=code), tempfile.TemporaryDirectory() as directory, \ - patch('build_support.subprocess.run', return_value=result) as run: - with self.assertRaisesRegex(RuntimeError, f'cloud-init exited {code}'): - wait_for_builder_initialization(['ssh', 'builder-fixture'], directory) - self.assertEqual(run.call_count, 2) - self.assertIn('tail -n 400 /var/log/cloud-init.log', run.call_args.args[0][-1]) - self.assertNotIn('mkdir', run.call_args.args[0][-1]) - self.assertTrue((Path(directory) / 'builder-cloud-init.log').exists()) - self.assertIn('fixture diagnostic', (Path(directory) / 'builder-cloud-init-status.log').read_text()) - - -class DeliveryTests(unittest.TestCase): - def test_verified_directory_is_published_atomically(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - stage = root / 'stage' - stage.mkdir() - (stage / 'payload.bin').write_bytes(b'synthetic fixture, not an image') - checksum = hashlib.sha256((stage / 'payload.bin').read_bytes()).hexdigest() - (stage / 'SHA256SUMS').write_text(f'{checksum} payload.bin\n') - self.assertEqual(deliver_artifacts(stage, root / 'complete'), {'payload.bin': checksum}) - self.assertFalse(stage.exists()) - self.assertEqual((root / 'complete/payload.bin').stat().st_mode & 0o777, 0o644) - - def test_bad_checksum_and_traversal_never_publish(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'payload.bin').write_bytes(b'fixture') - for entry in ('0' * 64 + ' payload.bin\n', '0' * 64 + ' ../payload.bin\n'): - (root / 'SHA256SUMS').write_text(entry) - with self.assertRaises(ValueError): - checksum_entries(root) - - def test_unchecked_artifacts_are_rejected(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'payload.bin').write_bytes(b'fixture') - digest = hashlib.sha256(b'fixture').hexdigest() - (root / 'SHA256SUMS').write_text(f'{digest} payload.bin\n') - (root / 'unexpected').touch() - with self.assertRaises(ValueError): - checksum_entries(root) - - -class CacheTests(unittest.TestCase): - def test_verified_cache_reuses_bytes_and_rejects_stale_destinations(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - exported, cached, guest = [root / name for name in ('exported', 'cached', 'guest')] - exported.mkdir() - checksum = hashlib.sha256(b'verified').hexdigest() - (exported / checksum).write_bytes(b'verified') - entry = {'path': '/var/cache/tool/asset', 'sha256': checksum} - (exported / 'manifest.json').write_text(json.dumps({'entries': [entry]})) - contract = [(checksum, '/var/cache/tool/asset', None)] - merge_cache(exported, cached, contract) - import_cache(cached, contract, guest) - self.assertEqual((guest / 'var/cache/tool/asset').read_bytes(), b'verified') - self.assertEqual(verified_entries(cached, []), []) - (cached / checksum).write_bytes(b'corrupt') - self.assertEqual(verified_entries(cached, contract), []) - - def test_cache_never_follows_destination_symlink(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - cache, guest, external = [root / name for name in ('cache', 'guest', 'external')] - for path in (cache, guest, external): - path.mkdir() - checksum = hashlib.sha256(b'verified').hexdigest() - (cache / checksum).write_bytes(b'verified') - (cache / 'manifest.json').write_text(json.dumps({'entries': [{'path': '/var/asset', 'sha256': checksum}]})) - (guest / 'var').symlink_to(external, target_is_directory=True) - with self.assertRaises(ValueError): - import_cache(cache, [(checksum, '/var/asset', None)], guest) - self.assertEqual(list(external.iterdir()), []) - - -class FirmwareTests(unittest.TestCase): - def test_qemu_descriptor_handles_fedora_qcow2_and_ignores_secure_boot(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - code, variables = root / 'code.qcow2', root / 'vars.qcow2' - code.touch() - variables.touch() - descriptor = {'interface-types': ['uefi'], 'features': [], 'targets': [{'architecture': 'x86_64', 'machines': ['pc-q35-*']}], - 'mapping': {'device': 'flash', 'executable': {'filename': str(code), 'format': 'qcow2'}, - 'nvram-template': {'filename': str(variables), 'format': 'qcow2'}}} - (root / 'plain.json').write_text(json.dumps(descriptor)) - descriptor['features'] = ['secure-boot'] - (root / 'first.json').write_text(json.dumps(descriptor)) - selected = select_firmware(root, root / 'missing') - self.assertEqual(selected['code']['format'], 'qcow2') - self.assertTrue(selected['descriptor'].endswith('plain.json')) - - def test_missing_firmware_fails_with_package_guidance(self): - with tempfile.TemporaryDirectory() as temporary: - with self.assertRaisesRegex(RuntimeError, 'edk2-ovmf'): - select_firmware(Path(temporary), Path(temporary)) - - -class PublisherTests(unittest.TestCase): - def fixture_client(self, responses, timeout=180): - clock = [0] - methods = [] - responses = iter(responses) - def request(method): - methods.append(method) - clock[0] += 1 - response = next(responses) - if isinstance(response, Exception): - raise response - return response - def sleep(seconds): - clock[0] += seconds - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, - request=request, timeout=timeout, sleep=sleep, clock=lambda: clock[0]) - return client, methods, clock - - def test_accepted_refresh_retries_only_status_timeouts(self): - client, methods, _ = self.fixture_client([ - {'status': 'running'}, {'result': 'success'}, TimeoutError('timed out'), - urllib.error.URLError(TimeoutError('timed out')), {'status': 'refreshing'}, - {'status': 'running'}, [{'name': 'fixture.iso'}], - ]) - client.refresh('fixture.iso') - self.assertEqual(methods.count('refresh_img_list'), 1) - self.assertEqual(methods.count('query_status'), 5) - self.assertEqual(methods[-1], 'get_img_tree') - - def test_polling_uses_one_deadline_for_the_whole_refresh(self): - client, methods, clock = self.fixture_client([ - {'status': 'refreshing'}, {'status': 'running'}, {'result': 'success'}, - TimeoutError('timed out')], timeout=8) - with self.assertRaisesRegex(RuntimeError, 'deadline'): - client.refresh('fixture.iso') - self.assertEqual(clock[0], 8) - self.assertEqual(methods, ['query_status', 'query_status', 'refresh_img_list', 'query_status']) - - def test_ambiguous_refresh_timeout_is_never_repeated(self): - client, methods, _ = self.fixture_client([{'status': 'running'}, TimeoutError('timed out')]) - with self.assertRaises(TimeoutError): - client.refresh('fixture.iso') - self.assertEqual(methods, ['query_status', 'refresh_img_list']) - - def test_status_json_and_other_transport_errors_are_not_retried(self): - for error in (json.JSONDecodeError('bad JSON', '!', 0), - urllib.error.URLError(ConnectionRefusedError('refused'))): - with self.subTest(error=type(error).__name__): - client, methods, _ = self.fixture_client([ - {'status': 'running'}, {'result': 'success'}, error]) - with self.assertRaises(type(error)): - client.refresh('fixture.iso') - self.assertEqual(methods, ['query_status', 'refresh_img_list', 'query_status']) - - def test_timeout_recovery_still_requires_running_pxe_and_expected_filename(self): - for remaining, message in (([{'status': 'stopped'}], 'not running'), - ([{'status': 'running'}, [{'name': 'other.iso'}]], 'published filename')): - client, _, _ = self.fixture_client([ - {'status': 'running'}, {'result': 'success'}, TimeoutError('timed out'), *remaining]) - with self.assertRaisesRegex(RuntimeError, message): - client.refresh('fixture.iso') - - def test_http_request_timeout_cannot_exceed_remaining_deadline(self): - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, clock=lambda: 5) - with patch('pxe_publish.urllib.request.urlopen', side_effect=TimeoutError) as request: - with self.assertRaises(TimeoutError): - client._query('query_status', 8) - self.assertEqual(request.call_args.kwargs['timeout'], 3) - - def test_refresh_polls_known_iventoy_contract_and_verifies_tree(self): - responses = iter([{'status': 'running'}, {'result': 'success'}, {'status': 'refreshing'}, {'status': 'running'}, [{'name': 'fixture.iso'}]]) - methods = [] - def request(method): - methods.append(method) - return next(responses) - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, request=request, sleep=lambda _: None) - client.refresh('fixture.iso') - self.assertEqual(methods, ['query_status', 'refresh_img_list', 'query_status', 'query_status', 'get_img_tree']) - - def test_http_success_is_not_refresh_success(self): - responses = iter([{'status': 'running'}, {'result': 'failure'}]) - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, request=lambda _: next(responses)) - with self.assertRaisesRegex(RuntimeError, 'rejected refresh'): - client.refresh('fixture.iso') - - def test_stopped_pxe_and_unknown_schema_fail(self): - for value in ({'status': 'ready'}, {'result': 'success'}, {'status': 'surprise'}): - with self.assertRaises(RuntimeError): - validate_status(value, DEFAULT_CONTRACT) - - def test_verified_publication_is_idempotent_and_preserves_other_files(self): - # These tiny text fixtures are not ISO filesystems or boot tests. - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - served = root / 'served' - served.mkdir() - source = root / 'payload.bin' - source.write_bytes(b'synthetic publication bytes') - previous = served / 'existing.bin' - previous.write_bytes(b'preserve') - first = publish(source, served, 'fixture.iso') - second = publish(source, served, 'fixture.iso') - self.assertEqual(first, second) - self.assertEqual(previous.read_bytes(), b'preserve') - source.write_bytes(b'different') - with self.assertRaises(FileExistsError): - publish(source, served, 'fixture.iso') - self.assertEqual(sorted(p.name for p in root.iterdir()), ['payload.bin', 'served']) - - @unittest.skipIf(os.geteuid() == 0, 'root bypasses directory permissions') - def test_publication_stages_in_nearest_writable_ancestor(self): - # Mirrors a PXE host: root-owned /data/pxe, user-writable /data and /data/pxe/iso. - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - parent = root / 'pxe' - served = parent / 'iso' - served.mkdir(parents=True) - source = root / 'payload.bin' - source.write_bytes(b'synthetic publication bytes') - parent.chmod(0o555) - try: - self.assertEqual(staging_parent(served), root.resolve()) - destination, _ = publish(source, served, 'fixture.iso') - self.assertEqual(destination.read_bytes(), b'synthetic publication bytes') - self.assertEqual(sorted(p.name for p in root.iterdir()), ['payload.bin', 'pxe']) - finally: - parent.chmod(0o755) - - def test_staging_inside_served_tree_is_rejected(self): - with tempfile.TemporaryDirectory() as temporary: - served = Path(temporary) / 'served' - (served / 'incoming').mkdir(parents=True) - for requested in (served, served / 'incoming'): - with self.assertRaises(ValueError): - staging_parent(served, requested) - self.assertEqual(staging_parent(served, Path(temporary)), Path(temporary).resolve()) - - -class QualificationSourceTests(unittest.TestCase): - def test_long_artifact_paths_use_bindable_private_qmp_runtime_across_boots(self): - from vm_testing import TestVM, poweroff_guest - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / ('a' * 80) / ('b' * 80) - root.mkdir(parents=True) - vm = TestVM(root, firmware='bios') - vm.owned = True - sockets = [] - def launch(arguments, **kwargs): - option = arguments[arguments.index('-qmp') + 1] - path = option.removeprefix('unix:').split(',')[0] - connection = socket.socket(socket.AF_UNIX) - connection.bind(path) # Real kernel pathname-length check. - sockets.append(connection) - process = Mock(pid=12345) - process.poll.return_value = None - def finish(*args, **kwargs): - connection.close() - process.poll.return_value = 0 - return 0 - process.wait.side_effect = finish - process.terminate.side_effect = finish - return process - try: - with patch('vm_testing.subprocess.Popen', side_effect=launch), \ - patch.dict(os.environ, {'TMPDIR': str(root)}): - paths = [] - for poweroff in (True, False): - vm.start() - runtime = vm.runtime - paths.append(runtime) - self.assertEqual(runtime.stat().st_mode & 0o777, 0o700) - self.assertLess(len(os.fsencode(vm.qmp_path)), 108) - self.assertEqual(json.loads((root / 'vm.json').read_text())['qmp'], str(vm.qmp_path)) - if poweroff: - poweroff_guest(vm, 'fixture-password', Mock()) - else: - vm.cleanup(keep_artifacts=True) - self.assertFalse(runtime.exists()) - self.assertIsNone(vm.runtime) - self.assertNotEqual(*paths) - self.assertTrue((root / 'qemu.log').exists(), 'Requested diagnostic outputs stay retained') - finally: - vm.stop() - for connection in sockets: - connection.close() - - def test_failed_vm_launch_cleans_private_qmp_runtime(self): - from vm_testing import TestVM - with tempfile.TemporaryDirectory() as temporary: - vm = TestVM(temporary, firmware='bios') - runtimes = [] - def fail(*args, **kwargs): - runtimes.append(vm.runtime) - raise OSError('fixture launch failure') - with patch('vm_testing.subprocess.Popen', side_effect=fail): - with self.assertRaisesRegex(OSError, 'fixture launch failure'): - vm.start() - self.assertFalse(runtimes[0].exists()) - self.assertIsNone(vm.runtime) - self.assertIsNone(vm.console) - - def test_state_publication_failure_stops_guest_before_removing_runtime(self): - from vm_testing import TestVM - with tempfile.TemporaryDirectory() as temporary: - vm = TestVM(temporary, firmware='bios') - process = Mock(pid=12345) - process.poll.return_value = None - runtimes = [] - def terminate(): - self.assertTrue(vm.runtime.is_dir()) - process.poll.return_value = 0 - process.terminate.side_effect = terminate - process.wait.return_value = 0 - def fail(*args): - runtimes.append(vm.runtime) - raise OSError('fixture state publication failure') - with patch('vm_testing.subprocess.Popen', return_value=process), \ - patch('vm_testing.atomic_json', side_effect=fail): - with self.assertRaisesRegex(OSError, 'fixture state publication failure'): - vm.start() - process.terminate.assert_called_once() - self.assertFalse(runtimes[0].exists()) - self.assertIsNone(vm.runtime) - - def test_installation_requires_both_explicit_execution_flags(self): - import qualification - for flags in ([], ['--execute-vm'], ['--erase-disposable-disk']): - with patch('sys.argv', ['qualify', '/does-not-exist', '--output', '/does-not-exist', *flags]), patch('sys.stderr'), patch.object(qualification.signal, 'signal'), patch.object(qualification, 'TestVM') as vm: - with self.assertRaises(SystemExit): - qualification.main() - vm.assert_not_called() - - def test_cleanup_never_touches_an_unowned_directory(self): - from vm_testing import TestVM - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'installed.qcow2').write_bytes(b'some other task') - vm = TestVM(root) - vm.cleanup() - self.assertEqual((root / 'installed.qcow2').read_bytes(), b'some other task') - - def test_qemu_filename_options_cannot_be_injected(self): - from build_support import validate_qemu_path - for path in ('/tmp/example,media=disk', '/tmp/new\nline'): - with self.assertRaises(ValueError): - validate_qemu_path(path) - - def test_backend_protocol_extracts_result_and_never_passes_secrets_in_argv(self): - import qualification - from types import SimpleNamespace - vm = SimpleNamespace(ssh=['ssh', 'fixture']) - response = SimpleNamespace(stdout='{"event":"result","ok":true,"data":{"disk":{"name":"vda"},"token":"fixture-token"}}\n') - with patch.object(qualification, 'run', return_value=response) as run: - plan = qualification.backend(vm, 'plan', {'password': 'private-test-value'}) - self.assertEqual(plan['disk']['name'], 'vda') - self.assertNotIn('private-test-value', ' '.join(run.call_args.args[0])) - self.assertIn('private-test-value', run.call_args.kwargs['input']) - - -class DeliveryRegressionTests(unittest.TestCase): - def fixture(self, root, name='cybexos-desktop-0.0.0~dev^fixture.x86_64.rpm'): - root.mkdir() - (root / name).write_bytes(b'synthetic bytes, not an RPM') - checksum = hashlib.sha256((root / name).read_bytes()).hexdigest() - (root / 'SHA256SUMS').write_text(f'{checksum} {name}\n') - return name, checksum - - def test_prerelease_rpm_names_are_accepted(self): - with tempfile.TemporaryDirectory() as directory: - stage = Path(directory) / 'stage' - name, checksum = self.fixture(stage) - self.assertEqual(checksum_entries(stage), {name: checksum}) - - def test_symlinked_checksum_manifest_is_rejected_without_chmod(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - stage = root / 'stage' - self.fixture(stage) - external = root / 'external' - (stage / 'SHA256SUMS').rename(external) - external.chmod(0o600) - (stage / 'SHA256SUMS').symlink_to(external) - with self.assertRaisesRegex(ValueError, 'never a symlink'): - deliver_artifacts(stage, root / 'delivered') - self.assertEqual(external.stat().st_mode & 0o777, 0o600) - self.assertFalse((root / 'delivered').exists()) - - def test_concurrent_empty_directory_is_preserved(self): - import build_support - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - stage, destination = root / 'stage', root / 'complete' - self.fixture(stage) - original = build_support.rename_new_directory - def concurrent_creation(source, target): - target.mkdir() - return original(source, target) - with patch.object(build_support, 'rename_new_directory', side_effect=concurrent_creation): - with self.assertRaises(FileExistsError): - deliver_artifacts(stage, destination) - self.assertTrue(stage.is_dir()) - self.assertEqual(list(destination.iterdir()), []) - - def test_adjacent_checksum_requires_correct_bytes_and_filename(self): - from build_support import verify_sidecar - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / 'payload.bin' - path.write_bytes(b'fixture') - checksum = hashlib.sha256(b'fixture').hexdigest() - sidecar = path.with_name(path.name + '.sha256') - for entry in (f'{checksum} different.bin\n', '0' * 64 + ' payload.bin\n'): - sidecar.write_text(entry) - with self.assertRaises(ValueError): - verify_sidecar(path) - sidecar.write_text(f'{checksum} payload.bin\n') - self.assertEqual(verify_sidecar(path), checksum) - - def test_guest_audit_streams_the_actual_shared_helper(self): - from vm_testing import audit_script - helper = (Path(__file__).resolve().parents[1] / 'tests/lib/quickshell-live').read_text() - script = audit_script() - self.assertIn(helper, script) - self.assertTrue(script.endswith('qs_live_begin\nqs_live_end\n')) - - def test_imported_qualification_entry_registers_cleanup_signals(self): - import qualification - import signal - with patch('sys.argv', ['qualify', 'fixture', '--output', 'fixture']), patch('sys.stderr'), patch.object(qualification.signal, 'signal') as register: - with self.assertRaises(SystemExit): - qualification.main() - register.assert_any_call(signal.SIGTERM, qualification.interrupt) - register.assert_any_call(signal.SIGHUP, qualification.interrupt) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_console_bootstrap.py b/image/test_console_bootstrap.py deleted file mode 100644 index f5b7bec8..00000000 --- a/image/test_console_bootstrap.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Mocked installed-console transport: no VM or host session is touched.""" -import tempfile -import unittest -from unittest.mock import patch - -from vm_testing import TestVM, console_output, sudo_password_prompt - - -class ConsoleBootstrapTests(unittest.TestCase): - def exercise(self, *, passworded=False, german=False, failure=None, late_greeter=False, refusals=0, - splash_switches=0, dropped_names=0, - sudo_prompt='[sudo] password for qualification:'): - password = 'private-fixture-secret' - state = {'phase': 'login', 'time': 0, 'typed': [], 'greeter': splash_switches > 0, 'keys': [], - 'refusals': refusals, 'splash': splash_switches, 'dropped_names': dropped_names} - def clock(): - state['time'] += 0.1 - return state['time'] - def sleep(seconds): - state['time'] += seconds - def typing(text): - state['typed'].append(text) - if text == 'qualification\n' and state['dropped_names']: - state['dropped_names'] -= 1 - return # The display manager took focus before getty received it. - if text == 'qualification\n' and state['refusals']: - # pam_nologin while systemd-user-sessions has not started. - state['refusals'] -= 1 - state['phase'] = 'refused' if state['phase'] == 'login' else 'refused-again' - elif text == 'qualification\n': - state['phase'] = 'password' - # SDDM starts now and switches the display to its greeter. - state['greeter'] = late_greeter - elif text == password + '\n': - state['phase'] = 'shell' if state['phase'] == 'password' else 'authorized' - elif text.startswith('echo CONSOLEWORKS'): - state['phase'] = 'probe' - elif text == 'clear\n': - state['phase'] = 'shell' - elif text.startswith('sudo echo'): - state['phase'] = 'sudo' if passworded else 'authorized' - elif text.startswith('HISTFILE=') and 'CONSOLE%sREADY' in text: - state['phase'] = 'bash' - def keys(combination): - state['keys'].append(combination) - if combination == 'ctrl+alt+f3': - # The first switches land while the boot splash or a starting - # display manager still owns the screen. - if state['splash']: - state['splash'] -= 1 - else: - state['greeter'] = False - def screen(): - if state['greeter']: - return 'Sessie CybexOS Kies je gebruikersnaam en voer wachtwoord in' - phase = state['phase'] - return { - 'login': 'fedora login:', 'password': 'Password:', - 'refused': 'fedora login: qualification System is booting up. Unprivileged users are not ' - 'permitted to log in yet. fedora login:', - 'refused-again': 'fedora login: qualification System is booting up. fedora login: ' - 'qualification System is booting up. fedora login:', - 'shell': 'a custom Fish prompt without user or host', - 'probe': ('custom prompt echo CONSOLEWORKS y' if failure == 'shell' else - 'custom prompt echo CONSOLEWORKS y\nCONSOLE WORKS ' + ('z' if german else 'y')), - 'sudo': sudo_prompt, - 'authorized': 'CONSOLE AUTH', - 'bash': ('echo ' + password if failure == 'bash' else - "bash-5.3$ printf 'CONSOLE%sREADY' BASH\nCONSOLEBASHREADY"), - }[phase] - with tempfile.TemporaryDirectory() as directory: - vm = TestVM(directory) - vm.key.with_suffix('.pub').write_text('ssh-ed25519 public-fixture') - with patch.object(vm, 'keypress', side_effect=keys), patch.object(vm, 'alive'), \ - patch.object(vm, 'type', side_effect=typing), \ - patch.object(vm, 'screen_text', side_effect=screen), \ - patch.object(vm, 'wait_ssh') as ssh, \ - patch('vm_testing.time.monotonic', side_effect=clock), \ - patch('vm_testing.time.sleep', side_effect=sleep): - if failure: - with self.assertRaises(RuntimeError) as error: - vm.bootstrap_installed_ssh(password, timeout=50) - self.assertNotIn(password, str(error.exception)) - ssh.assert_not_called() - else: - vm.bootstrap_installed_ssh(password, timeout=50) - ssh.assert_called_once() - return state['typed'] if not late_greeter else (state['typed'], state['keys']) - - def test_an_early_console_switch_is_repeated_until_the_login_prompt_shows(self): - typed = self.exercise(splash_switches=2) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - - def test_a_login_refused_while_booting_is_retried(self): - # pam_nologin refuses logins until systemd-user-sessions has started - # (2026-10-14 plain-nl run, after a first-boot unit ran before it). - typed = self.exercise(refusals=2) - self.assertEqual(typed.count('qualification\n'), 3) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - - def test_lost_username_is_retried_at_an_empty_login_prompt(self): - typed = self.exercise(dropped_names=1) - self.assertEqual(typed.count('qualification\n'), 2) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - - def test_empty_prompt_retries_are_bounded_and_never_send_a_secret(self): - typed = self.exercise(dropped_names=99, failure='username') - self.assertEqual(typed.count('qualification\n'), 4) - self.assertNotIn('private-fixture-secret\n', typed) - - def test_a_late_greeter_does_not_hide_the_console_password_prompt(self): - # The display manager can start after the switch to tty3 and take the - # screen (2026-10-14 plain-nl run); tty3 still waits for the password. - typed, keys = self.exercise(late_greeter=True) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - self.assertGreaterEqual(keys.count('ctrl+alt+f3'), 2) - - def test_passwordless_fish_prompt_and_german_keymap(self): - typed = self.exercise(german=True) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - self.assertIn('sudo loadkezs us\n', typed) - self.assertIn('exec env HISTFILE=/dev/null bash --noprofile --norc\n', typed) - self.assertTrue(any('authorized_keys' in command for command in typed)) - - def test_passworded_sudo_authenticates_once(self): - typed = self.exercise(passworded=True) - self.assertEqual(typed.count('private-fixture-secret\n'), 2) - self.assertIn('sudo loadkeys us\n', typed) - - def test_dutch_sudo_ocr_misread_authenticates_once(self): - typed = self.exercise(passworded=True, - sudo_prompt='[sudo] uachtwoord voor qualification:') - self.assertEqual(typed.count('private-fixture-secret\n'), 2) - self.assertIn('sudo loadkeys us\n', typed) - - def test_echoed_command_alone_never_proves_shell(self): - typed = self.exercise(failure='shell') - self.assertEqual(typed.count('echo CONSOLEWORKS y\n'), 3) - self.assertFalse(any(command.startswith('sudo') for command in typed)) - - def test_failed_bash_confirmation_never_sends_setup(self): - typed = self.exercise(failure='bash') - self.assertFalse(any('authorized_keys' in command for command in typed)) - - def test_output_only_and_explicit_sudo_prompts(self): - self.assertFalse(console_output('prompt sudo echo CONSOLEAUTH', 'CONSOLEAUTH')) - self.assertTrue(console_output('CONSOLE AUTH', 'CONSOLEAUTH')) - for prompt in ('[sudo] password for qualification:', 'Passwort für qualification:', - 'wachtwoord voor qualification:', '[sudo] uachtwoord voor qualification:'): - self.assertTrue(sudo_password_prompt(prompt)) - for prompt in ('Password:', 'password for john:', 'qualification login:', - 'uachtwoord voor qualification:', '[sudo] uachtwoord voor john:'): - self.assertFalse(sudo_password_prompt(prompt)) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_desktop_payload.py b/image/test_desktop_payload.py deleted file mode 100644 index 42dbd99f..00000000 --- a/image/test_desktop_payload.py +++ /dev/null @@ -1,755 +0,0 @@ -"""No image builds: isolated fixtures for desktop parity and boot appearance.""" -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import re -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -import jinja2 -import yaml - -from boot_branding import brand_boot_menu, grub_install_entries -from desktop_payload import prepare_defaults, split_seed, prepare_quickshell - -ROOT = Path(__file__).resolve().parents[1] - - -def load(name, relative): - loader = importlib.machinery.SourceFileLoader(name, str(ROOT / relative)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -INIT = load("desktop_user_init", "image/rootfs/usr/libexec/cybexos-user-init") -ACCOUNTS = load("desktop_seed_accounts", "image/rootfs/usr/libexec/cybexos-seed-installed-users") - - -class DesktopPayload(unittest.TestCase): - def test_notification_popups_preserve_keyboard_focus_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("NotificationToasts.qml", "Common/NotifCard.qml", - "Popovers/Drawer/DrawerNotifications.qml"): - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - toast = (installed / "NotificationToasts.qml").read_text() - self.assertIn("WlrLayershell.keyboardFocus: WlrKeyboardFocus.None", toast) - self.assertIn("keyboardEnabled: false", toast) - self.assertIn("keyboardEnabled: true", (installed / "Popovers/Drawer/DrawerNotifications.qml").read_text()) - - def test_github_and_plugin_failure_recovery_ship_identically_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ("Common/GitHub.qml", "Common/GitHubHelpers.js", "Common/GitHubQueue.js", - "Common/CommandRequest.qml", "Common/ProcHelpers.js", "Common/UserPlugins.qml", - "Popovers/GitHubPopover.qml", "scripts/user-plugins.py", "scripts/plugin_packages.py", - "Common/i18n/nl.js") - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - - def test_settings_navigation_and_input_drafts_ship_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ("SettingsWindow.qml", "LauncherView.qml", "Settings/SettingsView.qml", - "Settings/InputDrafts.qml", "Settings/InputDraftState.qml", "Settings/InputDraft.js", - "Settings/KeyboardPage.qml", "Settings/TouchpadPage.qml", "Settings/qmldir", - "Settings/SettingsPage.qml", "Settings/RecoveryGroup.qml", "Common/SettingsSearchData.js", - "Popovers/Drawer/DrawerNotifications.qml", "Popovers/ActionButton.qml") - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - - def test_workspace_and_notification_runtime_ship_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ("Common/DesktopWorkspaces.qml", "Common/WorkspaceHelpers.js", - "Settings/WorkspacesPage.qml", "Common/NotifPolicy.js", - "Common/NotifSmart.js", "Common/NotifSnoozes.qml", "Common/NotifButton.qml", - "scripts/notification-history.py") - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - self.assertEqual((installed / "scripts/notification-history.py").stat().st_mode & 0o777, 0o644) - tasks = yaml.safe_load((ROOT / "roles/desktop/tasks/main.yml").read_text()) - modules = {"workspaces.lua", "workspace_state.lua", "workspace_model.lua", "workspace_layouts.lua"} - copy = next(t for t in tasks if "bindings.lua" in t.get("loop", [])) - self.assertTrue(modules.issubset(copy["loop"])) - # The RPM and checkout copy the very same source files, and the stale - # runtime cleanup must retain the companion modules on re-convergence. - package = (ROOT / "image/package").read_text() - cleanup = next(t for t in tasks if any("basename not in" in str(w) for w in t.get("when", []))) - for name in modules: - self.assertIn('"' + name + '"', package) - self.assertIn(name, str(cleanup["when"])) - self.assertTrue((ROOT / "roles/desktop/files" / name).is_file()) - - def test_launcher_focus_handoff_ships_identically_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("LauncherWindow.qml", "LauncherView.qml", "shell.qml"): - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - window = (installed / "LauncherWindow.qml").read_text() - self.assertIn("root.contentItem.Window.active", window) - self.assertIn("active: Launcher.open && root.dismissalGrabReady", window) - self.assertIn("WlrKeyboardFocus.Exclusive", window) - - def test_model_usage_runtime_matches_checkout_including_rust_live_activity(self): - source = ROOT / "roles/desktop/files/quickshell/ModelUsage" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell/ModelUsage" - files = {p.relative_to(source): p for p in source.rglob("*") - if p.is_file() and not p.is_symlink() and "__pycache__" not in p.parts and p.suffix != ".pyc"} - self.assertEqual(set(files), {p.relative_to(installed) for p in installed.rglob("*") if p.is_file()}) - for relative, path in files.items(): - expected = path.read_bytes() - if path.suffix in (".qml", ".js"): - expected = expected.replace(b"/usr/local/libexec/cybexos-", b"/usr/libexec/cybexos-") - self.assertEqual((installed / relative).read_bytes(), expected, str(relative)) - self.assertIn(b"def fetch_rust_account", (installed / "scripts/usage-fetch.py").read_bytes()) - self.assertIn(b"normalize_rust_activity", (installed / "scripts/proxy-activity.py").read_bytes()) - self.assertIn(b"additional_headers", (installed / "scripts/proxy-live.py").read_bytes()) - self.assertIn(b"UsageLogic.acquireLive", (installed / "UsageActivityBackend.qml").read_bytes()) - self.assertTrue((installed / "UsageLiveConnection.qml").is_file()) - helpers = payload / "usr/share/cybexos/runtime/quickshell/Common/SettingsHelpers.js" - self.assertIn(b"liveAccountActivity: true", helpers.read_bytes()) - self.assertNotIn(b"showBarActivity", helpers.read_bytes()) - self.assertNotIn(b"showRecentSessions", helpers.read_bytes()) - # Both installation contracts provide the optional transport. - self.assertIn("python3-websockets", (ROOT / "image/cybexos-desktop.spec").read_text()) - self.assertIn("python3-websockets", (ROOT / "roles/desktop/tasks/main.yml").read_text()) - - def test_fusebox_widget_ships_identically_with_the_same_layout_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ["scripts/fusebox.py", "Common/Fusebox.qml", "Common/FuseboxHelpers.js", - "Bar/Modules/Fusebox.qml", "Popovers/FuseboxPopover.qml", "assets/fusebox.svg", - "assets/fusebox-white.svg", "Common/SettingsHelpers.js", "Common/BrandIcons.qml"] - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - # The checkout and the ISO derive the layout from the same helpers and - # the same connected-widgets flag, so both start Fusebox the same way. - script = ("const H = require(process.argv[1]); " - "console.log(JSON.stringify([false, true].map(connected => " - "H.merge(null, { connectedWidgets: connected }).mods.right.map(m => m.id + ':' + m.on))));") - layouts = [subprocess.run(["node", "-e", script, str(base / "Common/SettingsHelpers.js")], - text=True, capture_output=True, check=True).stdout - for base in (checkout, installed)] - self.assertEqual(layouts[0], layouts[1]) - plain, connected = json.loads(layouts[1]) - self.assertIn("fusebox:false", plain) - self.assertIn("fusebox:true", connected) - self.assertEqual(connected[connected.index("modelusage:true") + 1], "fusebox:true") - unit = (ROOT / "image/provision.yml").read_text() - self.assertIn("CYBEXOS_CONNECTED_WIDGETS", unit) - self.assertIn("CYBEXOS_CONNECTED_WIDGETS", (ROOT / "image/desktop_payload.py").read_text()) - - def test_capture_tools_reach_both_installation_paths(self): - # The capture helpers are user helpers: the checkout copies every one - # into ~/.local/bin, the RPM into /usr/share/cybexos/bin, which - # cybexos-user-init links into ~/.local/bin. `screenshot` finds - # screenshot-stitch beside its resolved self, so both must be helpers. - contract = json.loads((ROOT / "assets/desktop-contract.json").read_text()) - helpers = ("screenshot", "screenshot-stitch", "screen-record", "screen-ocr", "color-picker") - for name in helpers: - self.assertIn(name, contract["userHelpers"]) - self.assertTrue((ROOT / "assets/scripts" / name).read_text().startswith("#!"), name) - screenshot = (ROOT / "assets/scripts/screenshot").read_text() - self.assertIn('SCRIPT_DIR=$(dirname -- "$SCRIPT_PATH")', screenshot) - self.assertIn('python3 "$SCRIPT_DIR/screenshot-stitch"', screenshot) - dotfiles = (ROOT / "roles/dotfiles/tasks/main.yml").read_text() - self.assertIn('loop: "{{ cybexos_desktop_contract.userHelpers }}"', dotfiles) - self.assertIn('contract["userHelpers"]', (ROOT / "image/package").read_text()) - self.assertIn("contract['userHelpers']", (ROOT / "image/repair-installed").read_text()) - - # Packages: the desktop task installs them on a checkout and is part - # of the ISO's compose; the RPM also requires them. - applications = load("desktop_payload_applications", "image/applications") - packages, _ = applications.package_names() - spec = (ROOT / "image/cybexos-desktop.spec").read_text() - requires = {name for line in spec.splitlines() if line.startswith("Requires:") - for name in line.split()[1:]} - for package in ("grim", "slurp", "satty", "wf-recorder", "hyprpicker", "ImageMagick", - "tesseract", "zbar", "qt6-qtmultimedia", "python3", "wl-clipboard"): - self.assertIn(package, packages) - self.assertIn(package, requires) - - # The shell side ships byte-identical, overlay registration included. - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("WebcamOverlay.qml", "qmldir", "shell.qml", "Common/Recorder.qml", - "Common/CaptureHelpers.js", "Bar/Modules/Indicators.qml"): - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - - # Both paths render the same bindings file. - bindings = (ROOT / "roles/desktop/files/bindings.lua").read_text() - for command in ("screenshot scroll", "screen-record --webcam", "color-picker"): - self.assertIn(f'home .. "/.local/bin/{command}"', bindings) - self.assertIn('"bindings.lua"', (ROOT / "image/package").read_text()) - - def test_shell_translations_ship_identically_on_both_paths(self): - """The I18n singleton and its catalogs are shell source: the checkout - deploys the tree and the RPM copies it, so both read the same Dutch.""" - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("Common/I18n.qml", "Common/I18nHelpers.js", "Common/i18n/nl.js", "Common/qmldir"): - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - self.assertIn("singleton I18n I18n.qml", (installed / "Common/qmldir").read_text()) - - def test_overview_dock_and_process_list_ship_identically_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ["shell.qml", "qmldir", "Common/qmldir", "Popovers/Drawer/qmldir", - "OverviewWindow.qml", "OverviewView.qml", "DockWindow.qml", - "Common/Overview.qml", "Common/Dock.qml", "Common/ProcessList.qml", - "Common/OverviewHelpers.js", "Common/DockHelpers.js", - "Common/ProcessListHelpers.js", "Common/WindowCommands.js", - "Common/DesktopWorkspaces.qml", "Common/WorkspaceHelpers.js", - "Common/LauncherProviders.js", "Common/SettingsSearchData.js", - "Common/i18n/nl.js", "Settings/WorkspacesPage.qml", - "Popovers/Drawer/DrawerProcesses.qml", "Popovers/Drawer/DrawerOverview.qml", - "Settings/BarLayoutGroups.qml", "scripts/processes"] - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - # Both paths run the helper through bash; the ISO also marks it executable. - self.assertEqual((installed / "scripts/processes").stat().st_mode & 0o777, 0o755) - # One schema decides the defaults on both: all-layout dock starts off and - # unpinned, Top processes starts shown. - script = ("const H = require(process.argv[1]); const d = H.merge(null); " - "console.log(JSON.stringify([d.dock, d.dockAutoHide, d.dockPinned, d.drawerOverview.processes]));") - defaults = [subprocess.run(["node", "-e", script, str(base / "Common/SettingsHelpers.js")], - text=True, capture_output=True, check=True).stdout - for base in (checkout, installed)] - self.assertEqual(defaults[0], defaults[1]) - self.assertEqual(json.loads(defaults[1]), [False, False, [], True]) - # Exercise visibility and browser matching from both payloads. - probe = ''' -const D = require(process.argv[1]); -const live = {id: 41, name: "41"}; -console.log(JSON.stringify([ - D.showOnWorkspace(false, live, {id: 41, layout: "floating"}), - D.showOnWorkspace(false, live, {id: 41, layout: "dwindle"}), - D.showOnWorkspace(false, live, {id: 41, layout: "scrolling"}), - D.showOnWorkspace(true, live, {id: 41, layout: "dwindle"}), - D.webAppEntryId("brave-web.whatsapp.com__-Default", - [{id: "whatsapp", url: "https://web.whatsapp.com/"}]) -])); -''' - for tree in (checkout, installed): - result = subprocess.run(["node", "-e", probe, str(tree / "Common/DockHelpers.js")], - check=True, capture_output=True, text=True) - self.assertEqual(json.loads(result.stdout), [True, False, False, True, "cybexos-webapp-whatsapp"]) - # Super+Tab and the overview/dock layer rules come from the one - # bindings.lua and looknfeel template that both installs use. - bindings = (ROOT / "roles/desktop/files/bindings.lua").read_text() - self.assertIn('bind(mainMod .. " + TAB", hl.dsp.global("quickshell:overviewToggle")', bindings) - package = (ROOT / "image/package").read_text() - self.assertIn('"bindings.lua"', package) - self.assertIn('"looknfeel.lua"', package) - self.assertIn("- bindings.lua", (ROOT / "roles/desktop/tasks/main.yml").read_text()) - look = (ROOT / "roles/desktop/templates/looknfeel.lua.j2").read_text() - self.assertIn("|wallpaper|overview|dock)$", look) - self.assertIn("|shortcuts|dock)$", look) - - def test_portable_defaults_and_boot_assets_share_workstation_sources(self): - environment = jinja2.Environment(undefined=jinja2.StrictUndefined) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - contract = prepare_defaults(ROOT, payload, environment, inventory) - vendor = payload / "usr/share/cybexos" - app = vendor / "user-seed/.rustup/toolchains/default/rustc" - app.parent.mkdir(parents=True) - app.write_bytes(b"offline compiler") - split_seed(vendor, contract) - defaults = json.loads((vendor / "essential-seed/.config/cybexos/shell.json").read_text()) - self.assertEqual(defaults, contract["shell"]) - # Exercise first-login seeding followed by the real shell merger: - # an unversioned seed keeps current defaults and no personal host. - (vendor / "bin").mkdir() - (vendor / "runtime").mkdir() - home = payload / "installed-user" - with patch.dict(os.environ, {}, clear=True): - INIT.initialize(home, vendor, mode="essential") - preferences = home / ".config/cybexos/shell.json" - result = subprocess.run([ - "node", "-e", - "const fs = require('fs'), H = require(process.argv[1]); " - "const s = H.merge(JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))); " - "console.log(JSON.stringify({font: H.FONT_CHOICES.find(f => f.id === s.font).family, " - "remote: s.mods.right.find(m => m.id === 'remote'), options: s.modOpts.remote}));", - str(ROOT / "roles/desktop/files/quickshell/Common/SettingsHelpers.js"), - str(preferences), - ], text=True, capture_output=True, check=True) - settings = json.loads(result.stdout) - self.assertEqual(settings["font"], "JetBrainsMono Nerd Font") - self.assertTrue(settings["remote"]["on"]) - self.assertEqual(settings["options"]["host"], "") - self.assertEqual(settings["options"]["label"], "") - wallpaper = vendor / "essential-seed/Pictures/Wallpapers" / defaults["wall"] - self.assertEqual(wallpaper.read_bytes(), (ROOT / "assets/wallpapers" / defaults["wall"]).read_bytes()) - self.assertTrue(app.is_file()) - self.assertFalse((vendor / "essential-seed/.rustup").exists()) - self.assertEqual(json.loads((vendor / "seed-groups.json").read_text())["totalBytes"], app.stat().st_size) - self.assertIn("x-scheme-handler/t3code=t3code-nightly.desktop", - (vendor / "essential-seed/.config/mimeapps.list").read_text()) - self.assertFalse(list(vendor.rglob("plugins.json"))) - self.assertFalse(list(vendor.rglob("user.lua"))) - for source in (ROOT / "roles/boot/files").iterdir(): - self.assertEqual((payload / "usr/share/plymouth/themes/cybex" / source.name).read_bytes(), source.read_bytes()) - self.assertEqual((payload / "usr/lib/sysctl.d/60-cybexos-hardening.conf").read_bytes(), - (ROOT / "roles/base/files/60-cybexos-hardening.conf").read_bytes()) - self.assertIn('target="DROP"', (payload / "usr/lib/firewalld/zones/cybexos.xml").read_text()) - - def test_gestures_and_game_mode_render_identically_for_checkout_and_iso(self): - """Touchpad gestures and game mode live in input.lua and looknfeel.lua. - - The checkout renders them with the machine's facts; image/package - renders them with dynamic hardware and keyboard lookups. Only those - lines may differ, so both paths share the same gestures, game mode, - bindings and helper. - """ - import json as json_module - import shlex - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no, - to_json=json_module.dumps, quote=shlex.quote) - base = {"features": inventory["features"], "cybexos_xps_2026": False, - "machine_keyboard_layout": "us", "machine_keyboard_variant": ""} - for name, allowed in (("input.lua", ("kb_layout", "kb_variant")), ("looknfeel.lua", ("force_zero_scaling",))): - template = environment.from_string((ROOT / "roles/desktop/templates" / (name + ".j2")).read_text()) - checkout = template.render(**base).splitlines() - iso = template.render(**base, cybexos_dynamic_hardware=True) - if name == "input.lua": - # As image/package does for the installed keyboard choice. - iso = iso.replace('kb_layout = "us"', 'kb_layout = os.getenv("CYBEXOS_KEYBOARD_LAYOUT") or "us"') - iso = iso.replace('kb_variant = ""', 'kb_variant = os.getenv("CYBEXOS_KEYBOARD_VARIANT") or ""') - iso = iso.splitlines() - self.assertEqual(len(checkout), len(iso), name) - different = [(a, b) for a, b in zip(checkout, iso) if a != b] - self.assertTrue(all(any(key in a for key in allowed) for a, _ in different), (name, different)) - package = (ROOT / "image/package").read_text() - self.assertIn('"input.lua", "looknfeel.lua"', package) - self.assertIn('"bindings.lua", "autostart.lua", "displays.lua", "input_preferences.lua"', package) - self.assertIn('helpers = contract["userHelpers"]', package) - self.assertIn("game-mode", json.loads((ROOT / "assets/desktop-contract.json").read_text())["userHelpers"]) - - def test_named_themes_ship_and_list_identically_on_both_paths(self): - """Built-in themes, the theme helper and `cybex theme` on both layouts.""" - checkout = ROOT / "roles/desktop/files/quickshell" - contract = json.loads((ROOT / "assets/desktop-contract.json").read_text()) - self.assertIn("cybexos-theme", contract["userHelpers"]) - with tempfile.TemporaryDirectory() as temporary: - base = Path(temporary) - payload = base / "payload" - prepare_quickshell(ROOT, payload) - vendor = payload / "usr/share/cybexos" - installed = vendor / "runtime/quickshell" - themes = sorted(p.relative_to(checkout) for p in (checkout / "themes").rglob("*") if p.is_file()) - self.assertGreaterEqual(len(themes), 8) - for relative in themes + [Path(name) for name in ( - "scripts/theme-packs.py", "scripts/theme_packs.py", "scripts/settings-store", - "Common/Themes.qml", "Common/ThemeHelpers.js", "Common/SettingsHelpers.js")]: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), - str(relative)) - self.assertEqual(sorted(p.relative_to(installed) for p in (installed / "themes").rglob("*") - if p.is_file()), themes) - # The RPM's helpers, as image/package installs them, and the - # account link cybexos-user-init makes to the vendor runtime. - (vendor / "bin").mkdir() - for name in ("cybexos-theme", "cybexos-runtime"): - target = vendor / "bin" / name - target.write_bytes((ROOT / "assets/scripts" / name).read_bytes()) - target.chmod(0o755) - iso_data = base / "iso-home/.local/share" - (iso_data / "cybexos").mkdir(parents=True) - (iso_data / "cybexos/runtime").symlink_to(vendor / "runtime") - # The checkout: helpers in ~/.local/bin and the deployed runtime. - checkout_home = base / "checkout-home" - (checkout_home / ".local/bin").mkdir(parents=True) - for name in ("cybexos-theme", "cybexos-runtime"): - target = checkout_home / ".local/bin" / name - target.write_bytes((ROOT / "assets/scripts" / name).read_bytes()) - target.chmod(0o755) - (checkout_home / ".local/share/cybexos/runtime").mkdir(parents=True) - (checkout_home / ".local/share/cybexos/runtime/quickshell").symlink_to(checkout) - - def listing(helper, home, data): - environment = {key: value for key, value in os.environ.items() - if not key.startswith(("XDG_", "CYBEXOS_"))} - environment.update(HOME=str(home), XDG_DATA_HOME=str(data), PYTHONDONTWRITEBYTECODE="1", - XDG_CONFIG_HOME=str(home / ".config")) - result = subprocess.run([str(helper), "list", "--json"], env=environment, - capture_output=True, text=True, check=False) - self.assertEqual(result.returncode, 0, result.stderr) - return json.loads(result.stdout) - iso = listing(vendor / "bin/cybexos-theme", base / "iso-home", iso_data) - source = listing(checkout_home / ".local/bin/cybexos-theme", checkout_home, - checkout_home / ".local/share") - self.assertEqual(iso, source) - self.assertEqual(iso["invalid"], []) - self.assertEqual(iso["themes"][0]["id"], "cybexos") - self.assertEqual(len(iso["themes"]), len(themes)) - self.assertTrue(all(theme["builtin"] for theme in iso["themes"])) - - def test_theme_command_dispatches_identically_on_both_paths(self): - environment = jinja2.Environment(undefined=jinja2.StrictUndefined) - environment.filters["quote"] = lambda value: "'" + str(value).replace("'", "'\\''") + "'" - with tempfile.TemporaryDirectory() as temporary: - base = Path(temporary) - stub = '#!/bin/sh\nprintf "%s|" "$@"\n' - home = base / "home" - (home / ".local/bin").mkdir(parents=True) - (home / ".local/bin/cybexos-theme").write_text(stub) - (home / ".local/bin/cybexos-theme").chmod(0o755) - checkout = base / "cybex-checkout" - checkout.write_text(environment.from_string( - (ROOT / "roles/dotfiles/templates/cybex.j2").read_text()).render(primary_home=str(home))) - vendor = base / "usr/share/cybexos/bin" - vendor.mkdir(parents=True) - (vendor / "cybexos-theme").write_text(stub) - (vendor / "cybexos-theme").chmod(0o755) - iso = base / "cybex-iso" - iso.write_text((ROOT / "image/rootfs/usr/bin/cybex").read_text().replace(" /usr/", f" {base}/usr/")) - for arguments in (["theme", "list", "--json"], ["theme", "apply", "nord"], - ["theme", "install", "https://github.com/a/b"], - ["theme", "export", "CURRENT", "/tmp/out dir", "--name", "Night shift"], - ["theme", "remove", "harbour"], ["theme"]): - results = [subprocess.run(["bash", str(script), *arguments], capture_output=True, - text=True, check=False) for script in (checkout, iso)] - self.assertEqual([r.returncode for r in results], [0, 0], arguments) - self.assertEqual(results[0].stdout, results[1].stdout, arguments) - # printf repeats its format per argument, and runs it once for none. - self.assertEqual(results[0].stdout, "".join(f"{a}|" for a in arguments[1:]) or "|") - for script in (checkout, iso): - usage = subprocess.run(["bash", str(script), "help"], capture_output=True, text=True).stdout - self.assertRegex(usage, r"\n theme +List, apply, install, export, or remove appearance themes\n") - - def test_contract_rejects_seed_escape(self): - with tempfile.TemporaryDirectory() as temporary: - with self.assertRaises(ValueError): - split_seed(Path(temporary), {"essentialPaths": ["../../private"]}) - - def test_personal_portal_is_disabled_without_private_binary(self): - template = jinja2.Environment(undefined=jinja2.StrictUndefined) - template.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - features = {key: True for key in ["podman", "developer_tools", "connected_widgets", "proprietary_apps", "private_hooks"]} - text = (ROOT / "roles/desktop/templates/features.lua.j2").read_text() - self.assertIn("private_portal = false", template.from_string(text).render(features=features, cybexos_xps_2026=False)) - self.assertIn("private_portal = true", template.from_string(text).render( - features=features, private_services={"portal_binary": "/opt/private/portal"}, cybexos_xps_2026=False)) - - def test_vpn_and_hotspot_ship_identically_with_their_runtime_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ["scripts/network-tool.py", "scripts/network_vpn_agent.py", "scripts/system_settings_network.py", - "Settings/VpnGroup.qml", "Settings/HotspotGroup.qml", "Settings/FolderDialog.qml", - "Settings/NetworkPage.qml", "Popovers/Drawer/DrawerNetwork.qml", "Common/NetworkDetails.qml", - "Common/NetworkHelpers.js", "NetworkOverlayWindow.qml"] - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - # The helpers run nmcli, qrencode and PyGObject (the VPN sign-in - # agent); NetworkManager's shared mode needs dnsmasq for the hotspot's - # clients. Both installation contracts name each one. - requires = {name for line in (ROOT / "image/cybexos-desktop.spec").read_text().splitlines() - if line.startswith("Requires:") for name in line.split(":", 1)[1].split()} - tasks = yaml.safe_load((ROOT / "roles/desktop/tasks/main.yml").read_text()) - packages = set() - for task in tasks: - names = (task.get("ansible.builtin.dnf") or {}).get("name", []) - packages.update(names if isinstance(names, list) else []) - for package in ("NetworkManager", "qrencode", "python3-gobject", "dnsmasq", "nm-connection-editor"): - self.assertIn(package, requires, package) - self.assertIn(package, packages, package) - - -class SeedLifecycle(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.environment = patch.dict(os.environ, {}, clear=True) - self.environment.start() - self.addCleanup(self.environment.stop) - self.root = Path(self.temporary.name) - self.vendor = self.root / "vendor" - self.home = self.root / "alice" - (self.vendor / "bin").mkdir(parents=True) - (self.vendor / "runtime").mkdir() - self.essential = self.vendor / "essential-seed/.config/cybexos/shell.json" - self.essential.parent.mkdir(parents=True) - self.essential.write_text('{"wall":"mountain.jpg"}') - self.binary = self.vendor / "user-seed/.rustup/toolchains/default/bin/rustc" - self.binary.parent.mkdir(parents=True) - self.binary.write_bytes(b"complete offline compiler") - self.binary.chmod(0o755) - (self.vendor / "seed-groups.json").write_text(json.dumps({"totalBytes": self.binary.stat().st_size})) - - def status(self): - return json.loads((self.home / ".local/state/cybexos/seed-progress.json").read_text()) - - def test_desktop_essentials_do_not_copy_toolchains(self): - INIT.initialize(self.home, self.vendor, mode="essential") - self.assertEqual((self.home / ".config/cybexos/shell.json").read_text(), self.essential.read_text()) - self.assertFalse((self.home / ".rustup").exists()) - self.assertEqual(self.status()["state"], "pending") - self.assertTrue((self.home / ".local/share/cybexos/runtime").is_symlink()) - INIT.initialize(self.home, self.vendor) - output = self.home / self.binary.relative_to(self.vendor / "user-seed") - self.assertEqual(output.read_bytes(), self.binary.read_bytes()) - self.assertEqual(output.stat().st_mode & 0o777, 0o755) - self.assertEqual(self.status()["state"], "ready") - self.assertEqual(self.status()["completedBytes"], self.binary.stat().st_size) - - def test_interrupted_copy_has_no_partial_final_file_and_resumes(self): - INIT.initialize(self.home, self.vendor, mode="essential") - output = self.home / self.binary.relative_to(self.vendor / "user-seed") - - def interrupted(_source, target, **_kwargs): - target.write(b"partial") - raise InterruptedError("fixture interruption") - - with patch.object(INIT.fcntl, "ioctl", side_effect=OSError("no reflink")), \ - patch.object(INIT.shutil, "copyfileobj", side_effect=interrupted): - with self.assertRaises(InterruptedError): - INIT.initialize(self.home, self.vendor) - self.assertFalse(output.exists()) - self.assertFalse(list(self.home.rglob(".cybexos-seed-*"))) - self.assertEqual(self.status()["state"], "error") - self.assertFalse((self.home / ".local/state/cybexos/offline-apps-seeded").exists()) - INIT.initialize(self.home, self.vendor) - self.assertEqual(output.read_bytes(), self.binary.read_bytes()) - - def test_editor_activation_follows_complete_offline_dependencies(self): - config = self.vendor / "final-seed/.config/nvim/init.lua" - config.parent.mkdir(parents=True) - config.write_text("-- activate offline plugins") - INIT.initialize(self.home, self.vendor, mode="essential") - self.assertFalse((self.home / ".config/nvim/init.lua").exists()) - original = INIT.seed_applications - - def ordered(source, destination, progress=None): - if source == self.vendor / "final-seed": - self.assertEqual((self.home / self.binary.relative_to(self.vendor / "user-seed")).read_bytes(), - self.binary.read_bytes()) - return original(source, destination, progress) - - with patch.object(INIT, "seed_applications", side_effect=ordered): - INIT.initialize(self.home, self.vendor) - self.assertEqual((self.home / ".config/nvim/init.lua").read_text(), config.read_text()) - - def test_wallpaper_replacement_preserves_saved_old_selection_and_personal_files(self): - # New seed names never authorize deleting an earlier installed wallpaper - # or overwriting a personal file that happens to share a new name. - directory = self.vendor / "essential-seed/Pictures/Wallpapers" - directory.mkdir(parents=True, exist_ok=True) - (directory / "moon-mountains.png").write_bytes((ROOT / "assets/wallpapers/moon-mountains.png").read_bytes()) - personal = self.home / "Pictures/Wallpapers" - personal.mkdir(parents=True) - old = personal / "snow-capped-mountains-with-full-moon-lo.jpg" - old.write_bytes(b"user retained earlier wallpaper") - replacement = personal / "moon-mountains.png" - replacement.write_bytes(b"personal image with same new filename") - settings = self.home / ".config/cybexos/shell.json" - settings.parent.mkdir(parents=True) - saved = json.dumps({"wall": old.name, "wallDir": "~/Pictures/Wallpapers", "accent": "#112233"}) - settings.write_text(saved) - INIT.initialize(self.home, self.vendor, mode="essential") - self.assertEqual(old.read_bytes(), b"user retained earlier wallpaper") - self.assertEqual(replacement.read_bytes(), b"personal image with same new filename") - self.assertEqual(settings.read_text(), saved) - # Checkout uses the same source directory with copy-if-absent semantics; - # actual task execution and both-path parity are checked in managed CI. - tasks = yaml.safe_load((ROOT / "roles/dotfiles/tasks/wallpapers.yml").read_text()) - copy = next(task["ansible.builtin.copy"] for task in tasks if task.get("name") == "Install bundled wallpapers") - self.assertFalse(copy["force"]) - self.assertEqual(copy["src"], "{{ config_repo }}/assets/wallpapers/") - - def test_existing_personal_file_and_symlink_are_preserved(self): - INIT.initialize(self.home, self.vendor, mode="essential") - settings = self.home / ".config/cybexos/shell.json" - settings.write_text('{"personal":true}') - external = self.root / "external" - external.mkdir() - (external / "sentinel").write_text("keep") - (self.home / ".rustup").symlink_to(external) - INIT.initialize(self.home, self.vendor) - self.assertEqual(settings.read_text(), '{"personal":true}') - self.assertEqual(list(external.iterdir()), [external / "sentinel"]) - self.assertTrue((self.home / ".rustup").is_symlink()) - - def test_installed_seeding_uses_only_real_accounts_as_their_owner(self): - from types import SimpleNamespace - def account(name, uid, home): - return SimpleNamespace(pw_name=name, pw_uid=uid, pw_dir=home) - users = [account("root", 0, "/root"), account("liveuser", 1000, "/home/liveuser"), - account("alice", 1001, "/home/alice"), account("service", 1002, "/var/lib/service")] - with patch.object(ACCOUNTS.os, "geteuid", return_value=0), \ - patch.object(ACCOUNTS.pwd, "getpwall", return_value=users), \ - patch.object(ACCOUNTS.subprocess, "run") as run: - ACCOUNTS.main() - self.assertEqual(run.call_count, 1) - command = run.call_args.args[0] - self.assertEqual(command[:5], ["runuser", "--user", "alice", "--", "env"]) - self.assertIn("HOME=/home/alice", command) - self.assertEqual(command[-1], "--all") - - -class BootMenu(unittest.TestCase): - def test_menu_styling_keeps_boot_and_recovery_commands(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - iso, install = root / "staging", root / "install" - grub = iso / "EFI/BOOT/grub.cfg" - grub.parent.mkdir(parents=True) - original = """set timeout=3 -search --set=root -l CYBEXOS-LIVE-44 -menuentry 'Start CybexOS' --class fedora { - linuxefi /vmlinuz root=live:LABEL=CYBEXOS-LIVE-44 quiet rhgb - initrdefi /initrd.img -} -menuentry 'Check media' { linux /vmlinuz rd.live.check; } -menuentry 'Basic graphics' { linux /vmlinuz nomodeset; } -""" - grub.write_text(original) - bios = iso / "isolinux/isolinux.cfg" - bios.parent.mkdir() - bios.write_text("menu background splash.jpg\nmenu title Linux\nlabel linux0\n menu label ^Start CybexOS\n" - " kernel /vmlinuz\n append initrd=/initrd.img root=live:CDLABEL=CYBEXOS-LIVE-44 quiet \n" - " menu default\nmenu separator\nlabel basic0\n menu label Start CybexOS in ^basic graphics mode.\n" - " kernel /vmlinuz\n append initrd=/initrd.img nomodeset\nlabel memtest\n menu label Run a ^memory test.\n" - " kernel memtest\n") - art = install / "usr/share/plymouth/themes/cybex/logo.png" - art.parent.mkdir(parents=True) - art.write_bytes(b"logo fixture") - brand_boot_menu(iso, install) - branded = grub.read_text() - self.assertTrue(branded.startswith(grub_install_entries(original))) - # Installation-only entries come first, so the default boot installs; - # every generated live entry, check and recovery entry remains. - self.assertEqual(re.findall(r"menuentry '([^']+)'", branded), - ["Install CybexOS", "Try CybexOS live", "Check media", "Basic graphics"]) - installer, live = re.findall(r"(?m)^\tlinuxefi (.*)$", branded) - self.assertTrue(installer.endswith(" cybexos.install")) - self.assertEqual(installer.removesuffix(" cybexos.install"), live.rstrip()) - menu = bios.read_text() - self.assertEqual(re.findall(r"(?m)^label (\S+)", menu), ["install-linux0", "linux0", "install-basic0", "basic0", "memtest"]) - self.assertEqual(re.findall(r"(?m)^\s*menu label (.*)", menu), - ["^Install CybexOS", "^Try CybexOS live", "Install CybexOS in basic graphics mode.", - "Try CybexOS live in ^basic graphics mode.", "Run a ^memory test."]) - self.assertEqual(menu.count("menu default"), 1) - self.assertLess(menu.index("menu default"), menu.index("label linux0")) - appends = re.findall(r"(?m)^\s*append (.*)", menu) - self.assertEqual([line.endswith("cybexos.install") for line in appends], [True, False, True, False]) - self.assertIn("menu title CybexOS", bios.read_text()) - self.assertIn("root=live:CDLABEL=CYBEXOS-LIVE-44", bios.read_text()) - self.assertNotIn("menu background", bios.read_text()) - first = grub.read_text(), bios.read_text() - brand_boot_menu(iso, install) - self.assertEqual(first, (grub.read_text(), bios.read_text())) - - -class T3Callback(unittest.TestCase): - def test_offline_launch_waits_for_seed_and_never_downloads_on_failure(self): - with tempfile.TemporaryDirectory() as temporary: - directory = Path(temporary) - bindir = directory / "bin" - bindir.mkdir() - vendor = directory / "vendor" - vendor.mkdir() - launcher = directory / "t3-desktop" - launcher.write_text((ROOT / "roles/dotfiles/templates/t3code-desktop.j2").read_text().replace( - "/usr/share/cybexos/user-seed", str(vendor))) - updater = directory / ".local/bin/t3code-update" - updater.parent.mkdir(parents=True) - updater.write_text('#!/bin/sh\ntouch "$HOME/download-attempted"\nexit 91\n') - updater.chmod(0o755) - systemctl = bindir / "systemctl" - systemctl.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$HOME/service-request"\nexit 1\n') - systemctl.chmod(0o755) - env = {"PATH": str(bindir) + ":/usr/bin", "HOME": str(directory)} - result = subprocess.run(["bash", str(launcher)], env=env, capture_output=True, text=True) - self.assertEqual(result.returncode, 1) - self.assertIn("not ready", result.stderr) - self.assertFalse((directory / "download-attempted").exists()) - self.assertIn("cybexos-app-seed.service", (directory / "service-request").read_text()) - # A successful service response still cannot fall back to the - # network when the expected offline binary is missing. - systemctl.write_text("#!/bin/sh\nexit 0\n") - result = subprocess.run(["bash", str(launcher)], env=env, capture_output=True, text=True) - self.assertEqual(result.returncode, 1) - self.assertIn("unavailable", result.stderr) - self.assertFalse((directory / "download-attempted").exists()) - # Once seeding produces the app, launch it with the original URI. - app = directory / ".local/share/t3code-nightly/T3-Code-Nightly-x86_64.AppImage" - app.parent.mkdir(parents=True) - app.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$HOME/app-args"\n') - app.chmod(0o755) - subprocess.run(["bash", str(launcher), "t3code://fixture"], env=env, check=True) - self.assertIn("t3code://fixture", (directory / "app-args").read_text()) - self.assertFalse((directory / "download-attempted").exists()) - - def test_oauth_redirect_uses_active_runtime_before_appimage(self): - with tempfile.TemporaryDirectory() as temporary: - directory = Path(temporary) - bindir = directory / ".local/bin" - bindir.mkdir(parents=True) - record = directory / "node-args" - runtime = bindir / "cybexos-runtime" - runtime.write_text('#!/bin/sh\nprintf "%s\\n" "$FIXTURE_RUNTIME"\n') - node = bindir / "node" - node.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$FIXTURE_RECORD"\n') - for file in (runtime, node): - file.chmod(0o755) - env = dict(os.environ, PATH=str(bindir) + ":/usr/bin", HOME=str(directory), - FIXTURE_RUNTIME=str(directory / "runtime"), FIXTURE_RECORD=str(record)) - subprocess.run(["bash", str(ROOT / "roles/dotfiles/templates/t3code-desktop.j2"), - "t3code://app/oauth?fixture=1"], env=env, check=True) - self.assertEqual(record.read_text().splitlines(), [str(directory / "runtime/scripts/t3-cloud.mjs"), - "oauth-callback", "t3code://app/oauth?fixture=1"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/image/test_display_policy.py b/image/test_display_policy.py deleted file mode 100644 index a6278000..00000000 --- a/image/test_display_policy.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Execute the shared XPS display tasks against disposable EDIDs and kernels.""" -import json -import os -from pathlib import Path -import subprocess -import tempfile -import unittest - -import yaml - -from provision_payload import prepare_provision - - -ROOT = Path(__file__).resolve().parents[1] -GRUBBY = '''#!/usr/bin/python3 -import json, os, sys -from pathlib import Path -root = Path(os.environ['DISPLAY_FIXTURE']) -state = root / 'kernels.json' -entries = json.loads(state.read_text()) -with (root / 'grubby.jsonl').open('a') as log: - log.write(json.dumps(sys.argv[1:]) + '\\n') -if sys.argv[1:] == ['--info=ALL']: - for entry in entries: - print('args="' + entry + '"') -elif sys.argv[1:] == ['--update-kernel=ALL', '--args=xe.enable_psr=0']: - if os.environ.get('DISPLAY_IGNORE_UPDATE') != '1': - entries = [' '.join([arg for arg in entry.split() - if not arg.startswith('xe.enable_psr=')] + ['xe.enable_psr=0']) - for entry in entries] - state.write_text(json.dumps(entries)) -else: - raise SystemExit('unexpected grubby invocation: ' + repr(sys.argv)) -''' - - -class PanelRefreshParity(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory(prefix='cybex-panel-refresh.') - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - payload = self.root / 'payload' - prepare_provision(ROOT, payload) - self.sources = (ROOT, payload / 'usr/share/cybexos/provision') - for relative in ('roles/xps-2026/tasks/main.yml', 'roles/xps-2026/tasks/display.yml', - 'roles/xps-2026/defaults/main.yml'): - self.assertEqual((self.sources[0] / relative).read_bytes(), - (self.sources[1] / relative).read_bytes()) - - def fixture(self, source, *, supported=True, sku='0DB9', edid='30e4c607', opt_out=False): - root = Path(tempfile.mkdtemp(dir=self.root)) - binaries = root / 'bin' - binaries.mkdir() - binary = binaries / 'grubby' - binary.write_text(GRUBBY) - binary.chmod(0o755) - panel = root / 'drm/card0-eDP-1/edid' - panel.parent.mkdir(parents=True) - if edid is not None: - panel.write_bytes(b'\x00\xff\xff\xff\xff\xff\xff\x00' + bytes.fromhex(edid) + bytes(116)) - entries = ['root=UUID=fixture quiet xe.enable_psr=0', - 'root=UUID=fixture quiet video=DP-2:d xe.enable_dpcd_backlight=1'] - (root / 'kernels.json').write_text(json.dumps(entries)) - # Substitute only the sysfs root. Run the actual probe, guard and - # convergence tasks; no host EDID, boot entry or privilege is used. - display = root / 'display.yml' - display.write_text((source / 'roles/xps-2026/tasks/display.yml').read_text() - .replace('/sys/class/drm/', str(root / 'drm') + '/')) - main = yaml.safe_load((source / 'roles/xps-2026/tasks/main.yml').read_text()) - gate = dict(next(task for task in main if task.get('ansible.builtin.import_tasks') == 'display.yml')) - gate['ansible.builtin.import_tasks'] = str(display) - variables = yaml.safe_load((source / 'roles/xps-2026/defaults/main.yml').read_text()) - variables.update(xps_2026_is_supported=supported, xps_2026_product_sku=sku) - if opt_out: - variables['xps_2026_psr_disabled_panels'] = [] - playbook = root / 'playbook.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, 'become': False, - 'vars': variables, 'tasks': [gate], - 'environment': {'PATH': str(binaries) + ':/usr/bin:/bin', 'DISPLAY_FIXTURE': str(root), - 'DISPLAY_IGNORE_UPDATE': "{{ fixture_ignore_update | default('0') }}"}, - }])) - return root, entries - - def run_play(self, root, *arguments, succeeds=True): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(root / 'playbook.yml'), - *arguments], text=True, capture_output=True, timeout=30, - env={**os.environ, 'ANSIBLE_STDOUT_CALLBACK': 'default', - 'ANSIBLE_NOCOLOR': '1', 'ANSIBLE_FORCE_COLOR': '0'}) - self.assertEqual(result.returncode == 0, succeeds, result.stdout + result.stderr) - - def test_both_paths_preserve_arguments_and_update_all_kernels_once(self): - outcomes = [] - for source in self.sources: - with self.subTest(source=source): - root, before = self.fixture(source) - self.run_play(root, '--check') - self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) - self.run_play(root) - self.run_play(root) - after = json.loads((root / 'kernels.json').read_text()) - self.assertEqual(after, [before[0], before[1] + ' xe.enable_psr=0']) - calls = [json.loads(line) for line in (root / 'grubby.jsonl').read_text().splitlines()] - self.assertEqual(sum('--update-kernel=ALL' in call for call in calls), 1) - outcomes.append(after) - self.assertEqual(outcomes[0], outcomes[1]) - - def test_other_panels_machines_missing_edid_and_saved_opt_out_are_untouched(self): - cases = ({'edid': '30e4c707'}, {'edid': '1234c607'}, {'edid': None}, - {'sku': '0DBA'}, {'supported': False}, {'opt_out': True}) - for source in self.sources: - for case in cases: - with self.subTest(source=source, case=case): - root, before = self.fixture(source, **case) - self.run_play(root) - self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) - self.assertFalse((root / 'grubby.jsonl').exists()) - - def test_silent_boot_entry_update_failure_is_reported_on_both_paths(self): - for source in self.sources: - with self.subTest(source=source): - root, before = self.fixture(source) - self.run_play(root, '-e', 'fixture_ignore_update=1', succeeds=False) - self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_doctor.py b/image/test_doctor.py deleted file mode 100644 index 5d02cb8d..00000000 --- a/image/test_doctor.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Fixture checks for the installed doctor; no live services or capture devices.""" -import importlib.machinery -import importlib.util -import json -from pathlib import Path -import tempfile -import unittest - -SCRIPT = Path(__file__).parent / 'rootfs/usr/libexec/cybexos-doctor' -loader = importlib.machinery.SourceFileLoader('cybexos_doctor', str(SCRIPT)) -spec = importlib.util.spec_from_loader(loader.name, loader) -doctor = importlib.util.module_from_spec(spec) -loader.exec_module(doctor) - - -class DoctorTests(unittest.TestCase): - def fixture(self, root, home): - def write(path, value): - target = root / path.lstrip('/') - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(json.dumps(value)) - write('/usr/share/cybexos/build.json', {'source_revision': 'a' * 40}) - write('/etc/cybexos/hardware.json', {'xps_2026': True}) - write('/var/lib/cybexos/hardware-status.json', {'state': 'pending', 'reason': 'reboot required for camera validation'}) - for path in ('/etc/grub.d/42_cybexos_recovery', - '/usr/lib/systemd/system/cybexos-recovery-refresh.service'): - target = root / path.lstrip('/') - target.parent.mkdir(parents=True, exist_ok=True) - target.touch() - state = home / '.local/state/cybexos' - state.mkdir(parents=True) - (state / 'seed-progress.json').write_text('{"state":"copying"}') - - def command(self, *argv): - if argv[0] == 'rpm': - return 0, 'cybexos-desktop-1.0' - if argv[0].endswith('cybexos-update-channel'): - return 0, '{"status":"desktop-channel-disabled"}' - if argv[0].endswith('cybexos-reconcile'): - return 0, '{"state":"ready"}' - if argv[:3] == ('systemctl', '--user', 'is-active'): - return 0, 'active' - if argv[:2] == ('systemctl', 'is-active'): - return 0, 'active' - if argv[:2] == ('systemctl', 'is-enabled'): - return 0, 'enabled' - if argv[0] == 'nmcli': - return 0, 'connected' - return 0, '' - - def test_pending_reboot_and_seed_are_explicit_warnings(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / 'root' - home = Path(temporary) / 'home' - self.fixture(root, home) - result = doctor.collect(root, home, self.command) - checks = {entry['name']: entry for entry in result['checks']} - self.assertEqual(result['overall'], 'warning') - self.assertEqual(checks['hardware']['state'], 'pending') - self.assertIn('Reboot', checks['hardware']['action']) - self.assertEqual(checks['seeded_apps']['state'], 'pending') - self.assertEqual(checks['desktop_channel']['state'], 'warning') - - def test_malformed_helper_json_is_reported_and_retry_uses_installed_path(self): - with tempfile.TemporaryDirectory() as temporary: - root, home = Path(temporary) / 'root', Path(temporary) / 'home' - self.fixture(root, home) - def command(*argv): - if argv[0].endswith('cybexos-update-channel'): - return 0, '[]' - if argv[0].endswith('cybexos-reconcile'): - return 0, '{"state":"blocked"}' - return self.command(*argv) - checks = {entry['name']: entry for entry in doctor.collect(root, home, command)['checks']} - self.assertEqual(checks['desktop_channel']['state'], 'fail') - self.assertIn('/usr/libexec/cybexos-reconcile --retry', checks['reconciliation']['action']) - def malformed(*argv): - if argv[0].endswith('cybexos-reconcile'): - return 0, '[]' - return self.command(*argv) - checks = {entry['name']: entry for entry in doctor.collect(root, home, malformed)['checks']} - self.assertEqual(checks['reconciliation']['state'], 'warning') - - def test_missing_package_failure_and_qml_error_are_reported_without_journal_text(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / 'root' - home = Path(temporary) / 'home' - self.fixture(root, home) - secret = 'private-widget-value' - def command(*argv): - if argv[0] == 'rpm': - return 1, '' - if argv[0] == 'journalctl': - return 0, 'QML error ' + secret - if argv[:2] == ('systemctl', '--failed'): - return 0, 'bad.service loaded failed failed' - return self.command(*argv) - result = doctor.collect(root, home, command) - checks = {entry['name']: entry for entry in result['checks']} - self.assertEqual(result['overall'], 'fail') - self.assertEqual(checks['build']['state'], 'fail') - self.assertEqual(checks['failed_units']['state'], 'fail') - self.assertEqual(checks['qml']['state'], 'warning') - self.assertNotIn(secret, json.dumps(result)) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_fedora_release.py b/image/test_fedora_release.py deleted file mode 100644 index e1cd1e8c..00000000 --- a/image/test_fedora_release.py +++ /dev/null @@ -1,227 +0,0 @@ -"""One targeted Fedora release, and no release literal that can drift from it. - -inventory/group_vars/all.yml fedora_release names the release both installation -paths target; release-manifest.json supportedFedora must include it. Files that -used to repeat the number derive it instead. The literals that remain are -checksum-pinned inputs (the Fedora Cloud builder image), published paths, or -names reviewed with the release (the required CI check); they must equal it. -The next-release CI preview must stay unable to block or slow a release. -These are source checks: they qualify no Fedora release. -""" -import configparser -import json -from pathlib import Path -import re -import tempfile -import unittest - -import yaml - -from fedora_target import fedora_release - -ROOT = Path(__file__).resolve().parents[1] - -# Release numbers in the forms these files spell them. -RELEASE_TOKEN = re.compile( - r"Fedora (\d+)\b|\bfedora:(\d+)\b|\bfc(\d+)\b|/releases/(\d+)/|Generic-(\d+)-" - r"|\bfedora-(\d+)-x86_64|RPM-GPG-KEY-fedora-(\d+)|repo=fedora(?:-cisco-openh264)?-(\d+)" - r"|updates-released-f(\d+)|--version F(\d+)\b|/(\d+)/x86_64|CybexOS-Live-(\d+)|CYBEXOS-LIVE-(\d+)") - -# These derive the release (inventory, $releasever or a CI build argument). -DERIVED = ( - ".github/actions/fedora-source-environment/action.yml", - ".github/workflows/live-image.yml", - "image/build-in-guest", - "image/build.repo", - "image/live.ks", -) -# These spell it out and are reviewed when it changes. Each must name it. -PINNED = ( - ".github/workflows/tests.yml", # required check name and its container - ".github/workflows/fedora-vm-convergence.yml", - ".github/workflows/desktop-release.yml", # RPM signing container - "image/build", # checksum-pinned Fedora Cloud builder image - "image/qualify-checkout", - "tests/fedora-vm-convergence", - "image/compose", # the livecd-tools behaviour its adapter compensates for - "image/channels/stable.json", # published update channel path - "image/github_release.py", - "inventory/group_vars/all.yml", # mdview fcNN asset, source-build base image -) - - -def release_tokens(relative): - for number, line in enumerate((ROOT / relative).read_text().splitlines(), 1): - for match in RELEASE_TOKEN.finditer(line): - yield number, match.group(0), next(group for group in match.groups() if group) - - -def workflow(name): - document = yaml.safe_load((ROOT / ".github/workflows" / name).read_text()) - # YAML 1.1 reads the bare key `on` as true. - document["on"] = document.pop(True, document.get("on")) - return document - - -def steps_using(job, action): - return [step for step in job["steps"] if step.get("uses", "").split("@")[0] == action] - - -class TargetedReleaseTests(unittest.TestCase): - def test_inventory_release_is_supported_by_the_release_manifest(self): - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - manifest = json.loads((ROOT / "release-manifest.json").read_text()) - self.assertIsInstance(inventory["fedora_release"], str) - self.assertIn(inventory["fedora_release"], manifest["supportedFedora"]) - self.assertEqual(fedora_release(), inventory["fedora_release"]) - - def test_helper_refuses_an_unsupported_or_ambiguous_declaration(self): - cases = { - "unsupported": ('fedora_release: "45"\n', ["44"]), - "unquoted": ("fedora_release: 44\n", ["44"]), - "repeated": ('fedora_release: "44"\nfedora_release: "44"\n', ["44"]), - "manifest without a list": ('fedora_release: "44"\n', "44"), - } - for label, (inventory, supported) in cases.items(): - with self.subTest(label), tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "inventory/group_vars").mkdir(parents=True) - (root / "inventory/group_vars/all.yml").write_text(inventory) - (root / "release-manifest.json").write_text(json.dumps({"supportedFedora": supported})) - with self.assertRaises(ValueError): - fedora_release(root) - - -class ReleaseLiteralTests(unittest.TestCase): - def test_derived_files_name_no_release(self): - for relative in DERIVED: - with self.subTest(relative): - self.assertEqual([], list(release_tokens(relative)), - "derive the release from inventory instead of spelling it") - - def test_pinned_release_literals_match_inventory(self): - release = fedora_release() - for relative in PINNED: - with self.subTest(relative): - tokens = list(release_tokens(relative)) - self.assertTrue(tokens, "names no release any more; move it to DERIVED") - stale = [f"{relative}:{line}: {text}" for line, text, number in tokens if number != release] - self.assertEqual([], stale, f"review these for Fedora {release} (see docs/fedora-major-upgrade.md)") - - def test_image_tooling_derives_the_release(self): - compose = (ROOT / "image/compose").read_text() - self.assertIn("release = fedora_release()", compose) - self.assertIn('releasever=release,', compose) - self.assertIn('f"CybexOS-Live-{fedora_release()}"', (ROOT / "image/build").read_text()) - self.assertIn('fedora_release=$(python3 "$source_root/image/fedora_target.py")', - (ROOT / "image/build-in-guest").read_text()) - self.assertIn("releases/$releasever/", (ROOT / "image/live.ks").read_text()) - # CI passes the release as a build argument; the default serves local builds. - containerfile = (ROOT / "image/Containerfile.tests").read_text().splitlines() - self.assertEqual([f"ARG FEDORA_RELEASE={fedora_release()}", "FROM fedora:${FEDORA_RELEASE}"], - [line for line in containerfile if line.startswith(("ARG ", "FROM "))]) - - def test_image_repositories_expand_to_the_targeted_release(self): - release = fedora_release() - repositories = configparser.ConfigParser(interpolation=None) - repositories.read(ROOT / "image/build.repo") - for section in ("fedora", "updates", "fedora-cisco-openh264"): - entry = repositories[section] - for key in ("metalink", "gpgkey"): - self.assertIn("$releasever", entry[key], f"{section} {key}") - # image/compose substitutes exactly these two variables. - expanded = entry[key].replace("$releasever", release).replace("$basearch", "x86_64") - self.assertNotIn("$", expanded, f"{section} {key}") - self.assertEqual(repositories["fedora"]["gpgkey"], - "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64") - # The image composes from the COPR chroot the workstation template uses. - template = configparser.ConfigParser(interpolation=None) - template.read(ROOT / "roles/desktop/templates/hyprland-copr.repo.j2") - workstation = template["copr:copr.fedorainfracloud.org:sdegler:hyprland"]["baseurl"] - self.assertEqual(repositories["hyprland"]["baseurl"], workstation.replace("$basearch", "x86_64")) - - def test_fedora_cloud_builder_pins_agree(self): - def assigned(source, pattern): - return re.search(pattern, source, re.MULTILINE).group(1) - - pins = {} - for relative in ("image/build", "image/qualify-checkout"): - source = (ROOT / relative).read_text() - name = assigned(source, r"^IMAGE = ['\"]([^'\"]+)['\"]$") - pins[relative] = (assigned(source, r"^IMAGE_URL = ['\"]([^'\"]+)['\"] \+ IMAGE$") + name, name, - assigned(source, r"^IMAGE_SHA256 = ['\"]([0-9a-f]{64})['\"]$")) - runner = (ROOT / "tests/fedora-vm-convergence").read_text() - url = assigned(runner, r"^image_url=\$\{FEDORA_VM_IMAGE_URL:-([^}]+)\}$") - pins["tests/fedora-vm-convergence"] = (url, url.rsplit("/", 1)[1], - assigned(runner, r"^image_sha256=\$\{FEDORA_VM_IMAGE_SHA256:-([0-9a-f]{64})\}$")) - # The image name and its checksum change together, in every consumer. - self.assertEqual(1, len(set(pins.values())), pins) - url, name, _ = pins["image/build"] - release = fedora_release() - self.assertTrue(name.startswith(f"Fedora-Cloud-Base-Generic-{release}-"), name) - self.assertTrue(url.startswith(f"https://download.fedoraproject.org/pub/fedora/linux/releases/{release}/"), url) - - -class ContinuousIntegrationTests(unittest.TestCase): - def test_required_source_contract_tracks_inventory(self): - release = fedora_release() - tests = workflow("tests.yml") - # release.yml gates a release on this workflow, so every job is required. - self.assertIn("workflow_call", tests["on"]) - for job in tests["jobs"].values(): - self.assertNotIn("continue-on-error", job) - # Branch protection names the job; keep its id when the release changes. - job = tests["jobs"]["fedora-44"] - self.assertEqual(f"Fedora {release} source contract", job["name"]) - self.assertEqual(f"fedora:{release}", job["container"]["image"]) - [environment] = steps_using(job, "./.github/actions/fedora-source-environment") - # Without inputs the shared step asserts the inventory release strictly. - self.assertNotIn("with", environment) - self.assertEqual("./tests/run", job["steps"][-1]["run"]) - action = yaml.safe_load((ROOT / ".github/actions/fedora-source-environment/action.yml").read_text()) - self.assertEqual("false", action["inputs"]["best-effort"]["default"]) - self.assertIn("python3 image/fedora_target.py", action["runs"]["steps"][-1]["run"]) - - def test_preview_cannot_block_or_slow_a_release(self): - release = fedora_release() - supported = json.loads((ROOT / "release-manifest.json").read_text())["supportedFedora"] - preview = workflow("fedora-preview.yml") - self.assertLessEqual(set(preview["on"]), {"pull_request", "push", "schedule", "workflow_dispatch"}) - self.assertEqual({"branches": ["main"]}, preview["on"]["push"]) - # Nothing calls the preview, release.yml included. - for path in sorted((ROOT / ".github/workflows").glob("*.yml")): - self.assertNotRegex(path.read_text(), r"uses:\s*\S*fedora-preview", path.name) - tests = workflow("tests.yml")["jobs"]["fedora-44"] - for job in preview["jobs"].values(): - self.assertIs(True, job["continue-on-error"]) - self.assertIs(False, job["strategy"]["fail-fast"]) - releases = job["strategy"]["matrix"]["fedora"] - self.assertTrue(releases) - for next_release in releases: - # A qualified release belongs in tests.yml, not in a preview. - self.assertRegex(next_release, r"^[1-9][0-9]*$") - self.assertGreater(int(next_release), int(release)) - self.assertNotIn(next_release, supported) - self.assertEqual("fedora:${{ matrix.fedora }}", job["container"]["image"]) - self.assertEqual(tests["env"], job["env"]) - [environment] = steps_using(job, "./.github/actions/fedora-source-environment") - self.assertEqual({"fedora-release": "${{ matrix.fedora }}", "best-effort": "true"}, - environment["with"]) - self.assertEqual(steps_using(tests, "actions/checkout")[0]["uses"], - steps_using(job, "actions/checkout")[0]["uses"]) - # Every check runs; what the release lacked still fails the job. - runs = [step.get("run", "") for step in job["steps"]] - self.assertIn("./tests/run", runs) - self.assertIn("steps.environment.outputs.unavailable != ''", job["steps"][-1]["if"]) - self.assertIn("exit 1", job["steps"][-1]["run"]) - - def test_image_source_contract_builds_the_inventory_release(self): - job = workflow("live-image.yml")["jobs"]["welcome-and-accounts"] - commands = "\n".join(step.get("run", "") for step in job["steps"]) - self.assertIn("release=$(python3 image/fedora_target.py)", commands) - self.assertIn('--build-arg FEDORA_RELEASE="$FEDORA_RELEASE"', commands) - self.assertEqual(2, commands.count('--version "F$FEDORA_RELEASE"')) - - -if __name__ == "__main__": - unittest.main() diff --git a/image/test_github_release.py b/image/test_github_release.py deleted file mode 100644 index 5ae3732c..00000000 --- a/image/test_github_release.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Offline release staging gates, metadata integrity and ISO reconstruction.""" -import gzip -import importlib.machinery -import importlib.util -import json -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -import github_release as release -import sbom -from qualification_contract import SCHEMA, required_checks - -loader = importlib.machinery.SourceFileLoader('reconstruct_iso', str(Path(__file__).with_name('reconstruct-iso'))) -spec = importlib.util.spec_from_loader(loader.name, loader) -reconstruct = importlib.util.module_from_spec(spec) -loader.exec_module(reconstruct) -FINGERPRINT = 'A' * 40 -REPOSITORY = 'DigitalPals/CybexOS' -TAG = 'v1.2.3' -BASE = 'https://digitalpals.github.io/CybexOS/44/x86_64' - - -def checksums(root): - paths = sorted(path for path in root.rglob('*') if path.is_file() and path.name != 'SHA256SUMS') - (root / 'SHA256SUMS').write_text(''.join(f'{release.sha256(path)} {path.relative_to(root)}\n' for path in paths)) - - -class GitHubRelease(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - self.signed, self.artifacts = self.root / 'signed', self.root / 'artifacts' - self.output = self.root / 'result' - (self.signed / 'Packages').mkdir(parents=True) - (self.signed / 'repodata').mkdir() - self.artifacts.mkdir() - self.rpm = self.signed / 'Packages/cybexos-desktop.rpm' - self.rpm.write_bytes(b'fixture signed RPM bytes') - self.iso = self.artifacts / 'CybexOS-fixture.iso' - self.iso.write_bytes(b'fixture ISO image bytes') - checksums(self.artifacts) - self.package = {'name': 'cybexos-desktop', 'epoch': '1', 'version': '1.2.3', - 'release': '1.fc44', 'arch': 'x86_64', - 'file': 'Packages/' + self.rpm.name, - 'sha256': release.sha256(self.rpm), 'unsigned_input_sha256': 'b' * 64, - 'url': release.github_url(REPOSITORY, TAG) + '/' + self.rpm.name} - self.manifest = {'format': 1, 'fingerprint': FINGERPRINT, 'baseurl': BASE, - 'packages': [self.package]} - self.sbom = self.write_sbom() - checksums(self.artifacts) - (self.signed / 'release.json.asc').write_text('fixture detached signature') - (self.signed / 'CYBEXOS-desktop.asc').write_text('fixture public key') - (self.signed / 'update-channel.json').write_text(json.dumps({ - 'baseurl': BASE, 'fingerprint': FINGERPRINT, 'key_file': 'CYBEXOS-desktop.asc'})) - self.metadata() - self.reports = [] - for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): - self.report(scenario, release.sha256(self.iso), ['graphical-installer']) - self.report('upgrade', 'c' * 64, ['installed-rpm-upgrade', 'recovery-boot-restore'], 'b' * 64) - - def write_sbom(self, iso_digest=None, rpm_digest='b' * 64, name=None): - record = '\t'.join(('cybexos-desktop', '1', '1.2.3', '1.fc44', 'x86_64', 'MIT', - '(none)', '(none)', '(none)', '(none)')) + '\n' - document = sbom.build_document( - rpms=sbom.parse_rpm_inventory(record), flatpaks=[], pins=[], version='1.2.3', - provenance={'utc': '2026-09-26T00:00:00Z', 'build_id': 'fixture', 'source_revision': 'a' * 40}, - iso=(self.iso.name, iso_digest or release.sha256(self.iso)), - desktop_rpm=('cybexos-desktop-1.2.3-1.fc44.x86_64.rpm', rpm_digest)) - path = self.artifacts / (name or sbom.sbom_name(self.iso.name)) - path.write_bytes(sbom.serialize(document)) - return path - - def metadata(self, package_url=None): - package = self.package - primary = ('' - 'cybexos-desktopx86_64' - '' - f'{package["sha256"]}' - f'') - metadata = self.signed / 'repodata/primary.xml.gz' - metadata.write_bytes(gzip.compress(primary.encode())) - repomd = ('' - f'{release.sha256(metadata)}' - '') - (self.signed / 'repodata/repomd.xml').write_text(repomd) - (self.signed / 'repodata/repomd.xml.asc').write_text('fixture detached signature') - self.save_manifest() - - def save_manifest(self): - (self.signed / 'release.json').write_text(json.dumps(self.manifest)) - checksums(self.signed) - - def report(self, scenario, iso, checks, rpm=None): - path = self.root / (scenario + '.json') - kind = 'upgrade' if rpm else 'fresh' - actual_scenario = 'encrypted-us' if scenario == 'upgrade' else scenario - report = {'scenario': actual_scenario, 'iso_sha256': iso, 'status': 'passed', - 'checks': sorted(required_checks(actual_scenario, kind)), - 'evidence_schema': SCHEMA, 'execution_mode': 'qemu', 'qualification_kind': kind, - 'network': 'outbound-blocked', 'firmware': 'uefi', 'boot_entry': 'install', - 'legacy_installer': bool(rpm), 'source_revision': 'a' * 40, - 'source_content_sha256': 'd' * 64, 'candidate_rpm_sha256': rpm} - if scenario.startswith('plain-'): - report.update(source_revision='a' * 40, source_content_sha256='d' * 64, - checkout_parity={'status': 'passed', 'scenario': scenario, 'source_revision': 'a' * 40, - 'source_content_sha256': 'd' * 64, 'checks': ['full-checkout-installation', - 'installed-parity-fresh', 'installed-parity-saved', 'full-graphical-session']}) - report['checks'] += ['installed-parity-fresh', 'installed-parity-saved'] - path.write_text(json.dumps(report)) - self.reports.append(path) - return path - - def test_checkout_parity_cannot_be_missing_stale_or_fixture_only(self): - target = self.root / 'plain-us.json' - original = json.loads(target.read_text()) - for replacement in ({}, {'status': 'passed', 'source_content_sha256': 'e' * 64}, - {**original['checkout_parity'], 'checks': ['source-fixtures']}, - {**original['checkout_parity'], 'source_revision': 'b' * 40}): - with self.subTest(replacement=replacement): - target.write_text(json.dumps({**original, 'checkout_parity': replacement})) - with self.assertRaisesRegex(ValueError, 'same-source real checkout/ISO parity'): - self.prepare() - self.assertFalse(self.output.exists()) - target.write_text(json.dumps(original)) - - def prepare(self): - # These fixtures are deliberately not signed releases or VM evidence. - # Other tests cover the fail-closed signature subprocess boundary. - with patch.object(release, 'verify_release') as signatures, patch.object(release, 'verify_signed_rpm') as rpm: - result = release.prepare(self.signed, self.artifacts, self.output, - REPOSITORY, TAG, FINGERPRINT, self.reports) - self.assertEqual(signatures.call_count, 2) - rpm.assert_called_once() - return result - - def test_stages_qualified_release_and_metadata_without_rpm_on_pages(self): - self.assertEqual(self.prepare(), self.output) - self.assertTrue((self.output / 'assets' / self.rpm.name).is_file()) - self.assertFalse(list((self.output / 'pages').rglob('*.rpm'))) - self.assertTrue((self.output / 'assets/reconstruct-iso.py').is_file()) - self.assertEqual(len(json.loads((self.output / 'assets/qualification-reports.json').read_text())), 5) - self.assertEqual((self.output / 'assets/CybexOS-fixture.spdx.json').read_bytes(), self.sbom.read_bytes()) - self.assertIn('CybexOS-fixture.spdx.json', - release.verify_checksums(self.output / 'assets', 'desktop-SHA256SUMS')) - self.assertNotIn('CybexOS-fixture.spdx.json', release.verify_checksums(self.output / 'pages/44/x86_64')) - - def test_sbom_must_describe_the_exact_iso_and_candidate_rpm(self): - for label, change, message in ( - ('missing', lambda: self.sbom.unlink(), 'SPDX SBOM'), - ('second', lambda: self.write_sbom(name='CybexOS-other.spdx.json'), 'SPDX SBOM'), - ('other ISO', lambda: self.write_sbom(iso_digest='0' * 64), 'exact ISO'), - ('other RPM', lambda: self.write_sbom(rpm_digest='0' * 64), 'candidate desktop RPM'), - ('not SPDX', lambda: self.sbom.write_text('{"spdxVersion": "SPDX-2.2"}'), 'SPDX 2.3'), - ): - with self.subTest(label=label): - change() - checksums(self.artifacts) - with self.assertRaisesRegex(ValueError, message): - self.prepare() - self.assertFalse(self.output.exists()) - for extra in self.artifacts.glob('*.spdx.json'): - extra.unlink() - self.sbom = self.write_sbom() - checksums(self.artifacts) - self.assertEqual(self.prepare(), self.output) - - def test_checksum_inventory_cannot_hide_unlisted_files_or_symlinks(self): - extra = self.signed / 'repodata/unlisted' - extra.write_text('unlisted') - with self.assertRaisesRegex(ValueError, 'inventory'): - self.prepare() - extra.unlink() - extra.symlink_to(self.iso) - with self.assertRaisesRegex(ValueError, 'regular'): - self.prepare() - self.assertFalse(self.output.exists()) - - def test_checksum_path_escape_is_rejected(self): - (self.signed / 'SHA256SUMS').write_text(f'{release.sha256(self.iso)} ../artifacts/{self.iso.name}\n') - with self.assertRaisesRegex(ValueError, 'Unsafe'): - self.prepare() - - def test_invalid_signature_never_publishes(self): - with patch.object(release, 'verify_release', side_effect=subprocess.CalledProcessError(1, ['gpg'])): - with self.assertRaises(subprocess.CalledProcessError): - release.prepare(self.signed, self.artifacts, self.output, REPOSITORY, TAG, FINGERPRINT, self.reports) - self.assertFalse(self.output.exists()) - self.assertFalse(list(self.root.glob('.cybexos-github-*'))) - - def test_asset_limit_is_enforced_before_signatures_or_copying(self): - with patch.object(release, 'ASSET_LIMIT', self.rpm.stat().st_size): - with self.assertRaisesRegex(ValueError, '2 GiB'): - self.prepare() - self.assertFalse(self.output.exists()) - - def test_release_tag_and_pages_channel_must_match(self): - self.package['version'] = '1.2.4' - self.save_manifest() - with self.assertRaisesRegex(ValueError, 'release tag'): - self.prepare() - self.package['version'] = '1.2.3' - self.manifest['baseurl'] = 'https://unrelated.example/repo' - self.save_manifest() - with self.assertRaisesRegex(ValueError, 'GitHub Pages'): - self.prepare() - - def test_authenticated_metadata_must_reference_the_exact_uploaded_rpm(self): - self.metadata(package_url='https://unrelated.example/package.rpm') - with self.assertRaisesRegex(ValueError, 'package URL'): - self.prepare() - self.assertFalse(self.output.exists()) - - def test_repodata_hash_is_verified_independently_of_checksum_inventory(self): - (self.signed / 'repodata/primary.xml.gz').write_bytes(b'corrupt metadata') - checksums(self.signed) - with self.assertRaisesRegex(ValueError, 'metadata checksum'): - self.prepare() - - def test_missing_fresh_scenario_and_wrong_iso_are_blocked(self): - self.reports.pop(0) - with self.assertRaisesRegex(ValueError, 'four fresh'): - self.prepare() - self.report('encrypted-us', 'd' * 64, ['graphical-installer']) - with self.assertRaisesRegex(ValueError, 'four fresh'): - self.prepare() - - def test_upgrade_requires_different_iso_exact_candidate_and_recovery(self): - upgrade = self.reports[-1] - base = json.loads(upgrade.read_text()) - for changes in ({'iso_sha256': release.sha256(self.iso)}, - {'candidate_rpm_sha256': 'd' * 64}, - {'checks': ['installed-rpm-upgrade']}): - upgrade.write_text(json.dumps({**base, **changes})) - with self.assertRaisesRegex(ValueError, 'prior ISO|required scenario evidence'): - self.prepare() - upgrade.write_text(json.dumps({**base, 'status': 'failed'})) - with self.assertRaisesRegex(ValueError, 'must have passed'): - self.prepare() - - def test_every_mandatory_scenario_check_is_required_by_release_admission(self): - for path in self.reports: - original = json.loads(path.read_text()) - mandatory = required_checks(original['scenario'], original['qualification_kind']) - for check in sorted(mandatory): - with self.subTest(report=path.name, omitted=check): - report = {**original, 'checks': [item for item in original['checks'] if item != check]} - path.write_text(json.dumps(report)) - with self.assertRaisesRegex(ValueError, 'required scenario evidence'): - self.prepare() - self.assertFalse(self.output.exists()) - path.write_text(json.dumps(original)) - - def test_execution_and_schema_omissions_cannot_claim_passed_guest_evidence(self): - path = self.reports[0] - original = json.loads(path.read_text()) - for key in ('evidence_schema', 'execution_mode', 'qualification_kind', 'firmware', - 'boot_entry', 'network', 'legacy_installer', 'source_revision', 'source_content_sha256'): - for replacement in (None, 'fixture', True): - with self.subTest(key=key, replacement=replacement): - report = {**original, key: replacement} - if replacement is None: - report.pop(key) - path.write_text(json.dumps(report)) - with self.assertRaises(ValueError): - self.prepare() - self.assertFalse(self.output.exists()) - path.write_text(json.dumps(original)) - - def test_candidate_source_binding_cannot_disagree_between_scenarios(self): - path = self.reports[-1] - report = json.loads(path.read_text()) - report['source_revision'] = 'e' * 40 - path.write_text(json.dumps(report)) - with self.assertRaisesRegex(ValueError, 'same candidate source'): - self.prepare() - - def test_signed_verification_uses_the_expected_key_and_both_signatures(self): - work = self.root / 'verify' - work.mkdir() - with patch.object(release, 'public_key') as key, patch.object(release.subprocess, 'run') as run: - release.verify_release(self.signed, FINGERPRINT, work) - key.assert_called_once_with(self.signed / 'CYBEXOS-desktop.asc', FINGERPRINT) - self.assertEqual(len(run.call_args_list), 3) - signatures = [call.args[0] for call in run.call_args_list[1:]] - self.assertTrue(all('--verify' in command for command in signatures)) - self.assertIn(str(self.signed / 'release.json.asc'), signatures[0]) - self.assertIn(str(self.signed / 'repodata/repomd.xml.asc'), signatures[1]) - - -class IsoReconstruction(unittest.TestCase): - def test_chunks_reconstruct_exact_bytes_and_preserve_existing_output(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source, assets = root / 'CybexOS.iso', root / 'assets' - assets.mkdir() - source.write_bytes(bytes(range(256)) * 17) - record = release.split_iso(source, assets, chunk_size=127) - self.assertTrue(all(part['bytes'] <= 127 for part in record['parts'])) - manifest = assets / 'CybexOS.iso.parts.json' - result = reconstruct.reconstruct(manifest) - self.assertEqual(result.read_bytes(), source.read_bytes()) - self.assertEqual(release.sha256(result), record['sha256']) - with self.assertRaisesRegex(ValueError, 'already exists'): - reconstruct.reconstruct(manifest) - self.assertFalse(list(assets.glob('.cybexos-iso-*'))) - - def test_corruption_missing_parts_and_path_traversal_leave_no_partial_iso(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source, assets = root / 'CybexOS.iso', root / 'assets' - assets.mkdir() - source.write_bytes(b'123456789') - release.split_iso(source, assets, chunk_size=3) - manifest = assets / 'CybexOS.iso.parts.json' - record = json.loads(manifest.read_text()) - part = assets / record['parts'][0]['file'] - part.write_bytes(b'bad') - with self.assertRaisesRegex(ValueError, 'checksum'): - reconstruct.reconstruct(manifest) - part.unlink() - with self.assertRaisesRegex(ValueError, 'regular'): - reconstruct.reconstruct(manifest) - record['parts'][0]['file'] = '../outside' - manifest.write_text(json.dumps(record)) - with self.assertRaisesRegex(ValueError, 'Invalid'): - reconstruct.reconstruct(manifest) - self.assertFalse((assets / 'CybexOS.iso').exists()) - self.assertFalse(list(assets.glob('.cybexos-iso-*'))) - - def test_invalid_chunk_sizes_fail_before_creating_parts(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / 'CybexOS.iso' - source.write_bytes(b'image') - for size in (0, -1, release.ASSET_LIMIT): - with self.assertRaisesRegex(ValueError, 'part size'): - release.split_iso(source, root, size) - self.assertEqual(list(root.iterdir()), [source]) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installation_parity.py b/image/test_installation_parity.py deleted file mode 100644 index b2cabe1a..00000000 --- a/image/test_installation_parity.py +++ /dev/null @@ -1,714 +0,0 @@ -"""Compare real checkout configuration with the ISO's installed policy.""" -import json -import os -from pathlib import Path -import pty -import shlex -import subprocess -import unittest - -import yaml - -from test_reconfigure import Fixture, ROOT, config, target - - -class InstallationParity(Fixture): - def setUp(self): - super().setUp() - self.home = self.root / 'home/alice' - self.home.mkdir(parents=True) - self.bin = self.root / 'bin' - self.bin.mkdir() - self.installer = self.root / 'install' - self.installer.write_text((ROOT / 'install').read_text().replace( - 'repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)', - 'repo_dir=' + shlex.quote(str(ROOT)), - ).replace('[[ -r /etc/fedora-release ]]', 'true')) - for name, body in { - 'getent': f"printf '%s\\n' 'alice:x:1001:1001::{self.home}:/bin/bash'", - 'id': "printf '%s\\n' alice", - 'hostnamectl': "printf '%s\\n' studio", - 'timedatectl': "printf '%s\\n' Europe/Amsterdam", - 'locale': "printf '%s\\n' LANG=nl_NL.UTF-8", - 'localectl': 'echo "unsupported installer probe: localectl $*" >&2; exit 98', - 'sudo': 'echo "--check must never elevate or install" >&2; exit 97', - }.items(): - self.command(name, body) - self.environment = dict(os.environ, HOME=str(self.home), SUDO_USER='alice', - CYBEXOS_CONFIG_FILE=str(self.path), - PATH=f"{self.bin}:{os.environ['PATH']}", PYTHONDONTWRITEBYTECODE='1') - self.keyboard('us', 'dvorak') - - def command(self, name, body): - path = self.bin / name - path.write_text('#!/bin/sh\n' + body + '\n') - path.chmod(0o755) - - def keyboard(self, layout, variant): - # busctl get-property emits one typed JSON object per requested - # property, with a scalar string in data (not the call command's array). - response = '\n'.join(json.dumps({'type': 's', 'data': value}) - for value in (layout, variant)) - arguments = ('--system --json=short get-property ' - 'org.freedesktop.locale1 /org/freedesktop/locale1 ' - 'org.freedesktop.locale1 X11Layout X11Variant') - self.discovery_log = self.root / 'keyboard-discovery.log' - self.command('busctl', - 'printf "%s\\n" "$*" >> ' + shlex.quote(str(self.discovery_log)) + '\n' - '[ "$*" = ' + shlex.quote(arguments) - + ' ] || { echo "unsupported busctl arguments: $*" >&2; exit 98; }\n' - 'printf "%s\\n" ' + shlex.quote(response)) - - def encryption(self, encrypted, *, mixed=False, fail=False): - self.command('findmnt', "printf '%s\\n' " + shlex.quote(json.dumps({'filesystems': [ - {'source': '/dev/mapper/root[/root]', 'target': '/', 'fstype': 'btrfs'}]}))) - self.command('btrfs', 'exit 1' if fail else "cat <<'EOF'\n" - "Label: none uuid: 01234567-89ab-cdef-0123-456789abcdef\n" - "\tTotal devices 2 FS bytes used 4096\n" - "\tdevid 1 size 65536 used 4096 path /dev/mapper/first\n" - "\tdevid 2 size 65536 used 4096 path /dev/mapper/second\nEOF") - first = {'blockdevices': [{'type': 'crypt' if encrypted else 'part'}]} - second = {'blockdevices': [{'type': 'part' if mixed or not encrypted else 'crypt'}]} - self.command('lsblk', 'case "$*" in\n*first) printf "%s\\n" ' + shlex.quote(json.dumps(first)) - + ';;\n*) printf "%s\\n" ' + shlex.quote(json.dumps(second)) + ';;\nesac') - - def test_both_paths_select_graphical_boot_without_changing_the_running_session(self): - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/services.yml').read_text()) - selected = next(task for task in tasks if task.get('name') == 'Select graphical login for the next boot') - policy = selected['ansible.builtin.file'] - self.assertEqual(policy['path'], '/etc/systemd/system/default.target') - self.assertEqual(policy['src'], '/usr/lib/systemd/system/graphical.target') - self.assertIn('systemctl set-default graphical.target', (ROOT / 'image/live.ks').read_text()) - for path in ('roles/base/tasks/main.yml', 'roles/base/tasks/image.yml'): - self.assertIn('services.yml', (ROOT / path).read_text()) - # Run the actual shared Ansible module against only disposable paths. - units = self.root / 'units' - units.mkdir() - graphical, console = units / 'graphical.target', units / 'multi-user.target' - graphical.write_text('graphical fixture\n') - console.write_text('console fixture\n') - default = units / 'default.target' - default.symlink_to(console) - active = units / 'active.target' - active.symlink_to(console) - policy.update(path=str(default), src=str(graphical)) - playbook = self.root / 'graphical-boot.yml' - playbook.write_text(yaml.safe_dump([{'hosts': 'localhost', 'connection': 'local', - 'gather_facts': False, 'become': False, 'tasks': [selected]}])) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - text=True, capture_output=True, timeout=60) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertEqual(default.resolve(), graphical) - self.assertEqual(active.resolve(), console, 'the running session is not isolated or restarted') - - def checkout(self, *extra, interactive=False, terminal=False, answers='', warning=None): - source = self.installer - if interactive and not terminal: - # Replace terminal detection only; feed the actual questionnaire. - source = self.root / 'interactive-install' - source.write_text(self.installer.read_text().replace( - '[[ $non_interactive == false && ! -t 0 ]]', 'false')) - command = ['bash', str(source), '--check', - *([] if interactive else ['--non-interactive']), *extra] - if terminal: - # Exercise the terminal guard and real read prompts unchanged. - master_fd, slave_fd = pty.openpty() - try: - os.write(master_fd, answers.encode()) - result = subprocess.run(command, stdin=slave_fd, env=self.environment, - text=True, capture_output=True, timeout=20) - finally: - os.close(master_fd) - os.close(slave_fd) - else: - result = subprocess.run(command, env=self.environment, input=answers, - text=True, capture_output=True, timeout=20) - self.assertEqual(result.returncode, 0, result.stderr) - if terminal: - self.assertIn('Keyboard variant', result.stderr) - self.assertNotIn('Invalid value:', result.stderr) - elif warning is None: - self.assertEqual(result.stderr, '') - else: - self.assertIn(warning, result.stderr) - return yaml.safe_load(result.stdout[result.stdout.index('---\n'):]) - - def installed_iso(self, encrypted): - self.path.unlink(missing_ok=True) - self.generate() - (self.root / 'etc/passwd').write_text('alice:x:1001:1001::/home/alice:/usr/bin/fish\n') - (self.root / 'etc/shadow').write_text('root:!:1::::::\n') - target.finalize(self.root, {'account': {'username': 'alice', 'encrypted': encrypted}}, encrypted) - value = yaml.safe_load(self.path.read_text()) - self.path.unlink() - # Same account-scoped policy; the checkout fixture's existing home is - # under its disposable root instead of the host's real /home/alice. - value['primary_home'] = str(self.home) - return value - - def test_fresh_interactive_and_noninteractive_defaults_match_iso(self): - for encrypted in (False, True): - with self.subTest(encrypted=encrypted): - self.encryption(encrypted) - expected = self.installed_iso(encrypted) - self.assertEqual(self.checkout(), expected) - self.assertEqual(self.checkout(interactive=True, answers='\n' * 30), expected) - self.assertIs(expected['passwordless_wheel'], False) - self.assertIs(expected['manage_personal_dotfiles'], True) - self.assertIs(expected['desktop_autologin'], encrypted) - self.assertFalse((self.root / 'etc/sudoers.d/10-wheel-nopasswd').exists()) - self.assertFalse(self.path.exists()) - - def test_supported_keyboard_discovery_matches_iso_and_preserves_variant_positions(self): - self.encryption(False) - for layout, variant in (('nl', ''), ('us', ''), ('us', 'dvorak'), - ('nl,us', ',dvorak'), ('us,nl', 'intl,'), - ('us,nl,de', ',,nodeadkeys'), ('', ''), ('', 'dvorak')): - with self.subTest(layout=layout, variant=variant): - self.keyboard(layout, variant) - (self.root / 'etc/vconsole.conf').write_text( - f'XKBLAYOUT="{layout}"\nXKBVARIANT="{variant}"\n') - expected = self.installed_iso(False) - self.assertEqual(self.checkout(), expected) - self.assertEqual(self.checkout(interactive=True, answers='\n' * 30), expected) - self.assertEqual(expected['machine_keyboard_layout'], layout or 'us') - self.assertEqual(expected['machine_keyboard_variant'], variant if layout else '') - - def test_keyboard_discovery_failure_is_reported_and_keeps_first_install_fallback(self): - self.encryption(False) - failures = ( - 'echo "Failed to connect to bus" >&2; exit 1', - 'echo "busctl: command not found" >&2; exit 127', - # A partial response must not mix a discovered layout with a - # variant obtained from an unsuccessful query. - 'printf \'%s\\n\' \'{"type":"s","data":"nl"}\'; exit 1', - 'exit 0', - 'printf \'%s\\n\' \'not JSON\'', - 'printf \'%s\\n\' \'{"type":"s","data":"nl"}\'', - 'printf \'%s\\n\' \'{"type":"s","data":["nl"]}\' \'{"type":"s","data":""}\'', - 'printf \'%s\\n\' \'{"type":"as","data":["nl"]}\' \'{"type":"s","data":""}\'', - 'printf \'%s\\n\' \'{"type":"s","data":"nl"}\' \'{"type":"s","data":"bad variant"}\'', - ) - for failure in failures: - with self.subTest(failure=failure): - self.command('busctl', failure) - value = self.checkout(warning='could not discover the system X11 keyboard') - self.assertEqual((value['machine_keyboard_layout'], value['machine_keyboard_variant']), - ('us', '')) - self.assertIs(value['manage_system_identity'], False) - - def test_interactive_layout_change_resets_the_detected_variant(self): - self.encryption(False) - for detected_layout, detected_variant, layout, answer, variant in ( - ('us', 'dvorak', 'nl', '', ''), - ('us,nl', 'dvorak,', 'nl,us', '', ''), - ('us,nl', 'dvorak,', 'us', '', ''), - ('us', 'dvorak', 'nl,us', ',dvorak', ',dvorak')): - with self.subTest(detected_layout=detected_layout, layout=layout, answer=answer): - self.keyboard(detected_layout, detected_variant) - value = self.checkout(interactive=True, terminal=True, - answers='\n' * 5 + layout + '\n' + answer + '\n' * 30) - self.assertEqual((value['machine_keyboard_layout'], value['machine_keyboard_variant']), - (layout, variant)) - self.assertIs(value['manage_system_identity'], True) - detected = self.checkout() - self.assertEqual((detected['machine_keyboard_layout'], detected['machine_keyboard_variant']), - (detected_layout, detected_variant)) - - def test_interactive_variant_can_be_cleared_without_changing_the_layout(self): - self.encryption(False) - for layout, variant in (('us', 'dvorak'), ('us,nl', 'dvorak,')): - with self.subTest(layout=layout, variant=variant): - self.keyboard(layout, variant) - # Keep the username, host, timezone, locales and keyboard - # layout, then explicitly clear the variant with '-'. - value = self.checkout(interactive=True, terminal=True, - answers='\n' * 6 + '-\n' + '\n' * 30) - self.assertEqual((value['machine_keyboard_layout'], value['machine_keyboard_variant']), - (layout, '')) - self.assertIs(value['manage_system_identity'], True) - kept = self.checkout(interactive=True, terminal=True, answers='\n' * 30) - self.assertEqual((kept['machine_keyboard_layout'], kept['machine_keyboard_variant']), - (layout, variant)) - self.assertIs(kept['manage_system_identity'], False) - - def test_saved_keyboard_choices_are_reused_without_discovery(self): - self.encryption(False) - saved = self.checkout() - saved.update(machine_keyboard_layout='nl,us', machine_keyboard_variant=',dvorak') - original = config.render(saved) - self.path.write_text(original) - self.discovery_log.unlink(missing_ok=True) - self.command('busctl', 'touch ' + shlex.quote(str(self.discovery_log)) + '; exit 1') - self.assertEqual(self.checkout(), saved) - self.assertEqual(self.path.read_text(), original) - self.assertFalse(self.discovery_log.exists(), 'saved choices need no system discovery') - - def test_generic_checkout_selects_the_isos_shared_hardware_packages(self): - from test_installed_policy import repair - applications = repair.load_applications() - main = yaml.safe_load((ROOT / 'roles/xps-2026/tasks/main.yml').read_text()) - imports = [dict(task) for task in main if task.get('ansible.builtin.import_tasks') == 'packages.yml'] - self.assertEqual(len(imports), 1) - package_tasks = yaml.safe_load((ROOT / 'roles/xps-2026/tasks/packages.yml').read_text()) - # Execute the actual import/feature conditions through Ansible. Replace - # only DNF's side effect with recording the requested package names. - for task in package_tasks: - selected = task.pop('ansible.builtin.dnf')['name'] - task['ansible.builtin.set_fact'] = { - 'selected_hardware_packages': '{{ selected_hardware_packages | default([]) + ' - + json.dumps(selected if isinstance(selected, list) else [selected]) + ' }}' - } - fixture = self.root / 'hardware-packages.yml' - fixture.write_text(yaml.safe_dump(package_tasks)) - imports[0]['ansible.builtin.import_tasks'] = str(fixture) - packages, _ = applications.package_names() - for proprietary in (True, False): - output = self.root / 'selected-hardware-packages.json' - play = self.root / 'generic-hardware.yml' - play.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'gather_facts': False, - 'vars': {'xps_2026_is_supported': False, 'features': {'proprietary_apps': proprietary}}, - 'tasks': imports + [{'ansible.builtin.copy': { - 'dest': str(output), 'content': '{{ selected_hardware_packages | default([]) | to_json }}', - 'mode': '0600'}}], - }])) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', '-c', 'local', str(play)], - capture_output=True, text=True, timeout=45) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - selected = set(json.loads(output.read_text())) - expected = {name for task in yaml.safe_load((ROOT / 'roles/xps-2026/tasks/packages.yml').read_text()) - if proprietary or 'features.proprietary_apps | bool' not in task.get('when', []) - for name in ([task['ansible.builtin.dnf']['name']] - if isinstance(task['ansible.builtin.dnf']['name'], str) - else task['ansible.builtin.dnf']['name'])} - self.assertEqual(selected, expected) - self.assertLessEqual(selected, set(packages)) - self.assertIn('alsa-sof-firmware', selected) - - def test_explicit_first_account_choices_match_on_both_paths(self): - # An unattended seed's `config` (or a factory reset's kept choices) - # reaches the ISO through install-choices.json; the checkout installer - # receives the same explicit choices as a saved configuration. Both go - # through image/installation_policy.py and must produce one outcome. - choices = {'manage_personal_dotfiles': False, 'passwordless_local_polkit': True, - 'features': {'steam': False, 'connected_widgets': False, 'local_network_services': True}} - pending = self.root / 'etc/cybexos/install-choices.json' - for encrypted in (False, True): - with self.subTest(encrypted=encrypted): - self.encryption(encrypted) - pending.write_text(json.dumps({'version': 1, 'choices': choices})) - iso = self.installed_iso(encrypted) - self.assertFalse(pending.exists(), 'recording config.yml consumes the choices') - # Reconfiguring applies saved choices over the shared defaults. - self.path.write_text(yaml.safe_dump(choices)) - checkout = self.checkout('--reconfigure') - self.path.unlink() - self.assertEqual(checkout, iso) - self.assertIs(iso['manage_personal_dotfiles'], False) - self.assertIs(iso['passwordless_local_polkit'], True) - self.assertIs(iso['features']['steam'], False) - self.assertIs(iso['features']['local_network_services'], True) - self.assertIs(iso['desktop_autologin'], encrypted) - - def test_first_account_choices_are_validated_by_the_shared_policy(self): - from installation_policy import apply_choices, choices - self.encryption(False) - pending = self.root / 'etc/cybexos/install-choices.json' - for invalid in ({'desktop_autologin': True}, {'passwordless_wheel': True}, - {'features': {'unknown': True}}, {'docker_sudoless': 'yes'}): - with self.subTest(invalid=invalid): - pending.write_text(json.dumps({'version': 1, 'choices': invalid})) - self.path.unlink(missing_ok=True) - with self.assertRaises(ValueError): - self.generate() - with self.assertRaises(ValueError): - choices(invalid) - pending.unlink() - defaults = config.installation_defaults(Path(ROOT / 'inventory/group_vars/all.yml')) - # Sudoless Docker is root-equivalent and exists only with Docker selected. - applied = apply_choices(defaults, {'docker_sudoless': True, 'features': {'docker': False}}) - self.assertIs(applied['docker_sudoless'], False) - self.assertIs(defaults['features']['docker'], True, 'the defaults are not modified') - - def test_existing_personal_files_do_not_select_another_product_default(self): - self.encryption(True) - (self.home / '.gitconfig').write_text('[user]\nname = Personal\n') - value = self.checkout(interactive=True, answers='\n' * 30) - self.assertIs(value['manage_personal_dotfiles'], True) - self.assertEqual((self.home / '.gitconfig').read_text(), '[user]\nname = Personal\n') - - def test_mixed_or_unverifiable_btrfs_never_selects_autologin(self): - for options in ({'mixed': True}, {'fail': True}): - with self.subTest(options=options): - self.encryption(True, **options) - self.assertIs(self.checkout()['desktop_autologin'], False) - - def test_saved_choices_survive_reuse_and_reconfiguration_defaults(self): - self.encryption(True) - saved = self.checkout() - saved.update(manage_personal_dotfiles=False, desktop_autologin=False, passwordless_wheel=True) - saved['features'].update(steam=False, connected_widgets=False, podman=False, source_builds=False) - original = config.render(saved) - self.path.write_text(original) - self.assertEqual(self.checkout(), saved) - self.assertEqual(self.checkout('--reconfigure'), saved) - self.assertEqual(self.checkout('--reconfigure', interactive=True, answers='\n' * 30), saved) - self.assertEqual(self.path.read_text(), original) - - def test_source_personal_seeding_preserves_edits_like_iso_seeding(self): - from test_desktop_payload import INIT - selected = {'Install application configuration', 'Install Voxtype configuration', - 'Install Oh My Posh theme from the active configuration', - 'Install feature-aware MIME defaults', 'Configure npm user prefix'} - tasks = [task for task in yaml.safe_load((ROOT / 'roles/dotfiles/tasks/main.yml').read_text()) - if task.get('name') in selected] - self.assertEqual(len(tasks), len(selected)) - for task in tasks: - task['become'] = False - task.pop('notify', None) - options = task.get('ansible.builtin.copy') or task['ansible.builtin.template'] - if 'src' in options and not options['src'].startswith('{{ config_repo }}'): - directory = 'templates' if 'ansible.builtin.template' in task else 'files' - options['src'] = str(ROOT / 'roles/dotfiles' / directory) + '/' + options['src'] - paths = ('.config/fastfetch/config.jsonc', '.config/voxtype/config.toml', - '.config/oh-my-posh/EDM115-newline2.omp.json', '.config/mimeapps.list', '.npmrc') - for relative in paths: - (self.home / relative).parent.mkdir(parents=True, exist_ok=True) - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - playbook = self.root / 'personal-seed.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, - 'vars': {'primary_home': str(self.home), 'primary_user': 'fixture', - 'config_repo': str(ROOT), 'manage_personal_dotfiles': True, - 'features': inventory['features']}, 'tasks': tasks, - }])) - - def apply(): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=os.environ, text=True, capture_output=True, timeout=30) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - apply() - # Exercise the ISO's actual merge operation with the freshly rendered - # shared defaults, then edit both accounts before the next application. - seed = self.root / 'seed' - other = self.root / 'iso-account' - for relative in paths: - source = self.home / relative - (seed / relative).parent.mkdir(parents=True, exist_ok=True) - (seed / relative).write_bytes(source.read_bytes()) - INIT.seed_applications(seed, other) - for relative in paths: - self.assertEqual((self.home / relative).read_bytes(), (other / relative).read_bytes()) - for home in (self.home, other): - (home / relative).write_text('Personal configuration survives upgrades\n') - apply() - INIT.seed_applications(seed, other) - for relative in paths: - self.assertEqual((self.home / relative).read_text(), 'Personal configuration survives upgrades\n') - self.assertEqual((self.home / relative).read_bytes(), (other / relative).read_bytes()) - - def test_packaged_policy_is_the_same_source_used_by_checkout(self): - from installation_policy import defaults - import tempfile - from unittest.mock import patch - from test_installed_policy import repair - with tempfile.TemporaryDirectory() as directory: - payload = Path(directory) - repair.prepare(payload) - bundled = payload / 'usr/share/cybexos/lib/installation_policy.py' - self.assertEqual(bundled.read_bytes(), (ROOT / 'image/installation_policy.py').read_bytes()) - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - inventory['features']['new_unhandled_choice'] = True - with patch.object(Path, 'read_text', return_value=yaml.safe_dump(inventory)): - with self.assertRaisesRegex(ValueError, 'both installer schemas'): - defaults(Path('fixture')) - - def test_chatgpt_launcher_matches_checkout_and_iso_at_native_display_scale(self): - from desktop_payload import prepare_app_launchers - from test_desktop_payload import INIT - - launcher = '.local/share/applications/chatgpt.desktop' - (self.home / launcher).parent.mkdir(parents=True) - task = next(task for task in yaml.safe_load( - (ROOT / 'roles/dotfiles/tasks/main.yml').read_text()) - if task.get('name') == 'Install MIME defaults and desktop launchers') - task['become'] = False - task['loop'] = [item for item in task['loop'] if item['name'] == 'chatgpt.desktop'] - task['ansible.builtin.copy']['src'] = str(ROOT / 'roles/dotfiles/files') + '/{{ item.name }}' - playbook = self.root / 'chatgpt-launcher.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, - 'vars': {'primary_home': str(self.home), 'primary_user': 'fixture', - 'features': {'proprietary_apps': True}}, - 'tasks': [task], - }])) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=os.environ, text=True, capture_output=True, timeout=30) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - payload = self.root / 'payload' - prepare_app_launchers(ROOT, payload) - iso_home = self.root / 'iso-account' - INIT.seed_applications(payload / 'usr/share/cybexos/user-seed', iso_home) - self.assertEqual((self.home / launcher).read_bytes(), (iso_home / launcher).read_bytes()) - self.assertIn('Exec=chatgpt --ozone-platform=wayland %U', - (iso_home / launcher).read_text().splitlines()) - - def test_default_web_apps_are_seeded_alike_and_a_removed_one_stays_removed(self): - import shutil - from unittest.mock import patch - from test_desktop_payload import INIT - from test_installed_policy import repair - - # Both packaging paths ship every contract helper as the ISO's - # /usr/share/cybexos/bin; the checkout installs it as ~/.local/bin. - contract = json.loads((ROOT / 'assets/desktop-contract.json').read_text()) - self.assertIn('cybexos-webapp', contract['userHelpers']) - self.assertIn('helpers = contract["userHelpers"]', (ROOT / 'image/package').read_text()) - - # Checkout: the dotfiles role's own task, run as the account. - task = next(task for task in yaml.safe_load((ROOT / 'roles/dotfiles/tasks/main.yml').read_text()) - if task.get('name') == 'Seed the default web apps once') - task['become'] = False - task.pop('become_user') - helper = self.home / '.local/bin/cybexos-webapp' - helper.parent.mkdir(parents=True) - shutil.copy2(ROOT / 'assets/scripts/cybexos-webapp', helper) - playbook = self.root / 'webapps.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, - 'vars': {'primary_home': str(self.home), 'primary_user': 'fixture'}, 'tasks': [task], - }])) - environment = {key: value for key, value in os.environ.items() if not key.startswith('XDG_')} - - def checkout(): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=environment, text=True, capture_output=True, timeout=60) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - # ISO: the packaged helper, seeded by cybexos-user-init at login. - payload = self.root / 'payload' - repair.prepare(payload) - vendor = payload / 'usr/share/cybexos' - iso_home = self.root / 'home/iso' - iso_home.mkdir(parents=True) - - def iso(): - with patch.dict(os.environ, {'HOME': str(iso_home), 'PATH': os.environ['PATH']}, clear=True): - INIT.initialize(iso_home, vendor, mode='essential') - - def outcome(home): - files = {} - roots = [home / '.local/share/cybexos/webapps', home / '.local/share/applications'] - for path in sorted(path for root in roots for path in root.rglob('*')): - if path.is_file() and path.name != '.lock' and ( - path.parent.name != 'applications' or path.name.startswith('cybexos-webapp-')): - files[str(path.relative_to(home))] = path.read_bytes().replace( - str(home).encode(), b'HOME') - return files - - def remove(home): - subprocess.run([str(home / '.local/bin/cybexos-webapp'), 'remove', 'youtube'], - env={**environment, 'HOME': str(home)}, check=True, - capture_output=True, timeout=30) - - checkout() - iso() - seeded = outcome(self.home) - self.assertEqual(seeded, outcome(iso_home)) - for identifier in ('slack', 'whatsapp', 'youtube', 'google-photos', 'x'): - self.assertIn(f'.local/share/applications/cybexos-webapp-{identifier}.desktop', seeded) - self.assertIn(b'Exec=HOME/.local/bin/cybexos-webapp launch slack', - seeded['.local/share/applications/cybexos-webapp-slack.desktop']) - - # Removing a default is a choice both paths keep across updates. - for home in (self.home, iso_home): - remove(home) - checkout() - iso() - kept = outcome(self.home) - self.assertEqual(kept, outcome(iso_home)) - self.assertNotIn('.local/share/applications/cybexos-webapp-youtube.desktop', kept) - self.assertIn('.local/share/applications/cybexos-webapp-slack.desktop', kept) - - def test_a_failing_web_app_seed_never_blocks_account_initialization(self): - import contextlib - import io - from unittest.mock import patch - from test_desktop_payload import INIT - - vendor = self.root / 'vendor' - (vendor / 'bin').mkdir(parents=True) - (vendor / 'bin/cybexos-webapp').write_text('raise RuntimeError("broken helper")\n') - home = self.root / 'home/broken' - home.mkdir(parents=True) - errors = io.StringIO() - with patch.dict(os.environ, {'HOME': str(home), 'PATH': os.environ['PATH']}, clear=True), \ - contextlib.redirect_stderr(errors): - INIT.initialize(home, vendor, mode='essential') - self.assertIn('default web apps were not seeded: broken helper', errors.getvalue()) - self.assertTrue((home / '.local/bin/cybexos-webapp').is_symlink()) - - def test_firewall_zone_matches_checkout_and_iso_for_each_network_choice(self): - from xml.etree import ElementTree - import jinja2 - from desktop_payload import prepare_defaults - - def outcome(path): - zone = ElementTree.parse(path).getroot() - return (zone.get('target'), - sorted((port.get('port'), port.get('protocol')) for port in zone.iter('port')), - sorted(interface.get('name') for interface in zone.iter('interface'))) - - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - sources = {'checkout': ('roles/base/tasks/main.yml', 'Configure strict firewalld zone'), - 'iso': ('roles/base/tasks/image.yml', 'Apply the shared workstation firewall')} - plays, expected = [], {} - for enabled in (False, True): - features = {**inventory['features'], 'local_network_services': enabled} - tasks = [] - for path, name in sources.values(): - task = next(task for task in yaml.safe_load((ROOT / path).read_text()) - if task.get('name') == name) - for key in ('notify', 'register'): - task.pop(key, None) - template = task['ansible.builtin.template'] - template.pop('owner') - template.pop('group') - template['src'] = str(ROOT / 'roles/base/templates' / template['src']) - template['dest'] = str(self.root / f'{Path(path).stem}-{enabled}.xml') - tasks.append(task) - plays.append({'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, 'become': False, - 'vars': {'features': features, 'firewall_zone': inventory['firewall_zone'], - 'firewall_ports': inventory['firewall_ports']}, - 'tasks': tasks}) - # The ISO package renders the same template into its RPM payload. - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - payload = self.root / f'payload-{enabled}' - prepare_defaults(ROOT, payload, environment, {**inventory, 'features': features}) - expected[enabled] = outcome(payload / 'usr/lib/firewalld/zones/cybexos.xml') - playbook = self.root / 'firewall-zone.yml' - playbook.write_text(yaml.safe_dump(plays)) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=os.environ, text=True, capture_output=True, timeout=60) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - for enabled, packaged in expected.items(): - for path, _ in sources.values(): - self.assertEqual(outcome(self.root / f'{Path(path).stem}-{enabled}.xml'), packaged, (path, enabled)) - target, ports, _ = packaged - self.assertEqual(target, 'DROP') - # Printer and scanner discovery needs mDNS replies on every install. - self.assertIn(('5353', 'udp'), ports) - self.assertIn(('53317', 'tcp'), ports) - self.assertEqual(('27015', 'tcp') in ports, enabled) - - def test_hotspot_profiles_keep_networkmanager_shared_zone_on_both_paths(self): - """The Settings hotspot serves DHCP and DNS from NetworkManager's - nm-shared zone. Both paths ship the same helper that creates it there; - the checkout's zone adoption must skip it, and the ISO never rebinds - profiles, so its clients get addresses on either installation.""" - import importlib.util - import jinja2 - - spec = importlib.util.spec_from_file_location( - 'parity_network_tool', ROOT / 'roles/desktop/files/quickshell/scripts/network-tool.py') - network_tool = importlib.util.module_from_spec(spec) - spec.loader.exec_module(network_tool) - settings = dict(zip(network_tool.HOTSPOT_SETTINGS[::2], network_tool.HOTSPOT_SETTINGS[1::2])) - self.assertEqual((settings['ipv4.method'], settings['connection.zone'], settings['connection.autoconnect']), - ('shared', 'nm-shared', 'no')) - - uuids = {'wired': '0b1c2d3e-0000-4000-8000-000000000001', 'wifi': '0b1c2d3e-0000-4000-8000-000000000002', - 'hotspot': network_tool.HOTSPOT_UUID, 'vpn': '0b1c2d3e-0000-4000-8000-000000000004'} - self.command('nmcli', '\n'.join([ - 'case "$*" in', - f" '-t -f UUID,TYPE connection show') printf '%s\\n' {uuids['wired']}:802-3-ethernet " - f"{uuids['wifi']}:802-11-wireless {uuids['hotspot']}:802-11-wireless {uuids['vpn']}:vpn ;;", - f" '-g ipv4.method connection show {uuids['hotspot']}') echo shared ;;", - " '-g ipv4.method connection show '*) echo auto ;;", - f" '-g connection.zone connection show {uuids['wired']}') echo public ;;", - f" '-g connection.zone connection show {uuids['wifi']}') echo cybexos ;;", - f" '-g connection.zone connection show {uuids['hotspot']}') echo nm-shared ;;", - 'esac'])) - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/main.yml').read_text()) - task = next(task for task in tasks if task.get('name') == 'Find Ethernet and Wi-Fi profiles outside the strict zone') - environment = jinja2.Environment() - environment.filters['quote'] = shlex.quote - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - script = environment.from_string(task['ansible.builtin.shell']).render(firewall_zone=inventory['firewall_zone']) - result = subprocess.run(['bash', '-c', script], env=self.environment, text=True, capture_output=True, timeout=20) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.split(), [uuids['wired']], - 'only the physical profile outside the strict zone is adopted; the hotspot keeps nm-shared') - - iso_sources = [path for path in (ROOT / 'image').rglob('*') - if path.is_file() and '__pycache__' not in path.parts - and not path.name.startswith('test_') and path.suffix != '.md'] - iso_sources += [ROOT / 'roles/base/tasks/image.yml', ROOT / 'roles/base/tasks/reconcile.yml'] - rebinding = [str(path.relative_to(ROOT)) for path in iso_sources - if 'connection.zone' in path.read_text(errors='replace')] - self.assertEqual(rebinding, [], 'the ISO path leaves NetworkManager profile zones to NetworkManager') - - def test_sign_in_policy_matches_checkout_and_iso_from_their_starting_points(self): - """Same authselect features and PAM files after either installation path.""" - import importlib.machinery - import importlib.util - loader = importlib.machinery.SourceFileLoader( - 'cybexos_auth_policy', str(ROOT / 'roles/base/files/cybexos-auth-policy')) - spec = importlib.util.spec_from_loader(loader.name, loader) - policy = importlib.util.module_from_spec(spec) - loader.exec_module(policy) - - class Authselect: - def __init__(self, features): - self.features = list(features) - - def __call__(self, argv): - if argv[0] == 'restorecon': - return 0, '', '' - if argv[1] == 'current': - return 0, 'sssd ' + ' '.join(self.features), '' - if argv[1] == 'list-features': - return 0, 'with-fingerprint\nwith-mdns4\nwith-pam-u2f\n', '' - if argv[1] == 'enable-feature': - self.features += [] if argv[2] in self.features else [argv[2]] - return 0, '', '' - raise AssertionError(argv) - - sudo = ('#%PAM-1.0\nauth include system-auth\naccount include system-auth\n' - 'password include system-auth\nsession include system-auth\n') - kickstart = next(line for line in (ROOT / 'image/live.ks').read_text().splitlines() - if line.startswith('authselect select ')) - starts = { - # A Fedora workstation before CybexOS, after the base role's mDNS step. - 'checkout': ['with-silent-lastlog', 'with-mdns4'], - # The ISO's kickstart selection, which installations inherit. - 'iso': [word for word in kickstart.split()[3:] if word.startswith('with')], - } - outcomes = {} - for name, features in starts.items(): - root = self.root / ('sign-in-' + name) - (root / 'etc/pam.d').mkdir(parents=True) - (root / 'etc/pam.d/sudo').write_text(sudo) - (root / 'usr/lib/pam.d').mkdir(parents=True) - (root / 'usr/lib/pam.d/polkit-1').write_text(policy.POLKIT_VENDOR_DEFAULT) - (root / policy.FPRINTD_MODULE).parent.mkdir(parents=True) - (root / policy.FPRINTD_MODULE).write_text('fixture') - authselect = Authselect(features) - policy.Policy(root, authselect).apply() - outcomes[name] = (sorted(authselect.features), - {path.name: path.read_text() for path in sorted((root / 'etc/pam.d').iterdir())}) - self.assertEqual(outcomes['checkout'], outcomes['iso']) - features, files = outcomes['iso'] - self.assertIn('with-fingerprint', features) - self.assertFalse(any(feature.startswith('with-pam-u2f') for feature in features), - 'a profile-wide u2f feature would let a key replace the SDDM password') - self.assertEqual(sorted(files), ['cybexos-lock', 'polkit-1', 'sudo']) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installed_outcomes.py b/image/test_installed_outcomes.py deleted file mode 100644 index 0b2583fd..00000000 --- a/image/test_installed_outcomes.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Fixtures for the real VM gate; these deliberately do not claim VM execution.""" -import importlib.machinery -import importlib.util -import io -import json -import re -from pathlib import Path -import subprocess -import tarfile -import tempfile -import unittest -from unittest.mock import Mock, patch - -import installed_outcomes as outcomes -import parity_qualification as parity -import source_snapshot as source - -loader = importlib.machinery.SourceFileLoader('checkout_qualification', str(Path(__file__).with_name('qualify-checkout'))) -spec = importlib.util.spec_from_loader(loader.name, loader) -checkout_runner = importlib.util.module_from_spec(spec) -loader.exec_module(checkout_runner) - - -def fixture(installation='iso'): - result = {key: {'fixture': True} for key in outcomes.CAPTURE_FIELDS} - result.update(format=1, installation=installation, scenario='plain-us', profile='fresh', - source_revision='a' * 40, source_content_sha256='b' * 64, hardware={'product_name': 'QEMU'}, - graphical_session=True, sole_managed_shell=True, installed_packages=['quickshell.x86_64=1'], - required_packages={'quickshell': ['quickshell.x86_64=1']}, flatpaks=[], selinux='Enforcing', boot_target='graphical.target') - return result - - -class FlatpakColumns(unittest.TestCase): - def test_capture_asks_only_for_columns_flatpak_118_accepts(self): - """Fedora 44's flatpak 1.18 exits "Unknown column: commit"; the - deployed commit is the `active` column (seen in a 2026-10-14 ISO run).""" - accepted = {'name', 'description', 'application', 'version', 'branch', 'arch', 'runtime', - 'origin', 'installation', 'ref', 'active', 'latest', 'size', 'options', 'all'} - source = Path(outcomes.__file__).read_text() - requested = re.findall(r"'--columns=([a-z,]+)'", source) - self.assertTrue(requested) - for columns in requested: - self.assertLessEqual(set(columns.split(',')), accepted, columns) - - -class OutcomeComparison(unittest.TestCase): - def test_session_target_links_and_user_enablement_have_equivalent_activation(self): - dependencies = {'quickshell.service', 'hypridle.service'} - self.assertEqual(outcomes.effective_unit_state('quickshell.service', 'disabled', 'active', dependencies), - outcomes.effective_unit_state('quickshell.service', 'enabled', 'active', dependencies)) - active = outcomes.effective_unit_state('quickshell.service', 'enabled', 'active', dependencies) - self.assertNotEqual(active, outcomes.effective_unit_state('quickshell.service', 'enabled', 'inactive', dependencies)) - self.assertNotEqual(active, outcomes.effective_unit_state('quickshell.service', 'disabled', 'active', set())) - for invalid in ('masked', 'masked-runtime', 'not-found', 'bad'): - self.assertNotEqual(active, outcomes.effective_unit_state('quickshell.service', invalid, 'active', dependencies)) - self.assertNotEqual(outcomes.effective_unit_state('voxtype.service', 'enabled', 'active', dependencies), - outcomes.effective_unit_state('voxtype.service', 'disabled', 'active', dependencies)) - - def test_only_utc_alias_is_normalized_and_saved_choices_are_not_modified(self): - selected = {'machine_timezone': 'UTC', 'passwordless_wheel': False, 'features': {'steam': False}} - original = dict(selected) - self.assertEqual(outcomes.policy_choices(selected), outcomes.policy_choices({**selected, 'machine_timezone': 'Etc/UTC'})) - self.assertNotEqual(outcomes.policy_choices(selected), outcomes.policy_choices({**selected, 'machine_timezone': 'Europe/Amsterdam'})) - self.assertEqual(selected, original) - self.assertIs(outcomes.policy_choices(selected)['passwordless_wheel'], False) - self.assertEqual(outcomes.policy_choices(selected)['features'], {'steam': False}) - - def test_equal_real_capture_shapes_pass_without_implicit_exclusions(self): - result = outcomes.compare(fixture(), fixture('checkout'), {}) - self.assertEqual(result['status'], 'passed') - self.assertEqual(result['differences'], {}) - - def test_policy_or_effective_behavior_difference_blocks_release(self): - for key in ('settings', 'input', 'choices', 'authentication', 'firewall', 'system_units', - 'user_units', 'recovery', 'default_apps', 'required_packages', 'boot_target'): - changed = fixture('checkout') - if isinstance(changed[key], dict): - changed[key]['difference'] = False - else: - changed[key] = 'rescue.target' - with self.subTest(key=key): - result = outcomes.compare(fixture(), changed, {}) - self.assertEqual(result['status'], 'failed') - self.assertIn(key, result['differences']) - - def test_different_source_hardware_or_profile_cannot_be_compared(self): - for key, value in (('source_content_sha256', 'c' * 64), ('source_revision', 'd' * 40), - ('hardware', {'product_name': 'different'}), ('profile', 'saved'), ('scenario', 'plain-nl')): - changed = fixture('checkout') - changed[key] = value - with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'mismatched ' + key): - outcomes.compare(fixture(), changed, {}) - - def test_sparse_fixture_or_no_live_session_is_not_installed_evidence(self): - for key in outcomes.CAPTURE_FIELDS: - changed = fixture('checkout') - del changed[key] - with self.subTest(key=key), self.assertRaises(ValueError): - outcomes.compare(fixture(), changed, {}) - changed = fixture('checkout') - changed['graphical_session'] = False - with self.assertRaisesRegex(ValueError, 'graphical session'): - outcomes.compare(fixture(), changed, {}) - - def test_extra_packages_require_explicit_reason_without_masking_required_versions(self): - changed = fixture('checkout') - changed['installed_packages'].append('cloud-init.noarch=1') - result = outcomes.compare(fixture(), changed, {}) - self.assertEqual(result['status'], 'failed') - self.assertEqual(result['package_delta']['checkout']['unreviewed'], ['cloud-init.noarch=1']) - self.assertNotIn('package-delta-reviewed', result['checks']) - exceptions = {'checkout': {'cloud-init.noarch': 'Pinned cloud test bootstrap dependency'}} - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'passed') - changed['required_packages']['cloud-init'] = ['cloud-init.noarch=1'] - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'failed') - with self.assertRaisesRegex(ValueError, 'review reason'): - outcomes.compare(fixture(), changed, {'checkout': {'cloud-init.noarch': ''}}) - - def test_normalization_only_unifies_ownership_paths(self): - value = {'wallDir': '/home/qualification/Pictures/Wallpapers', 'runtime': '/usr/share/cybexos/runtime/quickshell', - 'choices': {'passwordless': False}, 'layout': ['nl', 'us']} - normalized = outcomes.normalize(value, '/home/qualification') - self.assertEqual(normalized['wallDir'], '$HOME/Pictures/Wallpapers') - self.assertEqual(normalized['runtime'], '$RUNTIME/quickshell') - self.assertEqual(normalized['choices'], {'passwordless': False}) - self.assertEqual(normalized['layout'], ['nl', 'us']) - - def test_signing_key_exception_is_exact_and_never_masks_required_versions(self): - identity = 'gpg-pubkey.(none)=' + 'a' * 40 + '-12345678' - changed = fixture('checkout') - changed['installed_packages'].append(identity) - exceptions = {'checkout': {identity: 'Exact reviewed vendor key imported by its signed package scriptlet'}} - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'passed') - for unknown in (identity.replace('a' * 40, 'b' * 40), identity.replace('12345678', '87654321')): - with self.subTest(identity=unknown): - changed['installed_packages'][-1] = unknown - result = outcomes.compare(fixture(), changed, exceptions) - self.assertEqual(result['status'], 'failed') - self.assertEqual(result['package_delta']['checkout']['unreviewed'], [unknown]) - changed['installed_packages'][-1] = identity - changed['required_packages']['signing-key'] = [identity] - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'failed') - - def test_signing_key_family_exception_is_rejected(self): - with self.assertRaisesRegex(ValueError, 'exact fingerprint and RPM release'): - outcomes.compare(fixture(), fixture('checkout'), { - 'checkout': {'gpg-pubkey.(none)': 'A family-wide exception would hide unreviewed signing keys'}}) - - def test_reviewed_brave_scriptlet_keys_are_fingerprint_scoped(self): - policy = json.loads(Path(outcomes.__file__).with_name('parity-exceptions.json').read_text()) - keys = {name: reason for name, reason in policy['checkout'].items() if name.startswith('gpg-pubkey.')} - self.assertEqual(len(keys), 3) - for identity, reason in keys.items(): - self.assertRegex(identity, r'^gpg-pubkey\.\(none\)=[0-9a-f]{40}-[0-9a-f]{8}$') - self.assertIn('brave-keyring 1.19-1', reason) - self.assertIn('pinned stable repository', reason) - - -class SourceIdentity(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory(prefix='cybexos-source-test-') - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) / 'source' - self.root.mkdir() - subprocess.run(['git', 'init', '-q', str(self.root)], check=True) - for name in ('install', 'site.yml', 'inventory/group_vars/all.yml'): - path = self.root / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('fixture source\n') - self.provenance = Path(self.temp.name) / 'build.json' - self.provenance.write_text('{}') - self.archive = Path(self.temp.name) / 'source.tar.gz' - - def test_archive_has_identical_content_identity_and_changes_are_detected(self): - initial = source.tree_identity(self.root) - source.write_archive(self.root, self.archive, {'image/build-provenance.json': self.provenance}) - self.assertEqual(source.archive_identity(self.archive), initial) - (self.root / 'install').write_text('changed\n') - self.assertNotEqual(source.tree_identity(self.root), initial) - (self.root / 'install').write_text('fixture source\n') - (self.root / 'install').chmod(0o755) - self.assertNotEqual(source.tree_identity(self.root), initial) - - def test_ignored_files_and_generated_provenance_do_not_shape_content_identity(self): - (self.root / '.gitignore').write_text('local-secret\n') - first = source.tree_identity(self.root) - (self.root / 'local-secret').write_text('never archive') - (self.root / 'image').mkdir() - (self.root / 'image/build-provenance.json').write_text('generated') - self.assertEqual(source.tree_identity(self.root), first) - - def test_archive_rejects_traversal_links_duplicate_and_missing_installer(self): - for names in (['../escape'], ['/absolute'], ['same', 'same'], ['ordinary']): - with self.subTest(names=names): - with tarfile.open(self.archive, 'w:gz') as archive: - for name in names: - member = tarfile.TarInfo(name) - member.size = 1 - archive.addfile(member, io.BytesIO(b'x')) - with self.assertRaises(ValueError): - source.archive_identity(self.archive) - with tarfile.open(self.archive, 'w:gz') as archive: - link = tarfile.TarInfo('link') - link.type, link.linkname = tarfile.SYMTYPE, '/outside' - archive.addfile(link) - with self.assertRaises(ValueError): - source.archive_identity(self.archive) - - -class GuestWorkflow(unittest.TestCase): - def test_cloud_os_preparation_reboots_before_pruning_and_is_outside_public_installer(self): - vm = Mock(owned=True, network_restricted=False) - steps = [] - def execute(_vm, script, _password, **_kwargs): - steps.append(script) - return Mock(stdout='DNF output\n{"running_kernel":"current"}\n') - with patch.object(checkout_runner, 'root_script', side_effect=execute), \ - patch.object(checkout_runner, 'poweroff_installed', side_effect=lambda *_: steps.append('poweroff')): - result = checkout_runner.prepare_cloud_baseline(vm, 'fixture') - self.assertEqual(result['running_kernel'], 'current') - self.assertEqual(steps, [checkout_runner.PREPARE_CLOUD_BASELINE, 'poweroff', checkout_runner.FINALIZE_CLOUD_BASELINE]) - vm.start.assert_called_once_with(user='qualification') - vm.wait_ssh.assert_called_once() - self.assertIsNone(vm.seed) - self.assertIs(vm.expect_shell, False) - self.assertIn('--oldinstallonly --limit=1', checkout_runner.FINALIZE_CLOUD_BASELINE) - self.assertIn('fedora-release-cloud fedora-release', checkout_runner.PREPARE_CLOUD_BASELINE) - self.assertLess(checkout_runner.PREPARE_CLOUD_BASELINE.index('fedora-release-identity-cloud fedora-release-identity-basic'), - checkout_runner.PREPARE_CLOUD_BASELINE.index('fedora-release-cloud fedora-release')) - self.assertLess(checkout_runner.PREPARE_CLOUD_BASELINE.index('fedora-release-cloud fedora-release'), - checkout_runner.PREPARE_CLOUD_BASELINE.index('dnf upgrade -y --refresh --best')) - # Prepare only preexisting Cloud packages; never hide dependencies - # introduced by the public installer's intermediate codec choices. - for name in ('fdk-aac-free', 'libavc1394', 'libklvanc', 'librabbitmq', 'xevd-libs'): - self.assertNotIn(name, checkout_runner.PREPARE_CLOUD_BASELINE) - self.assertNotIn('dnf upgrade', checkout_runner.install_script('plain-us')) - self.assertNotIn('oldinstallonly', checkout_runner.install_script('plain-us')) - for script in (checkout_runner.PREPARE_CLOUD_BASELINE, checkout_runner.FINALIZE_CLOUD_BASELINE): - subprocess.run(['bash', '-n'], input=script, text=True, check=True) - for owned, restricted in ((False, False), (True, True)): - with self.assertRaises(ValueError): - checkout_runner.prepare_cloud_baseline(Mock(owned=owned, network_restricted=restricted), 'fixture') - - def test_public_installer_uses_all_defaults_and_no_fixture_feature_optouts(self): - script = checkout_runner.install_script('plain-nl') - self.assertIn('./install --non-interactive', script) - self.assertNotIn('convergence-vars', script) - self.assertNotIn('--tags', script) - self.assertIn('LANG=nl_NL.UTF-8', script) - self.assertIn('rm -f /etc/sudoers.d/90-cloud-init-users', script) - self.assertIn('export SUDO_USER=qualification', script) - self.assertIn('systemctl get-default', script) - self.assertNotIn('systemctl set-default', script) - self.assertNotIn('systemctl enable sddm', script) - self.assertNotIn('set-x11-keymap', checkout_runner.install_script('plain-nl', repeat=True)) - with self.assertRaises(ValueError): - checkout_runner.install_script('encrypted-us') - - def test_desktop_lifecycle_streams_the_real_managed_service_test(self): - with patch.object(parity.subprocess, 'run') as run: - parity.desktop_lifecycle(Mock(ssh=['ssh', 'fixture'])) - script = run.call_args.kwargs['input'] - self.assertIn('qs_live_begin', script) - self.assertIn('qs_live_end', script) - self.assertIn('sound network accounts keyboard touchpad region', script) - self.assertIn("trap cleanup EXIT", script) - subprocess.run(['bash', '-n'], input=script, text=True, check=True) - - def test_capture_source_is_valid_python_and_requires_explicit_cli(self): - source_text = Path(outcomes.__file__).read_text() - compile(source_text, '', 'exec') - with patch.object(outcomes.os, 'geteuid', return_value=1000), self.assertRaisesRegex(RuntimeError, 'disposable qualification'): - outcomes.capture('qualification', 'iso', 'plain-us', 'fresh', '/never', '/never') - compile(parity.SAVED_CHOICES, '', 'exec') - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installed_policy.py b/image/test_installed_policy.py deleted file mode 100644 index b5c4456c..00000000 --- a/image/test_installed_policy.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Regressions for the installed-image policy gap found on the physical laptop.""" -import configparser -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -import jinja2 -import yaml - -ROOT = Path(__file__).resolve().parents[1] - - -def load(name, path): - loader = importlib.machinery.SourceFileLoader(name, str(ROOT / path)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -policy = load('repository_policy', 'roles/apps/files/cybexos-repository-policy') -repair = load('repair_installed', 'image/repair-installed') -configure = load('configure_installed', 'image/rootfs/usr/libexec/cybexos-configure-installed') - - -class InstalledPolicy(unittest.TestCase): - def test_automatic_hardware_checks_retry_after_a_kernel_change(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (provision / 'policy').write_text('fixture') - (root / 'run/lock').mkdir(parents=True) - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) - kernel = types.SimpleNamespace(release='kernel-one') - boot = ['boot-one'] - def applied(*_args): - result = root / 'var/lib/cybexos/hardware-result.json' - result.parent.mkdir(parents=True, exist_ok=True) - result.write_text('{"reboot_required": false, "camera_ready": true}') - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), patch.object(configure.os, 'uname', return_value=kernel), \ - patch.object(configure, 'boot_id', side_effect=lambda: boot[0]), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), patch.object(configure, 'drain'), \ - patch.object(configure, 'configure', side_effect=applied) as apply, \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - configure.main() - configure.main() - self.assertEqual(apply.call_count, 1) - marker = root / 'var/lib/cybexos/hardware-configured' - marker.rename(marker.with_name('hardware-unsupported')) - with self.assertRaises(SystemExit): - configure.main() - self.assertEqual(apply.call_count, 1) - kernel.release = 'kernel-two' - configure.main() - self.assertEqual(apply.call_count, 2) - self.assertFalse(marker.with_name('hardware-unsupported').exists()) - - def test_pending_camera_resumes_once_after_same_kernel_reboot(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (provision / 'policy').write_text('fixture') - (root / 'run/lock').mkdir(parents=True) - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) - boot = ['boot-one'] - outcomes = [True, False] - def applied(*_args): - result = root / 'var/lib/cybexos/hardware-result.json' - result.parent.mkdir(parents=True, exist_ok=True) - result.write_text(json.dumps({'reboot_required': outcomes.pop(0), 'camera_ready': not outcomes})) - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), \ - patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='same-kernel')), \ - patch.object(configure, 'boot_id', side_effect=lambda: boot[0]), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), \ - patch.object(configure, 'drain'), \ - patch.object(configure, 'configure', side_effect=applied) as apply, \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - configure.main() - status = json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text()) - self.assertEqual(status['state'], 'pending') - self.assertFalse((root / 'var/lib/cybexos/hardware-configured').exists()) - configure.main() - self.assertEqual(apply.call_count, 1) - boot[0] = 'boot-two' - configure.main() - self.assertEqual(apply.call_count, 2) - configure.main() - self.assertEqual(apply.call_count, 2) - self.assertEqual(json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text())['state'], 'completed') - - def test_missing_hardware_result_is_failure_not_completion(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (root / 'run/lock').mkdir(parents=True) - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), \ - patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='kernel')), \ - patch.object(configure, 'boot_id', return_value='boot'), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), \ - patch.object(configure, 'configure'), \ - patch.object(configure, 'drain'), \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - with self.assertRaisesRegex(SystemExit, 'did not report an outcome'): - configure.main() - self.assertEqual(json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text())['state'], 'failed') - self.assertFalse((root / 'var/lib/cybexos/hardware-configured').exists()) - - def test_legacy_completion_marker_is_validated_once_and_config_changes_invalidate_it(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (root / 'run/lock').mkdir(parents=True) - saved = root / 'etc/cybexos/config.yml' - saved.parent.mkdir(parents=True) - saved.write_text('xps_2026_camera_enabled: true\n') - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/', '/etc/')) else Path(value) - def applied(*_args): - result = root / 'var/lib/cybexos/hardware-result.json' - result.parent.mkdir(parents=True, exist_ok=True) - result.write_text('{"reboot_required":false,"camera_ready":true}') - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), \ - patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='same-kernel')), \ - patch.object(configure, 'boot_id', return_value='same-boot'), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), \ - patch.object(configure, 'drain'), \ - patch.object(configure, 'configure', side_effect=applied) as apply, \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - configure.main() - (root / 'var/lib/cybexos/hardware-status.json').unlink() - configure.main() - self.assertEqual(apply.call_count, 2, 'Legacy marker alone cannot prove completion') - configure.main() - self.assertEqual(apply.call_count, 2) - saved.write_text('xps_2026_camera_enabled: false\n') - configure.main() - self.assertEqual(apply.call_count, 3, 'Saved hardware choices invalidate prior completion') - - def test_camera_backup_and_restore_preserve_a_dangling_vendor_symlink(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / 'etc/v4l2-relayd.d/icamerasrc.conf' - backup = root / 'var/lib/cybexos/backups/system/etc/v4l2-relayd.d/icamerasrc.conf' - source.parent.mkdir(parents=True) - backup.parent.mkdir(parents=True) - source.symlink_to('/run/cybexos-fixture-missing-relay') - for filename, task_name in ( - ('camera.yml', "Preserve Fedora's generic camera relay configuration"), - ('camera-disabled.yml', "Restore Fedora's generic camera relay configuration"), - ): - tasks = yaml.safe_load((ROOT / 'roles/xps-2026/tasks' / filename).read_text()) - command = next(task['ansible.builtin.command']['argv'] for task in tasks if task['name'] == task_name) - command = [str(root / arg.lstrip('/')) if arg.startswith(('/etc/', '/var/')) else arg for arg in command] - subprocess.run(command, check=True) - self.assertEqual(os.readlink(backup), '/run/cybexos-fixture-missing-relay') - self.assertEqual(os.readlink(source), os.readlink(backup)) - if filename == 'camera.yml': - source.unlink() - - def test_session_target_can_start_services_ordered_after_graphical_session(self): - with tempfile.TemporaryDirectory() as temporary: - units = Path(temporary) - target = ROOT / 'image/rootfs/usr/lib/systemd/user/hyprland-session.target' - (units / target.name).write_text(target.read_text()) - for unit in ('graphical-session', 'graphical-session-pre'): - (units / (unit + '.target')).write_text('[Unit]\nDescription=Fixture\n') - for name in ('quickshell', 'hypridle', 'voxtype', - 'hermes-menubar-bridge', 'cybexos-welcome', 'cybexos-app-seed'): - (units / (name + '.service')).write_text( - '[Unit]\nAfter=graphical-session.target\n[Service]\nExecStart=/usr/bin/true\n') - runtime = units / 'runtime' - runtime.mkdir(mode=0o700) - environment = {**os.environ, 'SYSTEMD_UNIT_PATH': str(units) + ':/usr/lib/systemd/user', - 'XDG_RUNTIME_DIR': str(runtime)} - def verify(): - return subprocess.run(['systemd-analyze', '--user', 'verify', str(units / target.name)], - env=environment, text=True, capture_output=True) - result = verify() - self.assertEqual(result.returncode, 0, result.stderr) - self.assertNotIn('ordering cycle', result.stderr) - # The same vendor ordering reproduced the pre-fix failure. - (units / target.name).write_text(target.read_text().replace('DefaultDependencies=no', 'DefaultDependencies=yes')) - self.assertIn('ordering cycle', verify().stderr) - - def test_repository_reconciliation_preserves_unknown_entries_and_is_idempotent(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - legacy = root / 'etc/yum.repos.d/cybex-applications.repo' - legacy.parent.mkdir(parents=True) - original = '[openai-chatgpt]\nbaseurl=https://old.example\n[personal]\nbaseurl=https://personal.example\n' - legacy.write_text(original) - policies = ROOT / 'roles/apps/files' - self.assertTrue(policy.configure(root, policies)) - self.assertFalse(policy.configure(root, policies)) - self.assertNotIn('[openai-chatgpt]', legacy.read_text()) - self.assertIn('[personal]', legacy.read_text()) - self.assertEqual((root / 'var/lib/cybexos/backups/repositories/cybex-applications.repo').read_text(), original) - config = configparser.ConfigParser(interpolation=None) - config.read(root / 'etc/dnf/repos.override.d/90-cybexos-vendors.repo') - self.assertTrue(config['openai-chatgpt'].getboolean('repo_gpgcheck')) - self.assertTrue(config['tailscale-stable'].getboolean('repo_gpgcheck')) - self.assertTrue(all(section.getboolean('gpgcheck') for section in config.values() if section.name != 'DEFAULT')) - (root / 'etc/yum.repos.d/chatgpt.repo').write_text('[openai-chatgpt]\ngpgcheck=0\n') - self.assertTrue(policy.configure(root, policies)) - self.assertFalse(policy.configure(root, policies)) - - def test_unsigned_policy_is_rejected_before_writing(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'chatgpt.repo').write_text('[openai-chatgpt]\ngpgcheck=0\n') - with self.assertRaises(ValueError): - policy.configure(root / 'target', root, ['chatgpt']) - self.assertFalse((root / 'target').exists()) - - def test_image_includes_every_shared_baseline_package(self): - packages, _ = repair.load_applications().package_names() - baseline = yaml.safe_load((ROOT / 'roles/base/defaults/main.yml').read_text()) - self.assertTrue(set(baseline['base_required_packages']).issubset(packages)) - self.assertTrue({'nano', 'fuzzel', 'tuned-ppd', 'alsa-ucm', 'alsa-utils', 'alsa-sof-firmware', - 'cirrus-audio-firmware', 'intel-vsc-firmware'}.issubset(packages)) - # Weak dependencies are off in the image, so firmware, microcode and - # Fedora Workstation defaults must be named explicitly. - self.assertTrue({'realtek-firmware', 'amd-ucode-firmware', 'microcode_ctl', 'switcheroo-control', - 'xdg-user-dirs', 'gvfs-mtp', 'NetworkManager-openvpn-gnome', 'hplip', - 'gstreamer1-plugin-openh264', 'ibus', 'bind-utils'}.issubset(packages)) - repositories = configparser.ConfigParser(interpolation=None) - repositories.read(ROOT / 'image/build.repo') - self.assertTrue(repositories['fedora-cisco-openh264'].getboolean('gpgcheck')) - - def test_installed_images_share_the_workstation_account_and_service_policy(self): - tasks_dir = ROOT / 'roles/base/tasks' - - def flatten(name): - for task in yaml.safe_load((tasks_dir / name).read_text()): - if 'ansible.builtin.import_tasks' in task: - yield from flatten(task['ansible.builtin.import_tasks']) - else: - yield task - - # The workstation and installed images apply the same task files. - for entry in ('main.yml', 'image.yml'): - imported = {task['ansible.builtin.import_tasks'] for task in yaml.safe_load((tasks_dir / entry).read_text()) - if 'ansible.builtin.import_tasks' in task} - self.assertTrue({'accounts.yml', 'mdns.yml', 'docker-activation.yml', - 'authentication.yml'}.issubset(imported), entry) - # Upgrades of older ISO installations reconcile the sign-in policy too. - reconcile = {task.get('ansible.builtin.import_tasks') for task in - yaml.safe_load((tasks_dir / 'reconcile.yml').read_text())} - self.assertIn('authentication.yml', reconcile) - image = {task['name']: task for task in flatten('image.yml')} - workstation = {task['name']: task for task in flatten('main.yml')} - for name in ('Enable user lingering', 'Enable user lingering in the offline installation target', - 'Create the offline user lingering directory', - 'Configure active local wheel Polkit authorization', - 'Revoke passwordless local Polkit authorization when disabled', - 'Enable Docker socket activation when requested', 'Start Docker on demand rather than at boot', - 'Enable multicast DNS host resolution', - 'Offer fingerprints and security keys beside the password', - 'Report a sign-in policy CybexOS could not apply'): - self.assertIn(name, image) - self.assertEqual(image[name], workstation[name]) - # Anaconda's target has no logind: write the marker loginctl would. - online = image['Enable user lingering'] - self.assertIn('loginctl enable-linger', online['ansible.builtin.command']) - self.assertIn('not cybexos_offline', online['when']) - directory = image['Create the offline user lingering directory']['ansible.builtin.file'] - self.assertEqual((directory['path'], directory['state'], directory['mode']), - ('/var/lib/systemd/linger', 'directory', '0755')) - offline = image['Enable user lingering in the offline installation target']['ansible.builtin.copy'] - self.assertEqual((offline['dest'], offline['owner'], offline['mode']), - ('/var/lib/systemd/linger/{{ primary_user }}', 'root', '0644')) - # A fresh target has no linger directory; the copy module will not - # create it, which aborted the Anaconda post-install script. - order = [task['name'] for task in flatten('image.yml')] - directory = 'Create the offline user lingering directory' - self.assertLess(order.index(directory), order.index('Enable user lingering in the offline installation target')) - self.assertEqual(image[directory]['ansible.builtin.file'], - {'path': '/var/lib/systemd/linger', 'state': 'directory', - 'owner': 'root', 'group': 'root', 'mode': '0755'}) - self.assertEqual(image[directory]['when'], 'cybexos_offline | default(false) | bool') - self.assertEqual(image['Configure active local wheel Polkit authorization']['when'], - 'passwordless_local_polkit | bool') - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - self.assertIs(inventory['passwordless_local_polkit'], False) - self.assertEqual(image['Start Docker on demand rather than at boot']['ansible.builtin.systemd_service'], - {'name': 'docker.service', 'enabled': False}) - self.assertFalse(any(task.get('ansible.builtin.systemd_service', {}).get('name') == 'docker.service' - and task['ansible.builtin.systemd_service'].get('enabled') is True - for task in image.values())) - # cybex configure can deselect services the image installs for everyone. - deselected = image['Turn off deselected image services'] - self.assertEqual({(item['unit'], item['feature']) for item in deselected['loop']}, - {('docker.socket', 'docker'), ('docker.service', 'docker'), - ('tailscaled.service', 'tailscale')}) - self.assertIs(deselected['ansible.builtin.systemd_service']['enabled'], False) - self.assertIn('not features[item.feature] | bool', deselected['when']) - # authselect keeps a custom profile: only an existing feature is added. - mdns = image['Enable multicast DNS host resolution'] - self.assertEqual(mdns['ansible.builtin.command']['argv'], ['authselect', 'enable-feature', 'with-mdns4']) - self.assertIn("'with-mdns4' not in base_authselect_current.stdout.split()", mdns['when']) - self.assertFalse(any('select' in task.get('ansible.builtin.command', {}).get('argv', []) - for task in image.values() if isinstance(task.get('ansible.builtin.command'), dict))) - - def test_fingerprint_and_security_key_policy_reaches_both_paths(self): - # docs/security.md: one helper, applied from the role in every path, and - # installed for the Settings switch at the path each one uses. - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/authentication.yml').read_text()) - apply = tasks[0] - self.assertEqual(apply['ansible.builtin.script']['cmd'], 'cybexos-auth-policy apply') - self.assertEqual(apply['when'], 'not ansible_check_mode') - # A failure is reported, never left to break an installation half-way. - self.assertIs(apply['failed_when'], False) - main = {task['name']: task for task in yaml.safe_load((ROOT / 'roles/base/tasks/main.yml').read_text())} - helper = main['Install the sign-in policy helper']['ansible.builtin.copy'] - self.assertEqual((helper['src'], helper['dest'], helper['mode']), - ('cybexos-auth-policy', '/usr/local/libexec/cybexos-auth-policy', '0755')) - package = (ROOT / 'image/package').read_text() - self.assertIn('copy("roles/base/files/cybexos-auth-policy", "usr/libexec/cybexos-auth-policy", True)', package) - settings = (ROOT / 'roles/desktop/files/quickshell/scripts/system_settings_security.py').read_text() - for path in ('/usr/local/libexec/cybexos-auth-policy', '/usr/libexec/cybexos-auth-policy'): - self.assertIn(path, settings) - # The same packages on both paths, and on upgrades of older images. - packages = {'fprintd', 'fprintd-pam', 'libfprint', 'pam-u2f', 'pamu2fcfg', 'libfido2'} - baseline = yaml.safe_load((ROOT / 'roles/base/defaults/main.yml').read_text())['base_required_packages'] - self.assertTrue(packages.issubset(baseline)) - self.assertTrue(packages.issubset(repair.load_applications().package_names()[0])) - requires = ' '.join(line.split(':', 1)[1] for line in (ROOT / 'image/cybexos-desktop.spec').read_text().splitlines() - if line.startswith('Requires:')).split() - self.assertTrue({'fprintd', 'fprintd-pam', 'pam-u2f', 'pamu2fcfg'}.issubset(requires)) - # The XPS role keeps only its hardware gate and diagnostic; it no - # longer installs packages or changes authselect itself. - xps = (ROOT / 'roles/xps-2026/tasks/fingerprint.yml').read_text() - self.assertNotIn('ansible.builtin.dnf', xps) - self.assertNotIn('enable-feature', xps) - uninstall = (ROOT / 'roles/uninstall/tasks/main.yml').read_text() - self.assertLess(uninstall.index('/usr/local/libexec/cybexos-auth-policy remove'), - uninstall.index('- name: Remove project-owned system files')) - - def test_offline_linger_marker_is_created_when_parent_directory_is_absent(self): - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/accounts.yml').read_text()) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - linger = root / 'var/lib/systemd/linger' - selected = [dict(task) for task in tasks if task['name'] in ( - 'Create the offline user lingering directory', - 'Enable user lingering in the offline installation target')] - self.assertEqual(len(selected), 2) - self.assertEqual(selected[0]['name'], 'Create the offline user lingering directory') - selected[0]['ansible.builtin.file'] = { - **selected[0]['ansible.builtin.file'], 'path': str(linger)} - selected[1]['ansible.builtin.copy'] = { - **selected[1]['ansible.builtin.copy'], 'dest': str(linger / '{{ primary_user }}')} - for task in selected: - module = task.get('ansible.builtin.file') or task['ansible.builtin.copy'] - module.pop('owner', None) - module.pop('group', None) - playbook = root / 'offline-linger.yml' - playbook.write_text(yaml.safe_dump([{'hosts': 'localhost', 'connection': 'local', 'become': False, - 'gather_facts': False, 'vars': { - 'primary_user': 'fixture', 'cybexos_offline': True}, - 'tasks': selected}])) - for _ in range(2): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertTrue((linger / 'fixture').is_file()) - - def test_repair_payload_uses_shared_sources_and_hardware_detection(self): - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - repair.prepare(payload) - provision = payload / 'usr/share/cybexos/provision' - for relative in ('roles/base/tasks/accounts.yml', 'roles/base/tasks/mdns.yml', - 'roles/base/tasks/authentication.yml', 'roles/base/files/cybexos-auth-policy', - 'roles/base/tasks/docker-activation.yml', 'roles/xps-2026/tasks/camera.yml', - 'roles/dotfiles/files/fish-config.fish', 'roles/base/tasks/btrfs-scrub.yml', - 'roles/apps/tasks/mpv.yml'): - self.assertEqual((provision / relative).read_bytes(), (ROOT / relative).read_bytes()) - # Earlier installations receive the configure command, the - # feature-rendered shell unit, the agent skill and the XPS watcher. - for relative in ('usr/libexec/cybexos-config', 'usr/libexec/cybexos-external-monitor-toggle', - 'usr/lib/systemd/user/external-monitor-toggle.service', - 'usr/share/cybexos/agent-skills/cybexos/SKILL.md'): - self.assertTrue((payload / relative).is_file(), relative) - self.assertIn('CYBEXOS_CONNECTED_WIDGETS=1', - (payload / 'usr/lib/systemd/user/quickshell.service').read_text()) - features = (payload / 'usr/share/cybexos/runtime/hypr/features.lua').read_text() - self.assertIn('os.getenv("CYBEXOS_XPS_2026") == "1"', features) - self.assertNotIn('{{', features) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', - str(provision / 'image/provision.yml'), '--syntax-check', - '-e', '{"primary_user":"fixture","cybexos_offline":true,"cybexos_hardware_only":false}'], - env={**os.environ, 'ANSIBLE_CONFIG': str(provision / 'image/provision.cfg'), - 'ANSIBLE_ROLES_PATH': str(provision / 'roles')}, - text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_localsend_and_mdns_ports_stay_open_without_enabling_other_network_services(self): - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - env = jinja2.Environment(undefined=jinja2.StrictUndefined) - env.filters['bool'] = bool - source = (ROOT / 'roles/base/templates/cybexos-zone.xml.j2').read_text() - rendered = env.from_string(source).render(**inventory) - self.assertIn('port="53317" protocol="tcp"', rendered) - self.assertIn('port="53317" protocol="udp"', rendered) - self.assertIn('port="5353" protocol="udp"', rendered) - self.assertNotIn('port="27036"', rendered) - - def test_localsend_forwards_selected_files_and_keeps_text_literal(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - binary = root / 'flatpak' - binary.write_text('#!/usr/bin/python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n') - binary.chmod(0o755) - document = root / 'file with spaces.txt' - document.write_text('fixture') - result = subprocess.check_output(['bash', str(ROOT / 'assets/scripts/localsend'), str(document), '--text', 'a message'], - env={**os.environ, 'PATH': str(root) + ':' + os.environ['PATH']}, text=True) - self.assertEqual(json.loads(result), ['run', '--file-forwarding', 'org.localsend.localsend_app', - '@@', str(document), '@@', '--text', 'a message']) - - def test_clipboard_sharing_uses_a_description_without_clipboard_contents(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - for name, body in ( - ('wl-paste', 'print("private fixture")'), - ('systemd-run', 'import json,sys; print(json.dumps(sys.argv[1:]))'), - ): - tool = root / name - tool.write_text('#!/usr/bin/python3\n' + body + '\n') - tool.chmod(0o755) - result = subprocess.check_output(['bash', str(ROOT / 'assets/scripts/localsend-share'), 'clipboard'], - env={**os.environ, 'PATH': str(root) + ':' + os.environ['PATH']}, text=True) - arguments = json.loads(result) - self.assertIn('--description=Share clipboard via LocalSend', arguments) - self.assertEqual(arguments[-3:], ['localsend', '--text', 'private fixture']) - - def test_configuring_a_non_admin_never_adds_wheel(self): - import grp - import pwd - account = pwd.struct_passwd(('guest', 'x', 1001, 1001, '', '/home/guest', '/bin/bash')) - def group_name(name): - self.assertEqual(name, 'wheel') - return grp.struct_group(('wheel', 'x', 10, ['john'])) - with patch.object(configure.grp, 'getgrnam', side_effect=group_name), \ - patch.object(configure.grp, 'getgrgid', return_value=grp.struct_group(('guest', 'x', 1001, []))), \ - patch.object(configure, 'contained') as run: - configure.configure(account, offline=True) - values = json.loads(run.call_args.args[0][-1]) - self.assertNotIn('wheel', values['base_account_groups']) - self.assertTrue(values['cybexos_offline']) - self.assertEqual(run.call_args.kwargs['env']['ANSIBLE_CONFIG'], str(configure.PROVISION / 'image/provision.cfg')) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installer.py b/image/test_installer.py deleted file mode 100644 index e6df3036..00000000 --- a/image/test_installer.py +++ /dev/null @@ -1,1282 +0,0 @@ -"""Source-only fixtures. No real Anaconda, devices, mounts, services or secrets.""" - -import copy -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import tempfile -import types -import unittest -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parent - - -def load(name, relative): - loader = importlib.machinery.SourceFileLoader(name, str(ROOT / relative)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -backend = load("installer_backend", "live-rootfs/usr/libexec/cybexos-installer-backend") -target = load("installer_target", "live-rootfs/usr/libexec/cybexos-installer-target") -tree = load("installer_tree", "install-live-rootfs") -browser = load("installer_browser", "live-rootfs/usr/bin/cybexos-installer-browser") -DISK = dict( - name="vda", - path="/dev/vda", - size=100 * 1024**3, - model="Fixture disk", - serial="FIXTURE-ONLY", - wwn="", - device_id="disk-vda", -) -CHOICES = dict( - disks=[DISK], - keyboards=[{"id": "us", "label": "English"}, {"id": "nl", "label": "Dutch"}], - locales=["en_US.UTF-8", "nl_NL.UTF-8"], - locale="en_US.UTF-8", - keyboard="us", - timezones=["Europe/Amsterdam", "UTC"], - timezone="UTC", -) -ACCOUNT = dict( - username="alice", - password="fixture phrase never real", - confirm="fixture phrase never real", - keyboard="us", - locale="en_US.UTF-8", - timezone="UTC", - hostname="cybexos", - disk="vda", - encrypted=True, -) - - -class FakeBackend: - def __init__(self): - self.choices = copy.deepcopy(CHOICES) - self.started = False - self.failed = False - self.rescanned = False - self.actions = [{"action-description": "create"}] - self.analysis = {"free": 1024**3, "partitions": [ - {"id": "vda1", "path": "/dev/vda1", "size": 512 * 1024**2, "min_size": 512 * 1024**2, - "shrinkable": False, "filesystem": "efi"}, - {"id": "vda3", "path": "/dev/vda3", "size": 400 * 1024**3, "min_size": 100 * 1024**3, - "shrinkable": True, "filesystem": "ntfs"}, - {"id": "vda4", "path": "/dev/vda4", "size": 20 * 1024**3, "min_size": 2 * 1024**3, - "shrinkable": True, "filesystem": "ntfs"}, - ]} - - def inventory(self): - return self.choices - - def reset(self): - pass - - def rescan(self): - self.rescanned = True - - def keyboard(self, layout): - return {"keyboard": layout, "boot_keyboard": layout} - - def plan(self, account, credentials, disk): - if self.failed: - raise RuntimeError("fixture failure") - self.credentials = dict(credentials) - self.planned = account - return dict( - partitioning="/fixture", - actions=copy.deepcopy(self.actions), - errors=[], - warnings=[], - boot_keyboard="us", - ) - - def alongside(self, disk): - return copy.deepcopy(self.analysis) - - def check_plan(self, state): - pass - - def start_install(self, state): - self.started = True - - def run_install(self, state, progress, store): - progress(1, 2, "Installing fixture") - if self.failed: - raise RuntimeError("fixture failure") - - -class InstallerTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.state = backend.State(Path(self.temporary.name)) - self.adapter = FakeBackend() - self.installer = backend.Installer(self.adapter, self.state) - - def plan(self, **changes): - return self.installer.plan({**ACCOUNT, **changes}) - - def commit(self, plan, **changes): - return self.installer.install( - dict(token=plan["token"], confirmed_disk="vda", erase_confirmed=True, **changes) - ) - - def test_plan_is_non_destructive_and_persists_no_password(self): - data = copy.deepcopy(ACCOUNT) - result = self.installer.plan(data) - self.assertEqual(result["phase"], "review") - self.assertFalse(self.adapter.started) - self.assertNotIn("password", data) - self.assertNotIn(ACCOUNT["password"], (self.state.directory / "state.json").read_text()) - self.assertEqual((self.state.directory / "state.json").stat().st_mode & 0o777, 0o600) - - def test_invalid_account_cannot_reach_review(self): - cases = [ - {"username": "root"}, - {"username": "liveuser"}, - {"username": "sddm"}, - {"username": "alice\nuser"}, - {"password": "short", "confirm": "short"}, - {"confirm": "different"}, - {"keyboard": "invented"}, - {"locale": "invented"}, - {"timezone": "../etc/passwd"}, - {"hostname": "host;command"}, - {"encrypted": "false"}, - ] - for changes in cases: - with self.subTest(changes=list(changes)), self.assertRaises(backend.Invalid): - self.plan(**changes) - self.assertFalse(self.adapter.started) - - def test_locale_keyboard_and_unencrypted_choices_are_preserved(self): - plan = self.plan( - keyboard="nl", locale="nl_NL.UTF-8", timezone="Europe/Amsterdam", encrypted=False - ) - self.assertEqual(plan["account"]["locale"], "nl_NL.UTF-8") - self.assertFalse(plan["account"]["encrypted"]) - self.assertFalse(plan["account"]["passwordless_wheel"]) - - def test_sudo_requires_explicit_boolean_opt_in(self): - self.assertTrue(self.plan(passwordless_wheel=True)["account"]["passwordless_wheel"]) - for invalid in ("true", 1, None): - with self.subTest(invalid=invalid), self.assertRaises(backend.Invalid): - self.plan(passwordless_wheel=invalid) - - def test_rescan_invalidates_confirmation_and_returns_new_inventory(self): - old = self.plan() - self.assertEqual(self.installer.rescan()["disks"][0]["name"], "vda") - self.assertTrue(self.adapter.rescanned) - with self.assertRaises(backend.Invalid): - self.commit(old) - - def test_keyboard_change_invalidates_old_confirmation(self): - plan = self.plan() - self.assertEqual(self.installer.keyboard({"keyboard": "nl"})["boot_keyboard"], "nl") - with self.assertRaises(backend.Invalid): - self.commit(plan) - - def test_live_keyboard_command_runs_as_live_user_with_validated_values(self): - calls = [] - - def run(command, **kwargs): - calls.append(command) - output = ( - json.dumps([{"pid": os.getpid(), "instance": "fixture_instance-1"}]) - if command[-1] == "instances" - else "ok\n" - ) - return types.SimpleNamespace(stdout=output, returncode=0) - - with patch.object( - backend.pwd, "getpwnam", return_value=types.SimpleNamespace(pw_uid=os.getuid()) - ): - backend.apply_live_keyboard("us (dvorak)", run) - # Lua-configured Hyprland refuses `hyprctl keyword`; one eval sets both. - self.assertEqual( - calls[-1][-2:], - ["eval", 'hl.config({ input = { kb_layout = "us", kb_variant = "dvorak" } })'], - ) - self.assertEqual(len(calls), 2) - self.assertTrue( - all(command[:4] == ["runuser", "-u", "liveuser", "--"] for command in calls) - ) - with self.assertRaises(backend.Invalid): - backend.apply_live_keyboard("us; bad", run) - - def test_live_keyboard_rejected_by_compositor_is_reported(self): - def run(command, **kwargs): - if command[-1] == "instances": - return types.SimpleNamespace( - stdout=json.dumps([{"pid": os.getpid(), "instance": "fixture"}]), returncode=0 - ) - return types.SimpleNamespace(stdout=reply, returncode=code) - - for reply, code in ( - ("keyword can't work with non-legacy parsers. Use eval.\n", 0), - ("error: unknown config key 'input.kb_layout'\n", 7), - ): - with patch.object( - backend.pwd, "getpwnam", return_value=types.SimpleNamespace(pw_uid=os.getuid()) - ), self.assertRaises(backend.Invalid): - backend.apply_live_keyboard("nl", run) - - def test_password_control_characters_rejected_without_echo(self): - with self.assertRaises(backend.Invalid) as caught: - self.plan(password="long secret\nvalue", confirm="long secret\nvalue") - self.assertNotIn("secret", str(caught.exception)) - - def test_missing_or_small_disk_is_rejected(self): - with self.assertRaises(backend.Invalid): - self.plan(disk="sda") - self.adapter.choices["disks"][0]["size"] = 1024 - with self.assertRaises(backend.Invalid): - self.plan() - - def test_changed_disk_and_stale_confirmation_are_rejected(self): - plan = self.plan() - self.adapter.choices["disks"][0]["serial"] = "REPLACED" - with self.assertRaises(backend.Invalid): - self.commit(plan) - self.assertFalse(self.adapter.started) - self.adapter.choices["disks"][0]["serial"] = "FIXTURE-ONLY" - new = self.plan() - self.adapter.choices["disks"][0]["partitions"] = [ - {"path": "/dev/vda1", "size": 1024, "filesystem": "ext4"} - ] - with self.assertRaises(backend.Invalid): - self.commit(new) - - def test_status_and_diagnostics_redact_policy_and_worker_text(self): - plan = self.plan(passwordless_wheel=True) - self.commit(plan) - state = self.state.read() - state.update(step=1, total=2, message="private /home/alice fixture phrase never real") - self.state.write(state) - status = backend.installation_status(self.state, lambda *args, **kwargs: types.SimpleNamespace(stdout="active\n")) - self.assertEqual(status["phase"], "installing") - self.assertNotIn("account", status) - self.assertNotIn("private", json.dumps(status)) - report = backend.diagnostics(self.state, lambda *args, **kwargs: types.SimpleNamespace( - stdout="ActiveState=active\nSubState=running\nResult=success\nEnvironment=SECRET=leak\n")) - self.assertEqual(report["worker"], {"ActiveState": "active", "SubState": "running", "Result": "success"}) - self.assertNotIn("alice", json.dumps(report)) - self.assertNotIn("SECRET", json.dumps(report)) - - def test_confirmation_and_replay_protection(self): - plan = self.plan() - with self.assertRaises(backend.Invalid): - self.installer.install({"token": plan["token"], "confirmed_disk": "vda"}) - with self.assertRaises(backend.Invalid): - self.installer.install( - {"token": "other", "confirmed_disk": "vda", "erase_confirmed": True} - ) - self.commit(plan) - self.assertTrue(self.adapter.started) - with self.assertRaises(backend.Invalid): - self.commit(plan) - with self.assertRaises(backend.Invalid): - self.installer.reset() - - def test_failed_plan_retries_but_never_reuses_old_token(self): - old = self.plan() - self.adapter.failed = True - with self.assertRaises(RuntimeError): - self.plan() - with self.assertRaises(backend.Invalid): - self.commit(old) - self.adapter.failed = False - new = self.plan() - self.assertNotEqual(old["token"], new["token"]) - - def test_worker_completion_and_failure_are_durable(self): - self.commit(self.plan()) - self.assertEqual(self.installer.worker()["phase"], "complete") - self.state.write({"phase": "setup"}) - self.commit(self.plan()) - self.adapter.failed = True - with self.assertRaises(RuntimeError): - self.installer.worker() - self.assertEqual(self.state.read()["phase"], "failed-install") - with self.assertRaises(backend.Invalid): - self.plan() - - def test_failed_worker_names_its_step_and_explaining_log(self): - self.commit(self.plan()) - self.adapter.failed = True - self.adapter.run_install = lambda state, progress, store: ( - progress(40, 41, "Running post-installation scripts"), - (_ for _ in ()).throw(RuntimeError("There was an error running the kickstart script at line 1."))) - with self.assertRaises(RuntimeError): - self.installer.worker() - state = self.state.read() - self.assertEqual((state["phase"], state["failed_step"], state["log_source"]), - ("failed-install", "Running post-installation scripts", "post")) - self.assertIn("Running post-installation scripts", state["message"]) - status = backend.public_status(state) - self.assertEqual(status["log_source"], "post") - self.assertEqual(status["failed_step"], "Running post-installation scripts") - steps = (Path(self.temporary.name) / "worker.log").read_text() - self.assertIn("Installing to /dev/vda (Fixture disk), encrypted.", steps) - self.assertIn("[40/41] Running post-installation scripts", steps) - self.assertIn("RuntimeError: There was an error running the kickstart script at line 1.", steps) - self.assertNotIn(ACCOUNT["password"], steps + json.dumps(state)) - - def test_failed_worker_keeps_free_form_text_out_of_status_and_state(self): - self.commit(self.plan()) - private = "private /home/alice fixture phrase never real" - def fail(state, progress, store): - progress(1, 2, private) - raise RuntimeError(private) - self.adapter.run_install = fail - with self.assertRaises(RuntimeError): - self.installer.worker() - state = self.state.read() - self.assertNotIn(private, json.dumps(state)) - self.assertNotIn(private, json.dumps(backend.public_status(state))) - self.assertEqual(backend.public_status(state)["log_source"], "steps") - # Only the explicitly opened log carries free-form diagnostic text. - self.assertIn(private, (Path(self.temporary.name) / "worker.log").read_text()) - - def test_new_installation_starts_a_new_step_log(self): - log = Path(self.temporary.name) / "worker.log" - log.write_text("an earlier attempt\n") - self.commit(self.plan()) - self.installer.worker() - self.assertNotIn("an earlier attempt", log.read_text()) - self.assertTrue(log.read_text().rstrip().endswith("Installation finished.")) - # Other failures point at the step log itself. - self.assertEqual(backend.failure_state({"message": "Installing the software"})["log_source"], "steps") - - def test_disconnected_browser_cannot_allow_retry_after_worker_loss(self): - self.commit(self.plan()) - def active(*args, **kwargs): - return types.SimpleNamespace(stdout="active\n") - def stopped(*args, **kwargs): - return types.SimpleNamespace(stdout="inactive\n") - self.assertEqual(backend.installation_status(self.state, active)["phase"], "installing") - self.assertEqual(backend.installation_status(self.state, stopped)["phase"], "installing") - state = self.state.read() - state["worker_queued_at"] = 0 - self.state.write(state) - self.assertEqual( - backend.installation_status(self.state, stopped)["phase"], "failed-install" - ) - self.assertEqual(self.state.read()["phase"], "installing") - with self.assertRaises(backend.Invalid): - self.plan() - - def test_new_owner_plan_has_no_account_and_keeps_only_the_passphrase_in_memory(self): - data = {**ACCOUNT, "oem": True, "username": "", "password": "temporary handover 1", - "confirm": "temporary handover 1"} - plan = self.installer.plan(data) - self.assertEqual((plan["account"]["oem"], plan["account"]["username"]), (True, "")) - self.assertEqual(self.adapter.credentials, {"passphrase": "temporary handover 1"}) - self.assertNotIn("temporary handover", (self.state.directory / "state.json").read_text()) - # Unencrypted new-owner installations take no passphrase at all. - self.installer.plan({**data, "encrypted": False, "password": "", "confirm": ""}) - self.assertEqual(self.adapter.credentials, {}) - with self.assertRaises(backend.Invalid): - self.installer.plan({**data, "oem": "yes"}) - - def alongside(self, **changes): - return self.installer.plan({**ACCOUNT, "layout": "alongside", **changes}) - - def test_alongside_offers_and_accepts_only_the_largest_shrinkable_partition(self): - options = self.installer.alongside({"disk": "vda"}) - self.assertEqual(options["mode"], "shrink") - self.assertEqual(options["partition"]["id"], "vda3") - self.assertEqual(options["floor"], 105 * 1024**3) - self.assertEqual(options["max_space"], 1024**3 + 400 * 1024**3 - 105 * 1024**3) - self.assertTrue(options["min_space"] <= options["default_space"] <= options["max_space"]) - for shrink in ({"device": "vda4", "size": 10 * 1024**3}, # not the largest - {"device": "vda3", "size": 104 * 1024**3}, # below its floor - {"device": "vda3", "size": 400 * 1024**3}, # not a shrink - {"device": "vda3", "size": 380 * 1024**3}): # too little for CybexOS - with self.subTest(shrink=shrink), self.assertRaises(backend.Invalid): - self.alongside(shrink=shrink) - with self.assertRaises(backend.Invalid): - self.alongside() # Free space alone is not enough here. - plan = self.alongside(shrink={"device": "vda3", "size": 300 * 1024**3}) - self.assertEqual(plan["layout"]["resize"], {"path": "/dev/vda3", "filesystem": "ntfs", - "from": 400 * 1024**3, "to": 300 * 1024**3}) - self.assertEqual(plan["layout"]["space"], 101 * 1024**3) - self.assertEqual(plan["layout"]["kept"], ["/dev/vda1", "/dev/vda4"]) - self.assertEqual(self.adapter.planned["shrink"], {"device": "vda3", "size": 300 * 1024**3}) - - def test_alongside_uses_free_space_without_shrinking_when_it_suffices(self): - self.adapter.analysis["free"] = 80 * 1024**3 - self.assertEqual(self.installer.alongside({"disk": "vda"})["mode"], "free") - with self.assertRaises(backend.Invalid): - self.alongside(shrink={"device": "vda3", "size": 300 * 1024**3}) - plan = self.alongside() - self.assertIsNone(plan["account"]["shrink"]) - self.assertNotIn("resize", plan["layout"]) - - def test_alongside_plan_touching_other_partitions_is_refused(self): - self.adapter.analysis["free"] = 80 * 1024**3 - for action in ({"action-type": "destroy", "device-id": "vda4"}, - {"action-type": "resize", "device-id": "vda4"}, - {"action-type": "create", "object-type": "format", "device-id": "vda1"}, - {"action-type": "create", "object-type": "format", "device-id": "vda"}): - with self.subTest(action=action): - self.adapter.actions = [{"action-type": "create", "device-id": "new-1"}, action] - with self.assertRaises(backend.Invalid): - self.alongside() - self.assertEqual(self.state.read()["phase"], "failed-plan") - self.adapter.actions = [{"action-type": "create", "object-type": "device", "device-id": "new-1"}, - {"action-type": "create", "object-type": "format", "device-id": "new-1"}] - self.assertEqual(self.alongside()["phase"], "review") - - def test_alongside_install_requires_its_own_confirmation(self): - self.adapter.analysis["free"] = 80 * 1024**3 - plan = self.alongside() - with self.assertRaises(backend.Invalid): - self.commit(plan) # erase_confirmed does not confirm a dual-boot install. - self.assertFalse(self.adapter.started) - self.installer.install({"token": plan["token"], "confirmed_disk": "vda", "alongside_confirmed": True}) - self.assertTrue(self.adapter.started) - - -# Strict proxy member sets were checked against the anaconda-44.30 interface -# declarations linked in INSTALLER.md. Unknown property access or assignment -# fails instead of silently creating arbitrary MagicMock methods. -class StrictProxy: - def __init__(self, values=None, methods=None): - object.__setattr__(self, "_values", values or {}) - object.__setattr__(self, "_methods", methods or {}) - - def __getattr__(self, name): - if name in self._values: - return self._values[name] - if name in self._methods: - return self._methods[name] - raise AttributeError(name) - - def __setattr__(self, name, value): - if name not in self._values: - raise AttributeError(name) - self._values[name] = value - - -class Structure: - __slots__ = ( - "name", - "gecos", - "password", - "is_crypted", - "groups", - "partitioning_scheme", - "encrypted", - "luks_version", - ) - - @classmethod - def from_structure(cls, data): - return cls() - - @staticmethod - def to_structure(data): - return { - name.replace("_", "-"): getattr(data, name) - for name in data.__slots__ - if hasattr(data, name) - } - - -LSBLK = {"blockdevices": [ - {"name": "nvme0n1", "path": "/dev/nvme0n1", "type": "disk", "fstype": None, "label": None, "size": 512 * 1024**3, - "hotplug": False, "rm": False, "ro": False, "mountpoints": [None], - "children": [{"name": "nvme0n1p1", "path": "/dev/nvme0n1p1", "type": "part", "fstype": "vfat", "label": None, - "size": 600 * 1024**2, "hotplug": False, "rm": False, "ro": False, "mountpoints": [None]}]}, - {"name": "sda", "path": "/dev/sda", "type": "disk", "fstype": "iso9660", "label": "CYBEXOS-LIVE-44", - "size": 8 * 1024**3, "hotplug": True, "rm": True, "ro": False, "mountpoints": ["/run/initramfs/live"], - "children": [{"name": "sda2", "path": "/dev/sda2", "type": "part", "fstype": "vfat", "label": "ANACONDA", - "size": 10 * 1024**2, "hotplug": True, "rm": True, "ro": False, "mountpoints": [None]}]}, - {"name": "sdb", "path": "/dev/sdb", "type": "disk", "fstype": None, "label": None, "size": 32 * 1024**3, - "hotplug": True, "rm": True, "ro": False, "mountpoints": [None], - "children": [{"name": "sdb1", "path": "/dev/sdb1", "type": "part", "fstype": "exfat", "label": "STICK", - "size": 32 * 1024**3, "hotplug": True, "rm": True, "ro": False, "mountpoints": [None]}, - {"name": "sdb2", "path": "/dev/sdb2", "type": "part", "fstype": "ntfs", "label": "WIN", - "size": 1024**3, "hotplug": True, "rm": True, "ro": False, "mountpoints": [None]}]}, - {"name": "sdc", "path": "/dev/sdc", "type": "disk", "fstype": "vfat", "label": "MOUNTED", "size": 16 * 1024**3, - "hotplug": True, "rm": False, "ro": False, "mountpoints": ["/run/media/liveuser/MOUNTED"]}, - {"name": "vda", "path": "/dev/vda", "type": "disk", "fstype": "ext4", "label": None, "size": 100 * 1024**3, - "hotplug": True, "rm": False, "ro": False, "mountpoints": [None]}, -]} - - -class DiagnosticsTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - - def test_log_reading_is_allow_listed_and_bounded(self): - first, second = self.root / "one.log", self.root / "two.log" - first.write_text("".join(f"line {number}\n" for number in range(100))) - sources = {"one": [first], "both": [first, second], "missing": [self.root / "absent.log"]} - with self.assertRaises(backend.Invalid): - backend.read_log("../../etc/shadow", sources) - with self.assertRaises(backend.Invalid): - backend.read_log("/tmp/sensitive-info.log") - tail = backend.read_log("one", sources, limit=30)["text"] - self.assertTrue(tail.startswith("line ") and tail.endswith("line 99\n")) - self.assertLessEqual(len(tail), 30) - self.assertEqual(backend.read_log("missing", sources), {"source": "missing", "available": False, "text": ""}) - second.write_text("second\n") - both = backend.read_log("both", sources)["text"] - self.assertLess(both.index(f"==> {first} <=="), both.index(f"==> {second} <==")) - self.assertNotIn(Path("/tmp/sensitive-info.log"), [path for paths in backend.LOG_SOURCES.values() for path in paths]) - # Post-install output lands in the target (chroot) and the live system (nochroot). - self.assertEqual(backend.LOG_SOURCES["post"], [Path("/mnt/sysroot/var/log/anaconda/cybexos-cleanup.log"), - Path("/var/log/anaconda/cybexos-target-policy.log")]) - hook = (ROOT / "live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks").read_text() - self.assertIn("--log=/var/log/anaconda/cybexos-cleanup.log", hook) - self.assertIn("--log=/var/log/anaconda/cybexos-target-policy.log", hook) - - def test_page_offers_exactly_the_backend_logs(self): - import re - page = (ROOT / "live-rootfs/usr/share/cockpit/cybexos-installer/index.html").read_text() - select = page[page.index('", page.index(' { setQuery(e.target.value); setSelected(0); }} onKeyDown={e => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(i => (i + 1) % Math.max(1, filtered.length)); } if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(i => (i + filtered.length - 1) % Math.max(1, filtered.length)); } if (e.key === 'Enter' && filtered[selected]) { e.preventDefault(); openApp(filtered[selected]); } }} />esc
APPLICATIONS {filtered.length}
{filtered.length ? filtered.map((id, index) => { const Icon = ICONS[id]; return ; }) :

No apps found for “{query}”.

}
↑↓ navigate↵ openCYBEXOS
} + {panel === 'launcher' && setPanel(null)}>
{ setQuery(e.target.value); setSelected(0); }} onKeyDown={e => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(i => (i + 1) % Math.max(1, filtered.length)); } if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(i => (i + filtered.length - 1) % Math.max(1, filtered.length)); } if (e.key === 'Enter' && filtered[selected]) { e.preventDefault(); openApp(filtered[selected]); } }} />esc
APPLICATIONS {filtered.length}
{filtered.length ? filtered.map((id, index) => { const Icon = ICONS[id]; return ; }) :

No apps found for “{query}”.

}
↑↓ navigate↵ openNERDOS
} {panel === 'layout' && setPanel(null)}>

Hyprland

dwindle · workspace {workspace}

@@ -267,8 +267,8 @@ export default function Desktop() { {panel === 'controls' && setPanel(null)} volume={volume} setVolume={setVolume} brightness={brightness} setBrightness={setBrightness} wifi={wifi} setWifi={setWifi} bluetooth={bluetooth} setBluetooth={setBluetooth} dnd={dnd} setDnd={value => { setDnd(value); setToast(''); }} preferences={preferences} updatePreferences={updatePreferences} notices={notices} setNotices={setNotices} openApp={openApp} lock={() => { setPanel(null); setLocked(true); }} />} - {toast &&
CybexOS

{toast}

} + {toast &&
NerdOS

{toast}

} - {locked &&
Cybex
VVisitor
Just a pause. Your apps are right where you left them.
} + {locked &&
NerdOS
VVisitor
Just a pause. Your apps are right where you left them.
} ; } diff --git a/web/components/Fastfetch.tsx b/web/components/Fastfetch.tsx index 57c41fcd..2f51bdd5 100644 --- a/web/components/Fastfetch.tsx +++ b/web/components/Fastfetch.tsx @@ -1,13 +1,18 @@ -import { DEMO_SYSTEM, FEDORA_LOGO, SYSTEM_DETAILS } from '../lib/terminal'; +import type { ReactNode } from 'react'; +import { DEMO_SYSTEM, NERDOS_LOGO_COLORED, SYSTEM_DETAILS } from '../lib/terminal'; /** A semantic rendering of the same sample data returned by the command parser. */ export function Fastfetch() { return ( -
-