From 5cc75692754f3d5e564723b018f4509ba493c4d1 Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:21:31 +0200 Subject: [PATCH 1/2] Improve update recovery, settings ownership, and installation parity Add protected update transactions and bootstrap recovery, lossless settings persistence, shell safe mode, native input and region controls, and guided Fedora upgrades. Split integration modules, expand real QML behavior tests, and require same-source checkout/ISO qualification. Include session startup, XPS display, SMB defaults, and live DNF permissions fixes. Validated source and image parity gates and live managed Settings. Installed-system verification: zero failures, three existing warnings. ISO/VM release qualification was not run. --- ansible.cfg | 1 + assets/scripts/cybexos-runtime | 22 + assets/scripts/cybexos-update-run | 254 ++- docs/architecture/ownership.md | 70 +- docs/fedora-major-upgrade.md | 107 +- docs/installation-parity.md | 71 +- docs/integration-boundaries.md | 40 + docs/native-system-settings.md | 51 +- docs/operations.md | 70 +- docs/releasing.md | 15 +- docs/shell-recovery.md | 40 + docs/xps-2026-hardware.md | 33 + image/Containerfile.tests | 4 +- image/build | 27 +- image/cybexos-desktop.spec | 10 + image/desktop_payload.py | 5 +- image/github_release.py | 11 + image/installed_outcomes.py | 231 ++ image/library/cybexos_managed_file.py | 25 +- image/library/cybexos_user_include.py | 129 ++ image/package | 22 +- image/parity-exceptions.json | 9 + image/parity_qualification.py | 59 + image/provision_payload.py | 3 +- image/qualification.py | 24 + image/qualify-checkout | 192 ++ image/release-gate | 30 +- image/rootfs/usr/bin/cybex | 5 +- image/source_snapshot.py | 64 + image/test_display_policy.py | 128 ++ image/test_github_release.py | 25 +- image/test_installed_outcomes.py | 173 ++ image/test_release_gate.py | 25 + image/test_session_start.py | 12 + image/test_update_recovery.py | 97 + image/test_user_parity.py | 54 +- image/vm_testing.py | 10 +- roles/base/files/cybexos-major-upgrade | 717 ++++++ .../cybexos-major-upgrade-validate.service | 14 + .../cybexos-major-upgrade-validate.timer | 10 + roles/base/files/cybexos-system-snapshot | 7 +- roles/base/files/cybexos-update-bootstrap | 282 +++ roles/base/files/cybexos-update-recover | 12 + .../files/cybexos-update-recover-login.conf | 4 + .../base/files/cybexos-update-recover.service | 21 + roles/base/files/cybexos-update-transaction | 433 ++++ roles/base/files/cybexos-vendor-paths.json | 76 + roles/base/tasks/main.yml | 72 + .../files/90-cybexos-smb.gschema.override | 4 + roles/desktop/files/autostart.lua | 11 +- .../cybex_hermes/__init__.py | 4 + .../cybex_hermes/auth.py | 1249 +++++++++++ .../cybex_hermes/gateway.py | 442 ++++ .../cybex_hermes/protocol.py | 122 + .../cybex_hermes/registry.py | 182 ++ .../hermes-menubar-bridge/hermes_bridge.py | 1970 +---------------- roles/desktop/files/hyprland.lua | 4 +- roles/desktop/files/input_preferences.lua | 69 + .../quickshell/Common/CommandRequest.qml | 90 + .../files/quickshell/Common/GitHub.qml | 127 +- .../files/quickshell/Common/GitHubQueue.js | 39 + .../Common/Persistence/SettingsDocument.qml | 50 + .../quickshell/Common/Persistence/qmldir | 1 + .../files/quickshell/Common/Settings.qml | 178 +- .../quickshell/Common/SettingsHelpers.js | 317 ++- .../quickshell/Common/SettingsSearchData.js | 7 + .../quickshell/Common/SystemSettings.qml | 2 + .../Common/SystemSettingsBackend.qml | 6 +- .../files/quickshell/Common/T3Actions.qml | 385 ++++ .../desktop/files/quickshell/Common/T3Rpc.qml | 369 +-- .../quickshell/Common/UpdateLogReader.qml | 28 + .../files/quickshell/Common/Updates.qml | 110 +- roles/desktop/files/quickshell/Common/qmldir | 4 + .../files/quickshell/Settings/InputDraft.js | 32 + .../quickshell/Settings/KeyboardPage.qml | 145 ++ .../files/quickshell/Settings/RegionPage.qml | 111 + .../quickshell/Settings/SettingsView.qml | 2 + .../quickshell/Settings/TouchpadPage.qml | 69 + .../desktop/files/quickshell/Settings/qmldir | 1 + .../files/quickshell/safe-mode/shell.qml | 91 + .../files/quickshell/scripts/settings-store | 122 + .../quickshell/scripts/shell-recovery.py | 139 ++ .../quickshell/scripts/system-settings.py | 7 +- .../scripts/system_settings_input.py | 205 ++ .../scripts/system_settings_region.py | 79 + roles/desktop/files/quickshell/shell.qml | 5 +- roles/desktop/tasks/hermes-menubar.yml | 11 + roles/desktop/tasks/main.yml | 20 +- roles/desktop/templates/input.lua.j2 | 5 +- roles/desktop/templates/quickshell.service.j2 | 1 + roles/dotfiles/files/gitconfig | 13 + roles/dotfiles/files/ssh.conf | 4 + roles/dotfiles/tasks/personal.yml | 104 +- roles/dotfiles/tasks/shell-defaults.yml | 5 +- roles/dotfiles/templates/cybex.j2 | 8 + roles/uninstall/tasks/main.yml | 35 +- roles/xps-2026/defaults/main.yml | 6 + roles/xps-2026/tasks/display.yml | 74 + roles/xps-2026/tasks/main.yml | 4 + tests/cybexos-update-run | 294 ++- tests/hermes-bridge.py | 18 + tests/hermes-remote-runtime.py | 1 + tests/hermes-webui-auth.py | 1 + tests/hyprland-features | 2 +- tests/hyprland-input | 48 + tests/installation-parity.py | 6 +- tests/major-upgrade.py | 369 +++ tests/native-input-region.py | 218 ++ tests/qml-lifecycle/shell.qml | 57 + tests/qml/tst_input_draft.qml | 43 + tests/qml/tst_settings_document.qml | 149 ++ .../deployment-convergence.test.cjs | 2 +- tests/quickshell/display-settings.test.cjs | 3 +- tests/quickshell/fileview-writes.test.cjs | 187 +- .../github-inbox-structure.test.cjs | 29 +- tests/quickshell/github-queue.test.cjs | 28 + tests/quickshell/hermes-ui.test.cjs | 4 +- tests/quickshell/notes-widget.test.cjs | 2 +- tests/quickshell/settings-helpers.test.cjs | 49 +- .../quickshell/settings-persistence.test.cjs | 6 +- tests/quickshell/settings-rows.test.cjs | 2 +- tests/quickshell/settings-schema.test.cjs | 2 +- tests/quickshell/settings.test.cjs | 68 +- tests/quickshell/system-theme-kitty.test.cjs | 4 +- tests/quickshell/t3-actions.test.cjs | 67 + tests/quickshell/widget-editor.test.cjs | 2 +- tests/quickshell/widget-options.test.cjs | 1 + tests/repository-policy.py | 4 +- tests/run | 13 +- tests/session-launcher.py | 103 + tests/settings-ownership.py | 173 ++ tests/shell-recovery.py | 92 + tests/system-settings-live | 9 +- tests/update-bootstrap.py | 208 ++ tests/update-transaction.py | 478 ++++ tests/verify-system | 18 +- 136 files changed, 10503 insertions(+), 3121 deletions(-) create mode 100644 docs/integration-boundaries.md create mode 100644 docs/shell-recovery.md create mode 100644 image/installed_outcomes.py create mode 100644 image/library/cybexos_user_include.py create mode 100644 image/parity-exceptions.json create mode 100644 image/parity_qualification.py create mode 100755 image/qualify-checkout create mode 100644 image/source_snapshot.py create mode 100644 image/test_display_policy.py create mode 100644 image/test_installed_outcomes.py create mode 100644 image/test_session_start.py create mode 100644 image/test_update_recovery.py create mode 100755 roles/base/files/cybexos-major-upgrade create mode 100644 roles/base/files/cybexos-major-upgrade-validate.service create mode 100644 roles/base/files/cybexos-major-upgrade-validate.timer create mode 100755 roles/base/files/cybexos-update-bootstrap create mode 100755 roles/base/files/cybexos-update-recover create mode 100644 roles/base/files/cybexos-update-recover-login.conf create mode 100644 roles/base/files/cybexos-update-recover.service create mode 100755 roles/base/files/cybexos-update-transaction create mode 100644 roles/base/files/cybexos-vendor-paths.json create mode 100644 roles/desktop/files/90-cybexos-smb.gschema.override create mode 100644 roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py create mode 100644 roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py create mode 100644 roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py create mode 100644 roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py create mode 100644 roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py create mode 100644 roles/desktop/files/input_preferences.lua create mode 100644 roles/desktop/files/quickshell/Common/CommandRequest.qml create mode 100644 roles/desktop/files/quickshell/Common/GitHubQueue.js create mode 100644 roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml create mode 100644 roles/desktop/files/quickshell/Common/Persistence/qmldir create mode 100644 roles/desktop/files/quickshell/Common/T3Actions.qml create mode 100644 roles/desktop/files/quickshell/Common/UpdateLogReader.qml create mode 100644 roles/desktop/files/quickshell/Settings/InputDraft.js create mode 100644 roles/desktop/files/quickshell/Settings/KeyboardPage.qml create mode 100644 roles/desktop/files/quickshell/safe-mode/shell.qml create mode 100644 roles/desktop/files/quickshell/scripts/settings-store create mode 100644 roles/desktop/files/quickshell/scripts/shell-recovery.py create mode 100644 roles/desktop/files/quickshell/scripts/system_settings_input.py create mode 100644 roles/desktop/files/quickshell/scripts/system_settings_region.py create mode 100644 roles/dotfiles/files/gitconfig create mode 100644 roles/dotfiles/files/ssh.conf create mode 100644 roles/xps-2026/tasks/display.yml create mode 100755 tests/hyprland-input create mode 100644 tests/major-upgrade.py create mode 100755 tests/native-input-region.py create mode 100644 tests/qml/tst_input_draft.qml create mode 100644 tests/qml/tst_settings_document.qml create mode 100644 tests/quickshell/github-queue.test.cjs create mode 100644 tests/quickshell/t3-actions.test.cjs create mode 100644 tests/settings-ownership.py create mode 100644 tests/shell-recovery.py create mode 100644 tests/update-bootstrap.py create mode 100644 tests/update-transaction.py diff --git a/ansible.cfg b/ansible.cfg index 8d978b9c..e26deb33 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,6 +1,7 @@ [defaults] inventory = inventory/hosts.yml roles_path = roles +library = image/library callback_plugins = plugins/callback interpreter_python = auto_silent retry_files_enabled = False diff --git a/assets/scripts/cybexos-runtime b/assets/scripts/cybexos-runtime index fd690c8f..ea094191 100755 --- a/assets/scripts/cybexos-runtime +++ b/assets/scripts/cybexos-runtime @@ -17,6 +17,7 @@ Usage: 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, @@ -243,6 +244,9 @@ exec_runtime() { 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) @@ -280,6 +284,9 @@ exec_runtime() { 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. @@ -303,6 +310,21 @@ case $command_name in 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 diff --git a/assets/scripts/cybexos-update-run b/assets/scripts/cybexos-update-run index 5ee4fbf6..f8d9d7bf 100755 --- a/assets/scripts/cybexos-update-run +++ b/assets/scripts/cybexos-update-run @@ -311,6 +311,8 @@ write_status() { --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}" \ @@ -324,6 +326,7 @@ write_status() { 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" @@ -562,10 +565,25 @@ tagged_command() { set +e stdbuf -oL -eL "$@" 2>&1 | tee "$logfile" \ | sed -u "s/^/[$tag] /" >> "$run_dir/run.log" - printf '%s\n' "${PIPESTATUS[0]}" > "$rcfile" + 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 @@ -573,6 +591,11 @@ 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 @@ -656,6 +679,36 @@ restore_release_configuration() { 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. +finish_failed_transaction() { + [[ $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. @@ -665,6 +718,13 @@ worker_failed() { 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" @@ -712,12 +772,11 @@ run_as_update_owner() { activate_release() { [[ $release_transaction == true ]] || return 0 - local releases_root="$release_data_root/releases" expected old_release old_path + 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 - local -a installed_releases=() expected=$(readlink -f -- "$releases_root/$release_version") || return 1 [[ $expected == "$repo" ]] || return 1 @@ -755,6 +814,13 @@ activate_release() { 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 @@ -801,6 +867,12 @@ worker_cancelled() { 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" @@ -817,6 +889,7 @@ worker_exit_fallback() { 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 } @@ -907,7 +980,7 @@ run_firmware_phase() { if [[ -x $helper ]]; then tagged_command firmware "$run_dir/firmware.log" "$run_dir/firmware.rc" \ "${privileged_package[@]}" "$helper" install --events "$events" - firmware_rc=$(<"$run_dir/firmware.rc") + firmware_rc=$(phase_result "$run_dir/firmware.rc") else firmware_rc=127 printf '%s\n' "$firmware_rc" >"$run_dir/firmware.rc" @@ -1000,8 +1073,20 @@ worker() { 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 @@ -1013,20 +1098,97 @@ worker() { 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=$(<"$run_dir/snapshot.rc") + 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 == skipped:* ]] && snapshot_id="" + 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 + fi else - if [[ $(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) == btrfs ]]; then + 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 + 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 --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 @@ -1041,12 +1203,12 @@ worker() { printf '0\n' > "$run_dir/flatpak.rc"; flatpak_done=true fi wait_for_job "$dnf_pid" - dnf_rc=$(<"$run_dir/dnf.rc"); dnf_done=true + 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=$(<"$run_dir/flatpak.rc"); flatpak_done=true + flatpak_rc=$(phase_result "$run_dir/flatpak.rc"); flatpak_done=true write_status running packages "Package phase finished" 0 fi else @@ -1058,12 +1220,10 @@ worker() { if (( dnf_rc != 0 )); then worker_failed packages "dnf exited with status $dnf_rc" "$dnf_rc" fi - honor_cancellation - - if [[ $with_firmware == true ]]; then - run_firmware_phase - honor_cancellation + if [[ $transaction_started == true ]] && ((flatpak_rc != 0)); then + worker_failed packages "Flatpak exited with status $flatpak_rc" "$flatpak_rc" fi + honor_cancellation if [[ $full == true ]]; then cd -- "$repo" || { @@ -1076,7 +1236,7 @@ worker() { # 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=$(<"$run_dir/tests.rc") + 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 @@ -1098,9 +1258,13 @@ worker() { [[ $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=$(<"$run_dir/ansible.rc") + 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 @@ -1113,9 +1277,65 @@ worker() { 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 + 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" diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md index dff53810..037cbc21 100644 --- a/docs/architecture/ownership.md +++ b/docs/architecture/ownership.md @@ -57,6 +57,16 @@ 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 @@ -90,23 +100,43 @@ 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. -That target is not yet enforced for every application. Remaining work: - -- Shell settings currently normalize to known keys and have visual migrations - that infer an untouched value from equality with a previous default. Replace - that inference with explicit override tracking; treat legacy stored choices - conservatively. Keep unsupported future schemas read-only on older hosts. -- Personal-dotfile deployment still replaces files such as Fastfetch, - Voxtype, MIME associations, and XDG user directories. Move defaults into - vendor fragments where supported, or seed only absent user files. Migrate - adopted files using a last-installed baseline and preserve conflicting edits. -- Includes need application-specific precedence tests. Git and Kitty commonly - use later values; SSH commonly uses the first obtained value. The current - SSH include at the beginning can take precedence over personal choices. -- Extend release checks beyond file sentinels: verify settings behavior, an - enabled API fixture, service overrides, app defaults, failed updates, and - rollback against supported previous releases. Preserve user-created package - and service additions when optional distro features change. - -Until those changes land, the widget contract does not imply that every -existing application setting already survives distro convergence unchanged. +Shell settings now use a sparse schema-27 document: 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. diff --git a/docs/fedora-major-upgrade.md b/docs/fedora-major-upgrade.md index b8d2f162..28c6960e 100644 --- a/docs/fedora-major-upgrade.md +++ b/docs/fedora-major-upgrade.md @@ -1,5 +1,107 @@ # 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 @@ -21,8 +123,9 @@ branch for all compatibility changes. 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 rollback for a failed major upgrade is restore/reinstall, not an - attempted mass package downgrade. + 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. diff --git a/docs/installation-parity.md b/docs/installation-parity.md index 26824646..27988f1b 100644 --- a/docs/installation-parity.md +++ b/docs/installation-parity.md @@ -15,6 +15,7 @@ Fresh installations use these ISO defaults: | Docker administrator access | Sudo required | | Desktop automatic login | Enabled only after complete root encryption is verified | | Additional local-network firewall ports | Disabled; LocalSend retains its shared ports | +| 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. @@ -34,8 +35,9 @@ 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. Fastfetch, Voxtype, Oh My Posh, MIME associations, and npm configuration are -seeded only when absent, as on the ISO. Managed Fish and Kitty fragments remain -updateable independently of those personal files. +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 checkout path installs onto existing Fedora and retains source-release updates and uninstall; the ISO uses Anaconda for disk/account creation and RPM @@ -65,3 +67,68 @@ 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. + +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, filesystem and personal-file identities. 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. Initial exceptions cover only the ISO delivery RPM and Cloud provisioning +tools. 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. + +`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/integration-boundaries.md b/docs/integration-boundaries.md new file mode 100644 index 00000000..9de25202 --- /dev/null +++ b/docs/integration-boundaries.md @@ -0,0 +1,40 @@ +# Desktop integration boundaries + +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; + `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. +- **GitHub:** `GitHubQueue.js` implements deterministic deduplication and + interactive FIFO priority. `CommandRequest.qml` owns subprocess output, + launch failure and bounded termination. `GitHub.qml` owns caches, conditional + requests and Inbox reconciliation; popovers retain presentation only. +- **T3:** `T3Rpc.qml` owns wire correlation, request deadlines and interruption. + `T3Actions.qml` owns domain commands, capability checks, batches and action + 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. +- **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. + +`CommandRequest` has `command`, `running`, `stdinEnabled`, `inputText`, +`timeoutMs`, `killGraceMs` and `timeoutMessage` inputs. Set the command and then +`running = true`. `completed(code, body, error)` fires once per request; +`available()` announces when the process slot is free. Launch failure has code +`-1`; timeout has code `124` and an empty body, including if a process printed +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. diff --git a/docs/native-system-settings.md b/docs/native-system-settings.md index 0dca5c77..682ec170 100644 --- a/docs/native-system-settings.md +++ b/docs/native-system-settings.md @@ -1,6 +1,7 @@ # Native system settings -CybexOS Settings now includes Network, Sound, Displays and Online Accounts. The +CybexOS Settings includes Network, Sound, Displays, Keyboard, Touchpad, Region +and Online Accounts. 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. @@ -13,6 +14,9 @@ available. | 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 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` | +| 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 | GOA continues to own account authentication and credentials. The shell reads metadata and never requests access tokens or passwords. Calendar data still @@ -156,6 +160,51 @@ 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 diff --git a/docs/operations.md b/docs/operations.md index 6c3738c1..c821ea87 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -98,7 +98,8 @@ The stable role boundaries are `base`, `desktop`, `apps`, `xps-2026` (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`, and `touchpad`. The +`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. @@ -265,16 +266,23 @@ 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. Apply failure restores -the pre-migration configuration; activation failure also restores the prior -`current` target and every agent-skill slot. Files that Ansible had already -deployed from the candidate are not rolled back, so after a failed or -abandoned apply the run's `status.json` records `mixedState: true`: the -machine runs the previous release with some newer managed files. Retry -`cybex update` once the cause is fixed, or converge the active release again -with `~/.local/share/cybexos/current/install`. The active release plus two -recent release directories are retained as recovery material; filesystem -rollback remains the supported way to reverse system package changes. +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: @@ -328,7 +336,7 @@ contract is not cancellable. `dismiss` only changes the completed status shown by the UI and does not delete its logs. `--firmware` (also accepted by `cybex update`) installs available fwupd device -firmware in the same worker after the package phase, through +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`, @@ -339,8 +347,8 @@ 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, and `mixedState` when a release apply stopped after Ansible began - changing files; + 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`, @@ -369,6 +377,40 @@ 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. + +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 diff --git a/docs/releasing.md b/docs/releasing.md index df4ee9ad..f2c452db 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -70,12 +70,15 @@ repository. 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. -Users whose system apply failed keep their previous active release (`current`) -and their prior saved configuration, but files that Ansible had already -deployed from the failed candidate stay in place; the run records -`mixedState: true`. The corrected release converges them, and -`~/.local/share/cybexos/current/install` restores the previous release's files -in the meantime. +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 diff --git a/docs/shell-recovery.md b/docs/shell-recovery.md new file mode 100644 index 00000000..ae1d81fd --- /dev/null +++ b/docs/shell-recovery.md @@ -0,0 +1,40 @@ +# Shell crash recovery + +The managed Quickshell service records failed invocations without supervising a +second process. The launcher replaces itself with `qs`, so the service MainPID +remains the only shell process. Three failed invocations within two minutes +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, +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. +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 +``` + +**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 +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. +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. + +`tests/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/xps-2026-hardware.md b/docs/xps-2026-hardware.md index 19887124..92bf8619 100644 --- a/docs/xps-2026-hardware.md +++ b/docs/xps-2026-hardware.md @@ -37,6 +37,39 @@ 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 diff --git a/image/Containerfile.tests b/image/Containerfile.tests index 8847325f..c9189fed 100644 --- a/image/Containerfile.tests +++ b/image/Containerfile.tests @@ -1,7 +1,7 @@ FROM fedora:44 -RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 \ +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 ansible-core rpm-build rpm-sign createrepo_c \ + nodejs24 gnupg2 git ripgrep luajit ansible-core rpm-build rpm-sign createrepo_c \ && dnf clean all ENV QT_QPA_PLATFORM=offscreen WORKDIR /source diff --git a/image/build b/image/build index b933504e..de017a85 100755 --- a/image/build +++ b/image/build @@ -10,13 +10,13 @@ import signal import socket import subprocess import sys -import tarfile 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" @@ -29,26 +29,7 @@ def run(args, **kwargs): def source_archive(destination, additions=None): - roots = ["image", "roles/desktop", "assets/scripts", "assets/EDM115-newline2.omp.json", - "assets/desktop-contract.json", "assets/wallpapers", "assets/PROVENANCE.json", - "roles/boot/files", "roles/boot/defaults/main.yml", "inventory/group_vars/all.yml", "VERSION", "LICENSE", - "roles/dotfiles", "roles/apps", "roles/base", "roles/xps-2026", - "agent-skills/cybexos", "scripts/manage-agent-skills", - "assets/nautilus-localsend.py"] - excluded = {"image/update-channel.json", "image/update-key.asc", "image/build-provenance.json"} - with tarfile.open(destination, "w:gz") as archive: - for name in roots: - path = ROOT / name - if not path.exists(): - raise FileNotFoundError(f"Missing image source input: {name}") - for item in sorted(path.rglob("*")) if path.is_dir() else [path]: - relative = str(item.relative_to(ROOT)) - if item.is_file() and not item.is_symlink() and relative not in excluded and "__pycache__" not in item.parts and item.suffix != ".pyc": - archive.add(item, arcname=relative, recursive=False) - for name, path in (additions or {}).items(): - if name not in excluded: - raise ValueError("Unexpected generated source input") - archive.add(path, arcname=name, recursive=False) + write_archive(ROOT, destination, additions) def stop(process): @@ -116,9 +97,12 @@ def main(): 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] @@ -182,6 +166,7 @@ def main(): run(["scp", *ssh_options, "-P", str(port), "-r", "builder@127.0.0.1:/home/builder/artifacts/.", staging]) from build_support import checksum_entries checksum_entries(staging) + shutil.copyfile(work / "source.tar.gz", Path(staging) / "source.tar.gz") original = Path(staging) / "CybexOS-Live-44.iso" original.rename(Path(staging) / f"CybexOS-Live-44-{identifier}.iso") files = sorted(path for path in Path(staging).iterdir() if path.name != "SHA256SUMS") diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec index 45aebeee..aa5c3258 100644 --- a/image/cybexos-desktop.spec +++ b/image/cybexos-desktop.spec @@ -11,6 +11,7 @@ License: MIT AND LicenseRef-CybexOS-Bundled-Components URL: https://github.com/DigitalPals/CybexOS Source0: desktop.tar 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} @@ -35,6 +36,7 @@ Requires: ImageMagick tesseract tesseract-langpack-eng btop matugen 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 %description CybexOS (Cybex Opinionated System) is a Hyprland and Quickshell desktop for Fedora. @@ -70,6 +72,7 @@ cp -a usr opt etc %{buildroot}/ /usr/bin/cybexos-* /usr/bin/hyprland-quickshell /usr/libexec/cybexos-* +/usr/libexec/cybex_hermes/ /usr/lib/systemd/user/*.service /usr/lib/systemd/user/hypridle.service.d/ /usr/lib/systemd/user/voxtype.service.d/ @@ -80,6 +83,7 @@ cp -a usr opt etc %{buildroot}/ /usr/share/wayland-sessions/hyprland-quickshell.desktop /usr/share/fonts/cybexos/ /usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override +/usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override /usr/share/licenses/cybexos-fonts/ /usr/share/plymouth/themes/cybex/ /usr/lib/sysctl.d/60-cybexos-hardening.conf @@ -88,6 +92,10 @@ cp -a usr opt etc %{buildroot}/ /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-reconcile.service /usr/lib/systemd/system/cybexos-reconcile.timer /usr/lib/systemd/system/cybexos-hardware-setup.service @@ -107,6 +115,8 @@ if [ "$1" -eq 1 ]; then 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 || : %changelog diff --git a/image/desktop_payload.py b/image/desktop_payload.py index 9ff908a5..f0f314b4 100644 --- a/image/desktop_payload.py +++ b/image/desktop_payload.py @@ -39,9 +39,8 @@ def prepare_defaults(root, payload, environment, inventory): return contract -# blockinfile's rendering of the workstation's managed Kitty include -# (roles/dotfiles/tasks/personal.yml), so provisioning a seeded account finds -# its block already present instead of appending a second one. +# 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" diff --git a/image/github_release.py b/image/github_release.py index 2bd3d326..7e5644cf 100644 --- a/image/github_release.py +++ b/image/github_release.py @@ -189,6 +189,17 @@ def verify_qualifications(paths, iso_digest, packages): 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 'graphical-installer' in checks 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 (prior != iso_digest and report.get('candidate_rpm_sha256') in candidates and {'installed-rpm-upgrade', 'recovery-boot-restore'} <= set(checks)): diff --git a/image/installed_outcomes.py b/image/installed_outcomes.py new file mode 100644 index 00000000..54c2d5f2 --- /dev/null +++ b/image/installed_outcomes.py @@ -0,0 +1,231 @@ +"""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') +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', '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 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()) + flatpaks = sorted(command(['flatpak', 'list', '--system', '--app', '--columns=application,branch,commit']).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 = {} + for name in USER_UNITS: + # Static units are pulled into the session target; compare activation too. + user_units[name] = {'enabled': user_command(['systemctl', '--user', 'is-enabled', name], accepted=(0, 1, 3, 4)), + 'active': user_command(['systemctl', '--user', 'is-active', name], accepted=(0, 1, 3, 4))} + 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': {key: config.get(key) for key in CHOICES}, + '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']), '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') + unreviewed = [package for package in delta if 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/library/cybexos_managed_file.py b/image/library/cybexos_managed_file.py index 9a972865..0923bbc0 100644 --- a/image/library/cybexos_managed_file.py +++ b/image/library/cybexos_managed_file.py @@ -1,5 +1,6 @@ #!/usr/bin/python3 """Maintain vendor defaults only while their bytes still match our last write.""" +import fcntl import hashlib import json import os @@ -20,7 +21,19 @@ def atomic(path, data, mode=0o600): Path(temporary).unlink(missing_ok=True) -def manage(destination, content, ledger, absent=False, mode=0o644, check=False): +def manage(destination, content, ledger, absent=False, mode=0o644, check=False, baseline=None): + 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 @@ -39,6 +52,8 @@ def manage(destination, content, ledger, absent=False, mode=0o644, check=False): 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. @@ -71,14 +86,18 @@ def main(): 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'}, }, supports_check_mode=True) try: result = manage(module.params['dest'], module.params['content'].encode(), - '/var/lib/cybexos/reconcile/managed-files.json', + module.params['ledger'], absent=module.params['state'] == 'absent', - mode=int(module.params['mode'], 8), check=module.check_mode) + mode=int(module.params['mode'], 8), check=module.check_mode, + baseline=(module.params['baseline'].encode() + if module.params['baseline'] is not None else None)) except (OSError, ValueError) as error: module.fail_json(msg=str(error)) module.exit_json(**result) diff --git a/image/library/cybexos_user_include.py b/image/library/cybexos_user_include.py new file mode 100644 index 00000000..5e81a664 --- /dev/null +++ b/image/library/cybexos_user_include.py @@ -0,0 +1,129 @@ +#!/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/package b/image/package index 17b8de36..51cb5a63 100755 --- a/image/package +++ b/image/package @@ -65,6 +65,7 @@ def main(): runtime = "usr/share/cybexos/runtime" copy("LICENSE", "usr/share/licenses/cybexos-desktop/LICENSE") + copy("release-manifest.json", "usr/share/cybexos/release-manifest.json") quickshell = ROOT / "roles/desktop/files/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": @@ -74,7 +75,7 @@ def main(): if source.suffix in (".qml", ".js"): target = payload / relative target.write_text(target.read_text().replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - for name in ("hyprland.lua", "bindings.lua", "autostart.lua", "displays.lua"): + for name in ("hyprland.lua", "bindings.lua", "autostart.lua", "displays.lua", "input_preferences.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) @@ -126,11 +127,27 @@ def main(): 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) + 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-")) # `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") @@ -174,6 +191,9 @@ def main(): 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") # The fonts are already checksum-pinned by the project. Preserve upstream diff --git a/image/parity-exceptions.json b/image/parity-exceptions.json new file mode 100644 index 00000000..4967e217 --- /dev/null +++ b/image/parity-exceptions.json @@ -0,0 +1,9 @@ +{ + "iso": { + "cybexos-desktop.x86_64": "The ISO delivers the shared desktop payload as an RPM; checkout installs the same source through Ansible." + }, + "checkout": { + "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 new file mode 100644 index 00000000..1214644c --- /dev/null +++ b/image/parity_qualification.py @@ -0,0 +1,59 @@ +"""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/provision_payload.py b/image/provision_payload.py index a1c476a9..888e0e46 100644 --- a/image/provision_payload.py +++ b/image/provision_payload.py @@ -11,7 +11,8 @@ def prepare_provision(root, payload): '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/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 diff --git a/image/qualification.py b/image/qualification.py index eeaff41f..d0519ca6 100644 --- a/image/qualification.py +++ b/image/qualification.py @@ -11,6 +11,8 @@ from build_support import atomic_json, digest from browser_qualification import browser_dependencies, qualify_browser from login_qualification import qualify_login +from installed_outcomes import capture_guest +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 @@ -244,11 +246,14 @@ def main(): 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') 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.recovery_check and not args.candidate_rpm: parser.error('--recovery-check requires --candidate-rpm') + 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'): @@ -341,6 +346,25 @@ def main(): verify_restored(vm, versions['installed'], password, root_script) verify_user_choices(vm, password, root_script, preference) report['checks'].append('recovery-boot-restore') + 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) + root_script(vm, '/usr/libexec/cybexos-configure-installed --user qualification\n', password, timeout=1800) + 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=( diff --git a/image/qualify-checkout b/image/qualify-checkout new file mode 100755 index 00000000..ae1c4465 --- /dev/null +++ b/image/qualify-checkout @@ -0,0 +1,192 @@ +#!/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' + + +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''' +dnf install -y ansible-core git python3-pyyaml firewalld glibc-langpack-en glibc-langpack-nl +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 +systemctl set-default graphical.target +systemctl enable 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) + 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) + 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/release-gate b/image/release-gate index 23422571..720a208b 100755 --- a/image/release-gate +++ b/image/release-gate @@ -87,8 +87,34 @@ def main(): report['testing_iso'] = str(iso) report['testing_iso_bytes'] = iso.stat().st_size for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): - run_child([str(ROOT / 'image/qualify'), str(iso), '--output', str(output / scenario), - '--execute-vm', '--erase-disposable-disk', '--scenario', scenario]) + 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')}) run_child([str(ROOT / 'image/qualify'), str(baseline), '--output', str(output / 'upgrade'), '--execute-vm', '--erase-disposable-disk', '--scenario', 'encrypted-us', '--candidate-rpm', str(rpms[0]), '--recovery-check', '--legacy-installer']) diff --git a/image/rootfs/usr/bin/cybex b/image/rootfs/usr/bin/cybex index 5277b84a..f1b6af6b 100755 --- a/image/rootfs/usr/bin/cybex +++ b/image/rootfs/usr/bin/cybex @@ -6,11 +6,12 @@ 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 "$@" ;; - plugin|dev) exec /usr/share/cybexos/bin/cybexos-runtime "$command_name" "$@" ;; + plugin|dev|shell) exec /usr/share/cybexos/bin/cybexos-runtime "$command_name" "$@" ;; version|--version) exec rpm -q cybexos-desktop ;; verify|doctor) exec /usr/libexec/cybexos-doctor "$@" ;; uninstall) @@ -29,11 +30,13 @@ 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 plugin Install, update, clone, remove, or configure desktop plugins + shell Inspect shell health, enter safe mode, or retry the full desktop dev Select, inspect, or disable a live development checkout version Print the installed CybexOS package version verify Check installed-system health (--json for machine-readable output) diff --git a/image/source_snapshot.py b/image/source_snapshot.py new file mode 100644 index 00000000..eb0188f8 --- /dev/null +++ b/image/source_snapshot.py @@ -0,0 +1,64 @@ +"""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/test_display_policy.py b/image/test_display_policy.py new file mode 100644 index 00000000..a6278000 --- /dev/null +++ b/image/test_display_policy.py @@ -0,0 +1,128 @@ +"""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_github_release.py b/image/test_github_release.py index 3b111183..1df66ef5 100644 --- a/image/test_github_release.py +++ b/image/test_github_release.py @@ -80,12 +80,31 @@ def save_manifest(self): def report(self, scenario, iso, checks, rpm=None): path = self.root / (scenario + '.json') - path.write_text(json.dumps({'scenario': scenario, 'iso_sha256': iso, - 'status': 'passed', 'checks': checks, - 'candidate_rpm_sha256': rpm})) + report = {'scenario': scenario, 'iso_sha256': iso, 'status': 'passed', 'checks': checks, + '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. diff --git a/image/test_installed_outcomes.py b/image/test_installed_outcomes.py new file mode 100644 index 00000000..f68c503f --- /dev/null +++ b/image/test_installed_outcomes.py @@ -0,0 +1,173 @@ +"""Fixtures for the real VM gate; these deliberately do not claim VM execution.""" +import importlib.machinery +import importlib.util +import io +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') + return result + + +class OutcomeComparison(unittest.TestCase): + 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'): + changed = fixture('checkout') + changed[key]['difference'] = False + 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']) + + +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_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.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_release_gate.py b/image/test_release_gate.py index b194f578..c63b561c 100644 --- a/image/test_release_gate.py +++ b/image/test_release_gate.py @@ -41,6 +41,24 @@ def run(command): (artifacts / 'cybexos-desktop-1.2.3.rpm').write_bytes(b'RPM') if command[0].endswith('/image/publish-pxe'): (pxe / 'iso/candidate.iso').write_bytes(b'candidate') + if command[0].endswith('/image/qualify') and '--capture-outcomes' in command: + destination = Path(command[command.index('--output') + 1]) + destination.mkdir() + scenario = command[command.index('--scenario') + 1] + (destination / 'qualification.json').write_text(json.dumps({ + 'status': 'passed', 'scenario': scenario, 'source_revision': 'a' * 40, + 'source_content_sha256': 'b' * 64, 'checks': ['graphical-installer']})) + if command[0].endswith('/image/qualify-checkout'): + destination = Path(command[command.index('--output') + 1]) + destination.mkdir() + scenario = command[command.index('--scenario') + 1] + (destination / 'checkout-qualification.json').write_text(json.dumps({ + 'status': 'passed', 'scenario': scenario, 'source_revision': 'a' * 40, + 'source_content_sha256': 'b' * 64, 'checks': ['full-checkout-installation', + 'installed-parity-fresh', 'installed-parity-saved', 'full-graphical-session']})) + for profile in ('fresh', 'saved'): + (destination / ('parity-' + profile + '.json')).write_text(json.dumps({ + 'status': 'passed', 'scenario': scenario, 'profile': profile, 'source_content_sha256': 'b' * 64})) return checkout, baseline, output, paths, run, commands def invoke(self, root, same_iso=False): @@ -77,6 +95,13 @@ def test_required_matrix_and_prior_rpm_upgrade_recovery_are_invoked(self): self.assertIn('--candidate-rpm', upgrade) self.assertIn('--recovery-check', upgrade) self.assertIn('--legacy-installer', upgrade) + checkout = [command for command in commands if command[0].endswith('/image/qualify-checkout')] + self.assertEqual(len(checkout), 2) + self.assertEqual([command[command.index('--scenario') + 1] for command in checkout], ['plain-us', 'plain-nl']) + for scenario in ('plain-us', 'plain-nl'): + qualified = json.loads((output / scenario / 'qualification.json').read_text()) + self.assertIn('installed-parity-saved', qualified['checks']) + self.assertEqual(qualified['checkout_parity']['status'], 'passed') self.assertEqual(json.loads((output / 'release-gate.json').read_text())['status'], 'passed') def test_interruption_terminates_owned_process_group_before_returning(self): diff --git a/image/test_session_start.py b/image/test_session_start.py new file mode 100644 index 00000000..11b44ef1 --- /dev/null +++ b/image/test_session_start.py @@ -0,0 +1,12 @@ +"""Run the shared session startup regressions in the image source gate too.""" +import importlib.machinery +import importlib.util +from pathlib import Path + + +source = Path(__file__).resolve().parents[1] / 'tests/session-launcher.py' +loader = importlib.machinery.SourceFileLoader('session_start_fixtures', str(source)) +spec = importlib.util.spec_from_loader(loader.name, loader) +fixtures = importlib.util.module_from_spec(spec) +loader.exec_module(fixtures) +SessionAutostartTests = fixtures.SessionAutostartTests diff --git a/image/test_update_recovery.py b/image/test_update_recovery.py new file mode 100644 index 00000000..72b8bf45 --- /dev/null +++ b/image/test_update_recovery.py @@ -0,0 +1,97 @@ +"""Compare deployed recovery files from real Ansible and RPM assembly logic.""" +import ast +import copy +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +class RecoveryParity(unittest.TestCase): + def test_both_installers_deploy_the_same_recovery_protocol_and_login_barrier(self): + names = { + 'Install transactional Btrfs recovery helper', + 'Install the durable update transaction helpers', + 'Install the interrupted-update boot recovery unit', + 'Create the login recovery dependency directories', + 'Block login if interrupted-update recovery fails', + 'Install Fedora upgrade validation units', + } + tasks = [copy.deepcopy(task) for task in yaml.safe_load( + (ROOT / 'roles/base/tasks/main.yml').read_text()) if task.get('name') in names] + self.assertEqual({task['name'] for task in tasks}, names) + with tempfile.TemporaryDirectory(prefix='cybexos-recovery-parity-') as temporary: + directory = Path(temporary) + checkout, payload = directory / 'checkout', directory / 'rpm' + for relative in ('usr/local/libexec', 'etc/systemd/system'): + (checkout / relative).mkdir(parents=True) + for task in tasks: + task['become'] = False + options = task.get('ansible.builtin.copy') or task['ansible.builtin.file'] + for key in ('owner', 'group'): + options.pop(key, None) + destination = 'dest' if 'dest' in options else 'path' + options[destination] = '{{ fixture_root }}' + options[destination] + if 'src' in options: + options['src'] = str(ROOT / 'roles/base/files') + '/' + options['src'] + playbook = directory / 'recovery.yml' + playbook.write_text(yaml.safe_dump([{ + 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, + 'vars': {'fixture_root': str(checkout)}, 'tasks': tasks, + }])) + result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + # Execute the real contiguous recovery assembly section; unrelated + # application downloads and privileged image construction stay out + # of this disposable-filesystem contract test. + main = next(node for node in ast.parse((ROOT / 'image/package').read_text()).body + if isinstance(node, ast.FunctionDef) and node.name == 'main') + start = next(index for index, node in enumerate(main.body) + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) and node.value.func.id == 'copy' + and node.value.args and isinstance(node.value.args[0], ast.Constant) + and node.value.args[0].value == 'roles/base/files/cybexos-system-snapshot') + end = next(index for index, node in enumerate(main.body) + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) + and node.value.value == 'roles/base/files/recovery') + + def write(relative, content, executable=False): + target = payload / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + target.chmod(0o755 if executable else 0o644) + + def package_copy(source, relative, executable=False): + write(relative, (ROOT / source).read_text(), executable) + + exec(compile(ast.Module(body=main.body[start:end], type_ignores=[]), + str(ROOT / 'image/package'), 'exec'), + {'ROOT': ROOT, 'payload': payload, 'write': write, 'copy': package_copy}) + source_files = sorted(path for path in checkout.rglob('*') if path.is_file()) + self.assertGreaterEqual(len(source_files), 13) + for source in source_files: + relative = source.relative_to(checkout).as_posix() + installed = relative.replace('usr/local/libexec/', 'usr/libexec/').replace( + 'etc/systemd/system/', 'usr/lib/systemd/system/') + expected = source.read_text() + if relative.startswith('etc/systemd/system/'): + expected = expected.replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-') + self.assertEqual((payload / installed).read_text(), expected, relative) + self.assertEqual(os.access(source, os.X_OK), os.access(payload / installed, os.X_OK)) + # Every shipped recovery artifact must be covered by the RPM file + # manifest, including the new user-sessions dependency directory. + spec = (ROOT / 'image/cybexos-desktop.spec').read_text() + self.assertIn('/usr/libexec/cybexos-*', spec) + self.assertIn('/usr/lib/systemd/system/sddm.service.d/', spec) + self.assertIn('/usr/lib/systemd/system/systemd-user-sessions.service.d/60-cybexos-update-recover.conf', spec) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_user_parity.py b/image/test_user_parity.py index d574acd0..21540cb3 100644 --- a/image/test_user_parity.py +++ b/image/test_user_parity.py @@ -1,4 +1,6 @@ """Regressions for per-user workstation policy that ISO installations lacked.""" +import ast +import shutil import configparser import importlib.machinery import importlib.util @@ -58,6 +60,45 @@ def unit(text): class ProvisioningContract(unittest.TestCase): + def test_smb_default_matches_both_paths_and_preserves_user_choice(self): + relative = 'roles/desktop/tasks/main.yml' + install = task(relative, 'Default Files SMB connections to WORKGROUP')['ansible.builtin.copy'] + source = ROOT / 'roles/desktop/files' / install['src'] + destination = install['dest'] + # Resolve the actual RPM copy mapping rather than assuming a shared file. + copies = [node for node in ast.walk(ast.parse((ROOT / 'image/package').read_text())) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == 'copy' and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value == destination.lstrip('/')] + self.assertEqual(len(copies), 1) + packaged_source = ROOT / ast.literal_eval(copies[0].args[0]) + self.assertEqual(packaged_source, source) + self.assertIn(destination, (ROOT / 'image/cybexos-desktop.spec').read_text().splitlines()) + compile_task = task(relative, 'Compile GSettings schemas after changing the SMB default') + self.assertEqual(compile_task['when'], 'desktop_smb_schema_override is changed') + uninstall = (ROOT / 'roles/uninstall/tasks/main.yml').read_text() + self.assertIn(' - ' + destination, uninstall) + self.assertIn("selectattr('item', 'equalto', '" + destination + "')", uninstall) + for override in (source, packaged_source): + with tempfile.TemporaryDirectory(prefix='cybexos-smb-') as temporary: + schemas = Path(temporary) + shutil.copy('/usr/share/glib-2.0/schemas/org.gnome.system.smb.gschema.xml', schemas) + shutil.copy('/usr/share/glib-2.0/schemas/org.gnome.system.gvfs.enums.xml', schemas) + shutil.copy(override, schemas) + subprocess.run(['glib-compile-schemas', '--strict', str(schemas)], check=True) + result = subprocess.run(['/usr/bin/python3', '-c', + "from gi.repository import Gio; " + "s = Gio.Settings.new('org.gnome.system.smb'); " + "assert s.get_string('workgroup') == 'WORKGROUP'; " + "assert s.set_string('workgroup', 'OFFICE'); " + "assert Gio.Settings.new('org.gnome.system.smb').get_string('workgroup') == 'OFFICE'; " + "s.reset('workgroup'); " + "assert s.get_string('workgroup') == 'WORKGROUP'"], + env={**os.environ, 'GSETTINGS_SCHEMA_DIR': str(schemas), + 'GSETTINGS_BACKEND': 'memory'}, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_offline_gtk_task_skips_private_bus_creation(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -218,14 +259,15 @@ def test_seed_uses_the_managed_kitty_fragment_and_include(self): prepare_session(ROOT, self.payload, INVENTORY) kitty = self.vendor / 'user-seed/.config/kitty' self.assertEqual((kitty / 'cybexos.conf').read_bytes(), (ROOT / 'roles/dotfiles/files/kitty.conf').read_bytes()) - # Exactly what blockinfile writes for the workstation task, so repair - # recognizes a seeded kitty.conf instead of appending a second include. + # The shared include editor produces the packaged first-run block. + import importlib.util + spec = importlib.util.spec_from_file_location('include_policy', ROOT / 'image/library/cybexos_user_include.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) include = task('roles/dotfiles/tasks/personal.yml', 'Include the managed Kitty fragment without replacing user configuration') - options = include['ansible.builtin.blockinfile'] - marker = options['marker'] - self.assertEqual(KITTY_INCLUDE, '\n'.join((marker.replace('{mark}', 'BEGIN'), options['block'], - marker.replace('{mark}', 'END'))) + '\n') + self.assertEqual(include['cybexos_user_include']['kind'], 'kitty') + self.assertEqual(KITTY_INCLUDE, module.render('', 'kitty')[0]) self.assertEqual((kitty / 'kitty.conf').read_text(), KITTY_INCLUDE) # The theme integration expects the generated palette to be included # from the fragment, after its fallback colours. diff --git a/image/vm_testing.py b/image/vm_testing.py index 189d5530..c4d642fb 100644 --- a/image/vm_testing.py +++ b/image/vm_testing.py @@ -139,11 +139,13 @@ def stop_with_harness(): class TestVM: """Own exactly one disposable virtual disk; never attach host block devices.""" - def __init__(self, work, firmware="uefi", memory=16384, guard_disk=False): + def __init__(self, work, firmware="uefi", memory=16384, guard_disk=False, network_restricted=True): self.work = validate_qemu_path(Path(work).resolve()) self.firmware = firmware self.memory = memory self.guard_disk = guard_disk + self.network_restricted = network_restricted + self.seed = None self.owned = False self.process = None self.console = None @@ -246,7 +248,7 @@ def _start(self, iso, user): "-drive", f"file={self.disk},format=qcow2,if=none,id=qualification-disk,werror=report,rerror=report", "-device", f"virtio-blk-pci,drive=qualification-disk,serial={QUALIFICATION_DISK_SERIAL}", *firmware, "-device", "virtio-vga", "-device", "qemu-xhci", "-device", "usb-tablet", - "-netdev", f"user,id=net,restrict=on,hostfwd=tcp:127.0.0.1:{self.port}-:22", "-device", "virtio-net-pci,netdev=net", + "-netdev", f"user,id=net,{'restrict=on,' if self.network_restricted else ''}hostfwd=tcp:127.0.0.1:{self.port}-:22", "-device", "virtio-net-pci,netdev=net", "-vnc", f"127.0.0.1:{self.vnc_port - 5900}", "-serial", f"file:{self.work / 'serial.log'}", "-qmp", f"unix:{self.qmp_path},server=on,wait=off", "-monitor", "none"] if self.guard_disk: @@ -254,6 +256,10 @@ def _start(self, iso, user): "-device", f"virtio-blk-pci,drive=unused-disk,serial={QUALIFICATION_UNUSED_SERIAL}"] if iso is not None: args += ["-cdrom", str(require_test_iso(iso)), "-boot", "d"] + if self.seed is not None: + if self.seed.parent != self.work or self.seed.is_symlink() or not self.seed.is_file(): + raise ValueError('Cloud seed must be a task-owned regular file') + args += ["-drive", f"file={self.seed},format=raw,if=virtio,readonly=on"] self.console = (self.work / "qemu.log").open("a") self.process = subprocess.Popen(args, stdout=self.console, stderr=subprocess.STDOUT, process_group=0, preexec_fn=stop_with_harness) diff --git a/roles/base/files/cybexos-major-upgrade b/roles/base/files/cybexos-major-upgrade new file mode 100755 index 00000000..db42dd18 --- /dev/null +++ b/roles/base/files/cybexos-major-upgrade @@ -0,0 +1,717 @@ +#!/usr/bin/python3 +"""Prepare a qualified Fedora upgrade, reboot explicitly, and validate or roll back. + +Target support comes from an explicitly selected compatible source release or +signed desktop RPM, never from incrementing this machine's Fedora version. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import configparser +import fcntl +import grp +import hashlib +import json +import os +from pathlib import Path +import platform +import pwd +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +import yaml + +STATE = Path('/var/lib/cybexos/major-upgrade') +PAYLOADS = Path('/var/lib/cybexos/major-upgrade-payloads') +CONFIG = Path('/etc/cybexos/config.yml') +OFFLINE = Path('/usr/lib/sysimage/libdnf5/offline') +TRIGGER = Path('/system-update') +LIBEXEC = Path(__file__).resolve().parent +TRANSACTION = LIBEXEC / 'cybexos-update-transaction' +SNAPSHOT = LIBEXEC / 'cybexos-system-snapshot' +TERMINAL = {'committed', 'cancelled', 'failed', 'rolled-back'} + + +class Failure(Exception): + pass + + +class Busy(Failure): + pass + + +def run(command, timeout=120, env=None): + environment = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', + 'LANG': 'C.UTF-8', 'LC_ALL': 'C', 'PYTHONDONTWRITEBYTECODE': '1'} + if env: + environment.update(env) + process = subprocess.Popen([str(value) for value in command], env=environment, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + try: + output, error = process.communicate(timeout=timeout) + except BaseException: + # Rollback must not race a grandchild still applying packages/config. + # Every command owns a separate group; never signal unrelated writers. + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.communicate(timeout=10) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.communicate() + # A descendant may close inherited pipes and outlive an exited parent. + # Reaping the parent alone is not proof that its process group is gone. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + raise + if process.returncode: + raise Failure(f'{command[0]} failed: {(error or output).strip()[-1600:]}') + return output.strip() + + +def write(value): + STATE.mkdir(mode=0o700, parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix='.status-', dir=STATE) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, sort_keys=True, indent=2) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, STATE / 'status.json') + descriptor = os.open(STATE, os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + Path(name).unlink(missing_ok=True) + + +def status(): + try: + value = json.loads((STATE / 'status.json').read_text()) + except FileNotFoundError: + return {'v': 1, 'state': 'idle'} + if not isinstance(value, dict) or value.get('v') != 1: + raise Failure('Upgrade status is damaged; inspect the transaction recovery journal') + return value + + +def transaction(action, identifier, *extra): + return json.loads(run([TRANSACTION, action, identifier, *extra], timeout=900)) + + +def boot_id(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +def fedora_release(): + values = dict(line.split('=', 1) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line) + if values.get('ID', '').strip('"') != 'fedora': + raise Failure('Major upgrades require Fedora') + release = values.get('VERSION_ID', '').strip('"') + if not release.isdecimal(): + raise Failure('Cannot identify the installed Fedora release') + return release + + +def digest(path): + result = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def tree_digest(path): + result = hashlib.sha256() + if (path / '.git').exists(): + names = run(['git', '-c', 'safe.directory=' + str(path), '-C', str(path), 'ls-files', '-z']) + items = [path / name for name in names.split('\0') if name] + else: + items = path.rglob('*') + for item in sorted(items): + relative = item.relative_to(path) + if '.git' in relative.parts or '__pycache__' in relative.parts or item.suffix == '.pyc': + continue + if item.is_symlink(): + link = os.readlink(item) + # Executable source cannot escape the frozen reviewed tree. + if not item.resolve().is_relative_to(path.resolve()): + raise Failure(f'Source symlink escapes its tree: {relative}') + value = 'link:' + link + elif item.is_file(): + value = digest(item) + ':' + str(bool(item.stat().st_mode & 0o111)) + elif item.is_dir(): + continue + else: + raise Failure(f'Unsupported source file: {relative}') + result.update((str(relative) + '\0' + value + '\0').encode()) + return result.hexdigest() + + +def compatible(manifest, target, architecture, schema): + if (not isinstance(manifest, dict) or manifest.get('product') != 'cybexos' + or target not in manifest.get('supportedFedora', []) + or architecture not in manifest.get('architectures', []) + or manifest.get('configSchema') != schema): + raise Failure(f'The selected release does not support Fedora {target}, {architecture}, and this saved configuration schema') + + +def inspect_target(target, source=None, rpm=None): + saved = yaml.safe_load(CONFIG.read_text()) + if not isinstance(saved, dict): + raise Failure('A saved CybexOS installation configuration is required') + architecture = platform.machine() + if source: + source = Path(source).resolve(strict=True) + manifest = json.loads((source / 'release-manifest.json').read_text()) + compatible(manifest, target, architecture, saved.get('config_schema_version')) + inventory = yaml.safe_load((source / 'inventory/group_vars/all.yml').read_text()) + if str(inventory.get('fedora_release')) != target: + raise Failure('Source inventory and target release manifest disagree') + for name in ('site.yml', 'ansible.cfg', 'inventory/hosts.yml', 'verify'): + if not (source / name).is_file(): + raise Failure(f'The selected source release is incomplete: {name}') + revision = '' + if (source / '.git').exists(): + # The administrator explicitly selects this source. Its clean + # commit is frozen, not pulled or switched during an upgrade. + prefix = ['git', '-c', 'safe.directory=' + str(source), '-C', str(source)] + if run([*prefix, 'status', '--porcelain', '--untracked-files=normal']): + raise Failure('The target checkout has local changes; select a clean reviewed release') + revision = run([*prefix, 'rev-parse', 'HEAD']) + return {'kind': 'source', 'path': str(source), 'digest': tree_digest(source), + 'revision': revision, 'manifest': manifest} + if rpm: + rpm = Path(rpm).resolve(strict=True) + signature = run(['rpmkeys', '--checksig', str(rpm)]) + if 'signatures OK' not in signature or any(word in signature for word in ('NOKEY', 'NOT OK', 'NOTTRUSTED')): + raise Failure('The target desktop RPM must have a signature verified by the installed trusted keyring') + identity = run(['rpm', '-qp', '--qf', '%{NAME}\n%{RELEASE}\n%{ARCH}\n%{VERSION}', str(rpm)]).splitlines() + if (len(identity) != 4 or identity[0] != 'cybexos-desktop' + or not re.search(r'\.fc' + re.escape(target) + r'(?:\.|$)', identity[1]) + or identity[2] != architecture): + raise Failure('The signed RPM is not a desktop release for the target Fedora and architecture') + # An explicit signed capability avoids equating a dist tag with a + # qualification claim. Older packages without it cannot opt in. + provides = run(['rpm', '-qp', '--provides', str(rpm)]).splitlines() + if f'cybexos-supported-fedora = {target}' not in provides: + raise Failure('The signed desktop RPM does not declare support for the target Fedora release') + return {'kind': 'rpm', 'path': str(rpm), 'digest': digest(rpm), 'identity': identity} + raise Failure('Select a compatible reviewed --source directory or signed --rpm; no next release is assumed supported') + + +def verify_backup(manifest_path, account): + if not manifest_path: + raise Failure('Provide --backup with a recent off-disk backup manifest; a root snapshot does not back up personal data') + path = Path(manifest_path).resolve(strict=True) + manifest = json.loads(path.read_text()) + if manifest.get('v') != 1 or not isinstance(manifest.get('archives'), list): + raise Failure('Backup manifest needs v=1, createdAt and an archives list') + created = datetime.fromisoformat(manifest.get('createdAt', '').replace('Z', '+00:00')) + if created.tzinfo is None or not 0 <= time.time() - created.timestamp() <= 7 * 86400: + raise Failure('Verify a backup made within the last seven days') + root_device = run(['findmnt', '-n', '-o', 'UUID', '-T', '/']) + backup_device = run(['findmnt', '-n', '-o', 'UUID', '-T', str(path)]) + if not root_device or not backup_device or root_device == backup_device: + raise Failure('The backup must be on a different filesystem with a verifiable UUID') + required = {str(Path(account.pw_dir)), '/etc'} + for name in ('/var/lib/xps-hardware', '/etc/pki/akmods'): + if Path(name).exists(): + required.add(name) + covered = set() + receipts = [] + for archive in manifest['archives']: + relative = Path(archive.get('path', '')) + if relative.is_absolute() or '..' in relative.parts or not relative.parts: + raise Failure('Backup archive paths must stay beside their manifest') + file = path.parent / relative + if file.is_symlink() or not file.is_file() or digest(file) != archive.get('sha256'): + raise Failure(f'Backup checksum verification failed: {relative}') + # Read the entire archive through tar's parser; no archive member is + # extracted or trusted as executable code. + listing = run(['tar', '-tf', str(file)], timeout=3600).splitlines() + members = {('/' + entry.removeprefix('./').lstrip('/')).rstrip('/') for entry in listing} + for scope in archive.get('covers', []): + if scope in members and any(member.startswith(scope.rstrip('/') + '/') for member in members): + covered.add(scope) + receipts.append({'path': str(file), 'sha256': archive['sha256']}) + if required - covered: + raise Failure('Backup archives do not cover: ' + ', '.join(sorted(required - covered))) + return {'manifest': str(path), 'sha256': digest(path), 'archives': receipts, + 'verifiedAt': datetime.now(timezone.utc).isoformat()} + + +def offline_digest(): + if not OFFLINE.is_dir(): + return '' + entries = [p for p in sorted(OFFLINE.rglob('*')) if p.is_file() and p.suffix != '.rpm'] + if not entries: + return '' + total = hashlib.sha256() + for item in entries: + if item.is_symlink() or item.stat().st_size > 64 * 1024 * 1024: + raise Failure('Unexpected offline transaction metadata') + total.update((str(item.relative_to(OFFLINE)) + '\0' + digest(item)).encode()) + return total.hexdigest() + + +def check_repositories(current, kind): + repos = [] + for file in sorted(Path('/etc/yum.repos.d').glob('*.repo')): + parser = configparser.ConfigParser(interpolation=None) + parser.read(file) + for name in parser.sections(): + section = parser[name] + if not section.getboolean('enabled', fallback=True): + continue + if kind == 'rpm' and name == 'cybexos-desktop': + continue # The explicitly signed staged RPM replaces it after boot. + if not section.getboolean('gpgcheck', fallback=True): + raise Failure(f'Repository {name} disables signature verification') + urls = ' '.join(section.get(key, '') for key in ('baseurl', 'metalink', 'mirrorlist')) + if re.search(r'(?:/|fc)' + re.escape(current) + r'(?:/|\b)', urls): + raise Failure(f'Repository {name} pins Fedora {current}; provide a reviewed target-compatible repository before preparing') + repos.append(name) + if not repos: + raise Failure('No verified target-capable Fedora repositories are enabled') + return repos + + +def preflight(target, candidate, backup, uid): + current = fedora_release() + if not target.isdecimal() or not 1 <= int(target) - int(current) <= 2: + raise Failure('Select a newer Fedora release, at most two releases ahead') + account = pwd.getpwuid(uid) + if uid < 1000 or uid >= 65534: + raise Failure('Select the installed desktop account with --uid') + if TRIGGER.exists() or TRIGGER.is_symlink() or offline_digest(): + raise Failure('Another offline transaction exists; finish or cancel it before preparing a major upgrade') + for path, minimum in (('/', 15 * 1024**3), ('/var', 15 * 1024**3), ('/boot', 512 * 1024**2)): + if shutil.disk_usage(path).free < minimum: + raise Failure(f'{path} needs at least {minimum // 1024**2} MiB free for the upgrade and recovery') + latest = run(['rpm', '-q', '--qf', '%{VERSION}-%{RELEASE}.%{ARCH}\n', 'kernel-core']).splitlines() + if platform.release() not in latest: + raise Failure('Boot an installed Fedora kernel before upgrading') + # `grubby --default-kernel` names the latest/default boot entry without + # making lexical version ordering assumptions. + if Path(run(['grubby', '--default-kernel'])).name != 'vmlinuz-' + platform.release(): + raise Failure('Reboot into the default updated kernel before upgrading') + for marker in ('/var/lib/xps-hardware/ipu7/reboot-required', '/var/run/reboot-required'): + if Path(marker).exists(): + raise Failure(f'A pending hardware/system reboot must be resolved first: {marker}') + recovery = json.loads(run([SNAPSHOT, 'list', '--json'])) + if not recovery.get('supported') or recovery.get('recoveryBoot') or recovery.get('pendingReboot'): + raise Failure('Major upgrades require the managed Btrfs layout booted into its normal root') + hardware = [] + for name in ('xps-ipu7-abi-check', 'xps-ipu7-userspace-check'): + helper = Path('/usr/local/libexec') / name + if helper.exists(): + run([helper, platform.release()] if name.endswith('abi-check') else [helper], timeout=120) + hardware.append(str(helper)) + run(['rpm', '--verifydb']) + run(['dnf5', 'check'], timeout=600) + try: + run(['dnf5', '--refresh', 'check-upgrade'], timeout=600) + except Failure as error: + raise Failure('Finish current-release updates and resolve repository errors before upgrading: ' + str(error)) from None + run(['dnf5', 'system-upgrade', 'download', '--help']) + secure_boot = run(['mokutil', '--sb-state']) if Path('/sys/firmware/efi').exists() else 'legacy boot' + # Existing camera signing health is a baseline, not a promise that the + # next kernel works. A qualified target is still required. + if 'SecureBoot enabled' in secure_boot and Path('/etc/pki/akmods/certs/public_key.der').exists(): + run(['mokutil', '--test-key', '/etc/pki/akmods/certs/public_key.der']) + return {'currentFedora': current, 'targetFedora': target, 'uid': uid, + 'home': account.pw_dir, 'kernel': platform.release(), 'secureBoot': secure_boot, + 'hardwareChecks': hardware, + 'repositories': check_repositories(current, candidate['kind']), + 'backup': verify_backup(backup, account)} + + +@contextmanager +def operation_lock(uid=None, wait=False): + STATE.mkdir(mode=0o700, parents=True, exist_ok=True) + with (STATE / 'lock').open('a') as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB)) + except BlockingIOError: + raise Busy('Another major-upgrade operation is running; do not interrupt its DNF download') from None + # Cooperate with install/uninstall/the ordinary updater for this user. + shared = None + if uid is not None: + runtime = Path(f'/run/user/{uid}') + if runtime.is_dir(): + lock_path = runtime / 'update.lock' + try: + descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + os.fchown(descriptor, uid, pwd.getpwuid(uid).pw_gid) + except FileExistsError: + descriptor = os.open(lock_path, os.O_RDWR | os.O_NOFOLLOW) + shared = os.fdopen(descriptor, 'a') + try: + fcntl.flock(shared, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB)) + except BlockingIOError: + shared.close() + raise Busy('Another CybexOS install/update operation is running') from None + try: + yield + finally: + if shared: + shared.close() + + +def prepare(args): + previous = status() + if previous['state'] not in TERMINAL | {'idle'}: + raise Failure('An existing major upgrade needs completion or cancellation first') + candidate = inspect_target(args.target, args.source, args.rpm) + baseline = preflight(args.target, candidate, args.backup, args.uid) + identifier = 'major-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8] + PAYLOADS.mkdir(mode=0o755, parents=True, exist_ok=True) + os.chmod(PAYLOADS, 0o755) + staging = PAYLOADS / identifier + staging.mkdir(mode=0o755) + os.chmod(staging, 0o755) + record = {'v': 1, 'id': identifier, 'state': 'preparing', 'bootId': boot_id(), + 'candidate': candidate, **baseline, 'rebootRequested': False} + write(record) + begun = False + try: + source = Path(candidate['path']) + if candidate['kind'] == 'source': + staged = staging / 'source' + if (source / '.git').exists(): + staged.mkdir(mode=0o755) + run(['git', '-c', 'safe.directory=' + str(source), '-C', str(source), + 'checkout-index', '--all', '--prefix=' + str(staged) + '/']) + else: + shutil.copytree(source, staged, symlinks=True, + ignore=shutil.ignore_patterns('__pycache__', '*.pyc')) + for item in [staged, *staged.rglob('*')]: + if not item.is_symlink(): + item.chmod(0o755 if item.is_dir() or item.stat().st_mode & 0o111 else 0o644) + if tree_digest(staged) != candidate['digest']: + raise Failure('The source changed while it was staged') + else: + staged = staging / 'desktop.rpm' + shutil.copyfile(source, staged) + if digest(staged) != candidate['digest']: + raise Failure('The RPM changed while it was staged') + inspect_target(args.target, rpm=staged) + candidate['path'] = str(staged) + write(record) + point = run([SNAPSHOT, 'create', f'Before Fedora {args.target} major upgrade'], timeout=900).splitlines()[-1] + transaction('begin', identifier, point, '--uid', str(args.uid)) + begun = True + record['downloadUnit'] = 'cybexos-major-download-' + identifier + record['state'] = 'downloading' + write(record) + run(['systemd-run', '--unit=' + record['downloadUnit'], '--collect', + '--property=Type=exec', '--property=RuntimeMaxSec=3h', + '--property=KillMode=control-group', '--property=UMask=0077', + str(Path(__file__).resolve()), 'download', identifier]) + return record + except BaseException as error: + record.update(state='failed', error=str(error)) + write(record) + if begun: + transaction('abort', identifier) + shutil.rmtree(staging) + raise + + +def download(identifier): + record = status() + if record.get('id') != identifier or record['state'] != 'downloading': + raise Failure('Download request no longer matches the prepared major upgrade') + owns_download = False + try: + if offline_digest() or TRIGGER.exists() or TRIGGER.is_symlink(): + raise Failure('Another offline transaction appeared before the download began') + owns_download = True + command = ['dnf5', '--assumeyes', '--refresh', '--releasever=' + record['targetFedora'], + '--setopt=*.skip_if_unavailable=false', '--setopt=gpgcheck=true'] + if record['candidate']['kind'] == 'rpm': + command += ['--disable-repo=cybexos-desktop'] + run([*command, 'system-upgrade', 'download'], timeout=7200) + record['offlineDigest'] = offline_digest() + if not record['offlineDigest']: + raise Failure('DNF did not publish an offline transaction') + record['state'] = 'ready' + transaction('arm-upgrade', identifier, '--metadata', json.dumps(record)) + write(record) + return record + except BaseException as error: + # Retain failed-download diagnostics and payload until an explicit + # cancel. Never cancel a still-running DNF or an unknown transaction. + record.update(state='download-failed' if owns_download else 'failed', error=str(error), + offlineDigest=offline_digest() if owns_download else '') + write(record) + if not owns_download: + transaction('abort', identifier) + shutil.rmtree(PAYLOADS / identifier) + raise + + +def ensure_ours(record): + if record.get('state') not in {'ready', 'download-failed'} or record.get('rebootRequested'): + raise Failure('The offline upgrade is already scheduled or has started; cancellation is no longer safe') + if TRIGGER.exists() or TRIGGER.is_symlink(): + raise Failure('An offline reboot is already scheduled; do not cancel or interrupt it') + if record.get('bootId') != boot_id() and fedora_release() != record.get('currentFedora'): + raise Failure('The installed Fedora release changed since preparation; inspect recovery before proceeding') + if offline_digest() != record.get('offlineDigest', ''): + raise Failure('The DNF offline transaction changed; refusing to cancel or reboot an unrecognized transaction') + + +def cancel(): + record = status() + ensure_ours(record) + journal = transaction('status', record['id']) + if journal['state'] not in {'prepared', 'awaiting-upgrade', 'aborted'}: + raise Failure('The upgrade checkpoint has begun applying; inspect recovery before cancelling') + run(['dnf5', 'offline', 'clean']) + # Boot recovery can already abort a prepared checkpoint after a failed + # download. Its unchanged cached metadata can still be explicitly cleaned. + if journal['state'] != 'aborted': + transaction('abort', record['id']) + record['state'] = 'cancelled' + write(record) + shutil.rmtree(PAYLOADS / record['id']) + return record + + +def reboot(): + record = status() + if record['state'] == 'rolled-back': + run(['systemctl', '--no-block', 'reboot']) + return record + ensure_ours(record) + if record['state'] != 'ready' or not record.get('offlineDigest'): + raise Failure('The complete offline download must be ready before rebooting') + # A normal reboot before scheduling the offline upgrade is harmless. The + # next boot boundary must refer to the explicit offline reboot, otherwise + # the timer could start convergence while this old-release boot is active. + record['bootId'] = boot_id() + record['rebootRequested'] = True + write(record) + try: + run(['dnf5', 'offline', 'reboot']) + except BaseException: + if not TRIGGER.exists() and not TRIGGER.is_symlink(): + record['rebootRequested'] = False + write(record) + raise + return record + + +def converge(record): + candidate = record['candidate'] + path = Path(candidate['path']) + actual = tree_digest(path) if candidate['kind'] == 'source' else digest(path) + if actual != candidate['digest']: + raise Failure('The staged upgrade payload changed after preparation') + account = pwd.getpwuid(record['uid']) + if candidate['kind'] == 'source': + env = {'ANSIBLE_CONFIG': str(path / 'ansible.cfg'), + 'ANSIBLE_ROLES_PATH': str(path / 'roles'), + 'ANSIBLE_FORCE_HANDLERS': 'true'} + values = {'primary_user': account.pw_name, 'primary_home': account.pw_dir, + 'primary_group': grp.getgrgid(account.pw_gid).gr_name, + 'config_repo': str(path)} + run(['ansible-playbook', '-i', path / 'inventory/hosts.yml', path / 'site.yml', + '--extra-vars', '@' + str(CONFIG), '--extra-vars', json.dumps(values)], timeout=3600, env=env) + # Keep the qualified source as the active release; do not leave managed + # scripts referring to an input checkout that the user may delete. + record['retainedSource'] = str(path) + else: + inspect_target(record['targetFedora'], rpm=path) + run(['dnf5', '--assumeyes', '--setopt=gpgcheck=true', '--setopt=localpkg_gpgcheck=true', + 'install', path], timeout=1800) + run(['/usr/libexec/cybexos-reconcile', '--retry'], timeout=1200) + reconciled = json.loads(run(['/usr/libexec/cybexos-reconcile', '--status'])) + accounts = reconciled.get('accounts') + desired = reconciled.get('desiredVersion') + if (reconciled.get('pending') is not False or reconciled.get('state') != 'ready' + or not desired or reconciled.get('version') != desired + or not isinstance(accounts, dict) or not accounts + or any(not isinstance(entry, dict) or entry.get('state') != 'ready' + or entry.get('version') != desired for entry in accounts.values()) + or accounts.get(account.pw_name, {}).get('uid') != account.pw_uid): + raise Failure('The target desktop package has not finished account reconciliation') + + +def finish_committed(record): + """Retry bookkeeping without crossing a durable transaction commit.""" + record['state'] = 'committed' + packaged = record['candidate']['kind'] == 'rpm' + if packaged: + record['cleanupPending'] = True + write(record) + if packaged: + try: + shutil.rmtree(PAYLOADS / record['id']) + except FileNotFoundError: + pass + record.pop('cleanupPending', None) + write(record) + return record + + +def finalize(identifier): + record = status() + if record.get('id') != identifier: + raise Failure('The boot recovery transaction does not match the staged major upgrade') + journal = transaction('status', identifier) + if journal['state'] in {'committed', 'aborted', 'rolled-back'}: + if journal['state'] == 'committed': + return finish_committed(record), 0 + record['state'] = {'aborted': 'cancelled'}.get(journal['state'], journal['state']) + write(record) + return record, 0 + if journal['state'] == 'awaiting-upgrade': + if not record.get('rebootRequested') or record['bootId'] == boot_id(): + return record, 0 + try: + if fedora_release() != record['targetFedora']: + raise Failure('The offline upgrade did not boot the requested Fedora release') + transaction('applying', identifier) + record['state'] = 'converging' + write(record) + converge(record) + if not re.search(r'\.fc' + re.escape(record['targetFedora']) + r'(?:\.|$)', platform.release()): + raise Failure('The running kernel is not from the target Fedora release') + if 'SecureBoot enabled' in record.get('secureBoot', ''): + if 'SecureBoot enabled' not in run(['mokutil', '--sb-state']): + raise Failure('Secure Boot changed during the upgrade') + for helper in record.get('hardwareChecks', []): + run([helper, platform.release()] if helper.endswith('abi-check') else [helper], timeout=120) + transaction('await-desktop', identifier) + record['state'] = 'awaiting-desktop' + write(record) + except BaseException as error: + transaction('rollback', identifier) + record.update(state='rolled-back', error=str(error), restartRequired=True) + write(record) + return record, 75 + elif journal['state'] in {'awaiting-desktop', 'validating'}: + account = pwd.getpwuid(record['uid']) + bus = Path(f'/run/user/{account.pw_uid}/bus') + if not bus.exists(): + return record, 0 + session = ['runuser', '-u', account.pw_name, '--', 'env', + 'XDG_RUNTIME_DIR=' + str(bus.parent), + 'DBUS_SESSION_BUS_ADDRESS=unix:path=' + str(bus), 'systemctl', '--user'] + try: + run([*session, 'is-active', 'hyprland-session.target']) + except Failure: + return record, 0 + try: + # Once the real session started, failure is actionable; do not + # wait forever for a failed shell or report the recovery bar as healthy. + transaction('commit', identifier) + except BaseException as error: + # The command can lose its output after durably committing. Check + # that journal first; if it cannot be read, leave recovery pending + # rather than guessing that a committed update may be reverted. + observed = transaction('status', identifier) + if observed['state'] != 'committed': + transaction('rollback', identifier) + record.update(state='rolled-back', error=str(error), restartRequired=True) + write(record) + return record, 75 + record['validatedBoot'] = boot_id() + # Failure to save status or remove an RPM payload is bookkeeping work, + # not permission to roll back a successfully committed transaction. + return finish_committed(record), 0 + return record, 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + for name in ('check', 'prepare'): + command = commands.add_parser(name) + command.add_argument('--target', required=True) + target = command.add_mutually_exclusive_group(required=True) + target.add_argument('--source', type=Path) + target.add_argument('--rpm', type=Path) + command.add_argument('--backup', type=Path) + command.add_argument('--uid', type=int, default=int(os.environ.get('SUDO_UID', '0'))) + for name in ('status', 'reboot', 'cancel', 'finalize-current'): + commands.add_parser(name) + commands.add_parser('finalize').add_argument('id') + commands.add_parser('download').add_argument('id') + args = parser.parse_args(argv) + if os.geteuid() != 0: + parser.error('Run through sudo with the installed desktop account in SUDO_UID or --uid') + os.umask(0o077) + def interrupted(_signum, _frame): + raise InterruptedError('Upgrade helper interrupted; child processes were stopped before recovery') + signal.signal(signal.SIGTERM, interrupted) + try: + if args.command == 'status': + print(json.dumps(status(), sort_keys=True)) + return 0 + if args.command == 'finalize-current': + current = status() + if current['state'] in TERMINAL | {'idle', 'preparing', 'downloading', 'download-failed'}: + if current['state'] != 'committed' or not current.get('cleanupPending'): + return 0 + if current['state'] == 'ready' and (not current.get('rebootRequested') or current['bootId'] == boot_id()): + return 0 + owner = getattr(args, 'uid', None) + if args.command == 'download': + owner = status().get('uid') + with operation_lock(owner, wait=args.command == 'download'): + if args.command == 'check': + candidate = inspect_target(args.target, args.source, args.rpm) + result = {'supported': True, 'candidate': candidate, + **preflight(args.target, candidate, args.backup, args.uid)} + elif args.command == 'prepare': + result = prepare(args) + elif args.command == 'download': + result = download(args.id) + elif args.command == 'cancel': + result = cancel() + elif args.command == 'reboot': + result = reboot() + else: + identifier = args.id if args.command == 'finalize' else status().get('id') + if not identifier: + return 0 + result, code = finalize(identifier) + print(json.dumps(result, sort_keys=True)) + return code + print(json.dumps(result, sort_keys=True)) + except Busy as error: + if args.command == 'finalize-current': + return 0 + print('cybexos-major-upgrade: ' + str(error), file=sys.stderr) + return 1 + except (Failure, OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print('cybexos-major-upgrade: ' + str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/roles/base/files/cybexos-major-upgrade-validate.service b/roles/base/files/cybexos-major-upgrade-validate.service new file mode 100644 index 00000000..ae5a26da --- /dev/null +++ b/roles/base/files/cybexos-major-upgrade-validate.service @@ -0,0 +1,14 @@ +[Unit] +Description=Validate the desktop after a CybexOS Fedora major upgrade +After=systemd-user-sessions.service +ConditionPathExists=!/run/cybexos-live +ConditionPathExists=/var/lib/cybexos/major-upgrade/status.json + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/cybexos-major-upgrade finalize-current +TimeoutStartSec=75min +UMask=0077 +# Rollback selection can succeed while the running session still needs a +# restart. Preserve that state for the explicit upgrade-system reboot action. +SuccessExitStatus=75 diff --git a/roles/base/files/cybexos-major-upgrade-validate.timer b/roles/base/files/cybexos-major-upgrade-validate.timer new file mode 100644 index 00000000..f4dd4376 --- /dev/null +++ b/roles/base/files/cybexos-major-upgrade-validate.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check for the first desktop session after a Fedora major upgrade + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +Unit=cybexos-major-upgrade-validate.service + +[Install] +WantedBy=timers.target diff --git a/roles/base/files/cybexos-system-snapshot b/roles/base/files/cybexos-system-snapshot index c99e7868..100399a8 100755 --- a/roles/base/files/cybexos-system-snapshot +++ b/roles/base/files/cybexos-system-snapshot @@ -820,7 +820,12 @@ def command_create(arguments: list[str]) -> int: # independent transactions. Snapshots and archives rotate together. snapshots = sorted(entry.name for entry in store.roots.iterdir() if entry.is_dir()) while len(snapshots) > KEEP: - oldest = snapshots.pop(0) + removable = [point for point in snapshots + if not (store.metadata / f"{point}.pin").exists()] + if not removable: + break + oldest = removable[0] + snapshots.remove(oldest) if not ID_PATTERN.match(oldest): raise Failure(f"refusing to prune unexpected snapshot name: {oldest}") run(["btrfs", "subvolume", "delete", str(store.roots / oldest)]) diff --git a/roles/base/files/cybexos-update-bootstrap b/roles/base/files/cybexos-update-bootstrap new file mode 100755 index 00000000..44f339bf --- /dev/null +++ b/roles/base/files/cybexos-update-bootstrap @@ -0,0 +1,282 @@ +#!/usr/bin/python3 +"""Safely introduce durable updates on installations predating the journal. + +The untouched legacy recovery point is required before deployment. A second +point includes a root-owned recovery bundle and login barrier, so boot recovery +survives an interrupted rollback even when the original system had no hook. +""" +from __future__ import annotations + +import argparse +import contextlib +import fcntl +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import types + +HOST = Path('/') +ROOT_UID = 0 +BUNDLES = Path('/usr/local/libexec/cybexos-update-bootstrap.d') +UNIT = Path('/etc/systemd/system/cybexos-update-recover.service') +VENDOR_UNIT = Path('/usr/lib/systemd/system/cybexos-update-recover.service') +FILES = ('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') +POINT = re.compile(r'^[0-9]{8}T[0-9]{6}Z-[0-9]+$') +IDENTIFIER = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$') +TERMINAL = {'committed', 'aborted', 'rolled-back'} + + +class Failure(Exception): + pass + + +def execute(command): + return subprocess.run(command, check=True, capture_output=True, text=True, + timeout=120).stdout.strip() + + +def secure(path): + """No root boot code or its parents may be writable by another account.""" + path = Path(path) + while True: + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022: + raise Failure(f'Unsafe privileged recovery path: {path}') + if path == HOST: + return + if HOST not in path.parents: + raise Failure('Recovery path escapes the system root') + path = path.parent + + +def directory(path): + if not path.exists(): + directory(path.parent) + path.mkdir(mode=0o755) + secure(path) + + +def complete(libexec): + for name in FILES[:6]: + path = libexec / name + secure(path) + if not path.is_file() or (not name.endswith('.json') and not os.access(path, os.X_OK)): + raise Failure(f'Incomplete recovery bundle: {path}') + + +def ready(libexec): + complete(libexec) + if execute(['systemctl', 'is-enabled', 'cybexos-update-recover.service']) != 'enabled': + raise Failure('Boot recovery is not enabled') + invocation = execute(['systemctl', 'show', 'cybexos-update-recover.service', + '-p', 'ExecStart', '--value']) + if f'path={libexec}/cybexos-update-recover ;' not in invocation: + raise Failure('Boot recovery does not use this installed bundle') + for unit in ('systemd-user-sessions.service', 'sddm.service'): + requirements = execute(['systemctl', 'show', unit, '-p', 'Requires', '--value']).split() + ordering = execute(['systemctl', 'show', unit, '-p', 'After', '--value']).split() + if 'cybexos-update-recover.service' not in requirements or 'cybexos-update-recover.service' not in ordering: + raise Failure(f'{unit} has no enforced recovery barrier') + + +def read_sources(source): + contents = {} + for name in FILES: + # The source is the explicitly selected checkout/release or installed + # helper set. Only these fixed regular files become privileged code. + with os.fdopen(os.open(source / name, os.O_RDONLY | os.O_NOFOLLOW), 'rb') as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise Failure(f'Recovery source is not a regular file: {name}') + contents[name] = stream.read(1024 * 1024 + 1) + if len(contents[name]) > 1024 * 1024: + raise Failure(f'Recovery source is too large: {name}') + return contents + + +def snapshot_module(contents): + module = types.ModuleType('cybexos_bootstrap_snapshot') + module.__file__ = str(BUNDLES / 'cybexos-system-snapshot') + exec(compile(contents['cybexos-system-snapshot'], module.__file__, 'exec'), module.__dict__) + return module + + +def validate_checkpoint(snapshot, point): + if not POINT.fullmatch(point): + raise Failure('Invalid pre-bootstrap checkpoint') + layout = snapshot.detect_layout() + if not layout.usable or layout.pending_reboot or layout.kind == 'recovery': + raise Failure('Bootstrap requires the active managed Btrfs root') + with snapshot.opened_store(layout, create=False) as store: + if store is None or not (store.roots / point).is_dir(): + raise Failure('The untouched pre-bootstrap recovery point is missing') + for journal in (store.path / 'transactions').glob('*/transaction.json'): + if json.loads(journal.read_text()).get('state') not in TERMINAL: + raise Failure('An earlier update must recover before bootstrapping') + + +def atomic_write(path, contents, mode=0o644): + directory(path.parent) + if path.is_symlink(): + raise Failure(f'Refusing to replace a recovery symlink: {path}') + descriptor, name = tempfile.mkstemp(prefix=f'.{path.name}.', dir=path.parent) + try: + with os.fdopen(descriptor, 'wb') as stream: + stream.write(contents) + stream.flush() + os.fchmod(stream.fileno(), mode) + os.fsync(stream.fileno()) + os.replace(name, path) + descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + Path(name).unlink(missing_ok=True) + + +def install_bundle(contents): + digest = hashlib.sha256() + for name in FILES: + digest.update(name.encode() + b'\0' + contents[name] + b'\0') + bundle = BUNDLES / digest.hexdigest() + directory(BUNDLES) + if bundle.exists(): + secure(bundle) + for name in FILES: + secure(bundle / name) + if (bundle / name).read_bytes() != contents[name]: + raise Failure('An existing recovery bundle has changed') + return bundle + staging = Path(tempfile.mkdtemp(prefix='.stage.', dir=BUNDLES)) + try: + for name in FILES: + mode = 0o644 if name.endswith(('.json', '.service', '.conf')) else 0o755 + atomic_write(staging / name, contents[name], mode) + staging.chmod(0o755) + os.rename(staging, bundle) + execute(['sync', '-f', str(BUNDLES)]) + finally: + if staging.exists(): + shutil.rmtree(staging) + return bundle + + +def prepare(source, point, identifier): + if not IDENTIFIER.fullmatch(identifier): + raise Failure('Invalid update identifier') + contents = read_sources(source) + snapshot = snapshot_module(contents) + # This validation deliberately precedes the first installed file write. + validate_checkpoint(snapshot, point) + bundle = install_bundle(contents) + unit = contents['cybexos-update-recover.service'].decode() + unit, count = re.subn(r'^ExecStart=/(?:usr/local|usr)/libexec/cybexos-update-recover$', + f'ExecStart={bundle}/cybexos-update-recover', unit, flags=re.MULTILINE) + if count != 1: + raise Failure('Unsupported recovery unit template') + atomic_write(UNIT, unit.encode()) + for relative in ('systemd-user-sessions.service.d/60-cybexos-update-recover.conf', + 'sddm.service.d/60-cybexos-update-recover.conf'): + atomic_write(UNIT.parent / relative, contents['cybexos-update-recover-login.conf']) + # Atomic replacements and a newly created bundle need their final labels + # before systemd can read/execute them under Fedora's enforcing policy. + execute(['restorecon', '-RF', str(bundle), str(UNIT), + str(UNIT.parent / 'systemd-user-sessions.service.d/60-cybexos-update-recover.conf'), + str(UNIT.parent / 'sddm.service.d/60-cybexos-update-recover.conf')]) + execute(['systemctl', 'daemon-reload']) + execute(['systemctl', 'enable', 'cybexos-update-recover.service']) + ready(bundle) + execute(['sync', '-f', str(UNIT)]) + # Calling the captured module avoids mixing APIs with the legacy helper. + output = io.StringIO() + with contextlib.redirect_stdout(output): + snapshot.command_create([f'update {identifier} (recovery bootstrap)']) + lines = output.getvalue().strip().splitlines() + checkpoint = lines[-1] if lines else '' + if not POINT.fullmatch(checkpoint) or checkpoint == point: + raise Failure('Bootstrap did not create a distinct protected recovery point') + return {'snapshot': checkpoint, 'previousSnapshot': point, + 'transactionHelper': str(bundle / 'cybexos-update-transaction')} + + +def finalize(bundle): + """Retire only our bootstrap files after normal helpers have converged.""" + if bundle.parent != BUNDLES or not re.fullmatch(r'[0-9a-f]{64}', bundle.name): + raise Failure('Invalid bootstrap bundle path') + secure(bundle) + snapshot = snapshot_module(read_sources(bundle)) + layout = snapshot.detect_layout() + if not layout.usable or layout.pending_reboot or layout.kind == 'recovery': + raise Failure('Recovery bootstrap must remain until the active root is healthy') + with snapshot.opened_store(layout, create=False) as store: + if store is None: + raise Failure('Recovery store is unavailable') + for journal in (store.path / 'transactions').glob('*/transaction.json'): + if json.loads(journal.read_text()).get('state') not in TERMINAL: + raise Failure('An unfinished update still needs boot recovery') + for libexec in (HOST / 'usr/local/libexec', HOST / 'usr/libexec'): + try: + complete(libexec) + bootstrap_line = f'ExecStart={bundle}/cybexos-update-recover' + if UNIT.is_file() and bootstrap_line in UNIT.read_text().splitlines(): + secure(VENDOR_UNIT) + if f'ExecStart={libexec}/cybexos-update-recover' not in VENDOR_UNIT.read_text().splitlines(): + continue + UNIT.unlink() + execute(['systemctl', 'daemon-reload']) + ready(libexec) + except (Failure, OSError, subprocess.SubprocessError): + continue + shutil.rmtree(bundle) + execute(['sync', '-f', str(BUNDLES)]) + return + # Package-only updates may not yet deliver the new role/RPM. This bundle + # remains the installed recovery implementation until full convergence. + print('Recovery bootstrap retained until normal recovery helpers converge', file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + check = commands.add_parser('ready') + check.add_argument('--libexec', type=Path, required=True) + bootstrap = commands.add_parser('prepare') + bootstrap.add_argument('--source', type=Path, required=True) + bootstrap.add_argument('--checkpoint', required=True) + bootstrap.add_argument('--id', required=True) + cleanup = commands.add_parser('finalize') + cleanup.add_argument('--bundle', type=Path, required=True) + args = parser.parse_args() + if os.geteuid() != 0: + parser.error('Run the recovery bootstrap as root') + os.umask(0o022) + try: + if args.command == 'ready': + ready(args.libexec) + else: + with Path('/run/cybexos-update-bootstrap.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + if args.command == 'prepare': + print(json.dumps(prepare(args.source, args.checkpoint, args.id))) + else: + finalize(args.bundle) + except Exception as error: + print(f'cybexos-update-bootstrap: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/roles/base/files/cybexos-update-recover b/roles/base/files/cybexos-update-recover new file mode 100755 index 00000000..fcb19dcf --- /dev/null +++ b/roles/base/files/cybexos-update-recover @@ -0,0 +1,12 @@ +#!/usr/bin/bash +# Boot-only recovery: never expose a partially updated system to a new login. +set -uo pipefail +helper=$(dirname -- "$(readlink -f -- "$0")")/cybexos-update-transaction +"$helper" recover +result=$? +if ((result == 75)); then + systemctl --no-block reboot || exit 1 + # Keep the boot ordering barrier until shutdown takes over. + exec sleep infinity +fi +exit "$result" diff --git a/roles/base/files/cybexos-update-recover-login.conf b/roles/base/files/cybexos-update-recover-login.conf new file mode 100644 index 00000000..4eec9d18 --- /dev/null +++ b/roles/base/files/cybexos-update-recover-login.conf @@ -0,0 +1,4 @@ +[Unit] +# Ordering alone does not stop login when recovery fails. +Requires=cybexos-update-recover.service +After=cybexos-update-recover.service diff --git a/roles/base/files/cybexos-update-recover.service b/roles/base/files/cybexos-update-recover.service new file mode 100644 index 00000000..d57ba3fb --- /dev/null +++ b/roles/base/files/cybexos-update-recover.service @@ -0,0 +1,21 @@ +[Unit] +Description=Recover an interrupted CybexOS update before login +Wants=network-online.target +Requires=dbus.service +After=local-fs.target network-online.target dbus.service +RequiresMountsFor=/home /var +Before=systemd-user-sessions.service display-manager.service +Conflicts=shutdown.target +Before=shutdown.target +ConditionPathExists=!/run/cybexos-live +OnFailure=emergency.target +OnFailureJobMode=isolate + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/cybexos-update-recover +TimeoutStartSec=75min +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/roles/base/files/cybexos-update-transaction b/roles/base/files/cybexos-update-transaction new file mode 100755 index 00000000..1690d31f --- /dev/null +++ b/roles/base/files/cybexos-update-transaction @@ -0,0 +1,433 @@ +#!/usr/bin/python3 +"""Durable OS and vendor-desktop rollback, independent of the root being restored. + +The transaction journal and vendor checkpoint live in the Btrfs top-level +recovery store, outside both root and home. Personal preferences are never +checkpointed. A failed transaction selects the previous root for the next boot; +it does not pretend to replace the running kernel or reboot a working session. +""" +from __future__ import annotations + +import argparse +import contextlib +import importlib.machinery +import importlib.util +import hashlib +import json +import os +from pathlib import Path +import pwd +import re +import shutil +import subprocess +import sys +import time + + +def load_snapshot(): + path = Path(__file__).resolve().with_name('cybexos-system-snapshot') + loader = importlib.machinery.SourceFileLoader('cybexos_snapshot', str(path)) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +snapshot = load_snapshot() +IDENTIFIER = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$') +ACTIVE = {'prepared', 'applying', 'validating', 'rolling-back', 'rollback-failed', + 'awaiting-upgrade', 'awaiting-desktop'} +TERMINAL = {'committed', 'aborted', 'rolled-back'} + + +def boot_id(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +def execute(command, *, timeout=120): + result = subprocess.run(command, text=True, capture_output=True, timeout=timeout, + env={**os.environ, 'LC_ALL': 'C'}) + if result.returncode: + raise snapshot.Failure(f'{command[0]} failed: {result.stderr.strip()[-1000:]}') + return result.stdout.strip() + + +def as_user(uid, command): + account = pwd.getpwuid(uid) + return ['runuser', '-u', account.pw_name, '--', 'env', f'HOME={account.pw_dir}', + f'XDG_RUNTIME_DIR=/run/user/{uid}', + f'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{uid}/bus', *command] + + +def desktop_active(uid): + try: + return execute(as_user(uid, ['systemctl', '--user', 'is-active', + 'quickshell.service'])) == 'active' + except snapshot.Failure: + return False + + +def failed_units(): + return [line.split()[0] for line in + execute(['systemctl', '--failed', '--plain', '--no-legend']).splitlines() if line.strip()] + + +def health(record, *, desktop=True): + execute(['rpm', '--verifydb']) + new_failures = set(failed_units()) - set(record.get('failedUnits', [])) + if new_failures: + raise snapshot.Failure('New failed system units: ' + '; '.join(sorted(new_failures))) + if desktop and record.get('desktopActive'): + uid = record['uid'] + execute(as_user(uid, ['systemctl', '--user', 'daemon-reload'])) + execute(as_user(uid, ['systemctl', '--user', 'restart', 'quickshell.service'])) + account = pwd.getpwuid(uid) + runtime = Path(account.pw_dir) / '.local/bin/cybexos-runtime' + if not runtime.is_file(): + runtime = Path('/usr/share/cybexos/bin/cybexos-runtime') + deadline = time.monotonic() + 45 + stable_at = None + previous = None + while time.monotonic() < deadline: + try: + if not desktop_active(uid): + raise snapshot.Failure('Desktop service is not active') + invocation = execute(as_user(uid, ['systemctl', '--user', 'show', + 'quickshell.service', '-p', 'InvocationID', '--value'])) + pid = execute(as_user(uid, ['systemctl', '--user', 'show', 'quickshell.service', + '-p', 'MainPID', '--value'])) + if not re.fullmatch(r'[0-9a-f]{32}', invocation) or not pid.isdigit() or int(pid) == 0: + raise snapshot.Failure('Desktop process has not started') + if execute(['pgrep', '-u', str(uid), '-x', 'qs']).splitlines() != [pid]: + raise snapshot.Failure('The managed service must own the sole Quickshell process') + recovery = json.loads(execute(as_user(uid, [str(runtime), 'shell', 'status']))) + if recovery.get('safe'): + raise snapshot.Failure('The new desktop entered recovery mode') + # Type=simple readiness only means exec succeeded. Read-only + # IPC proves the full configuration finished constructing. + ready = json.loads(execute(as_user(uid, [str(runtime), 'ipc', 'settings', 'status']), + timeout=5)) + if not isinstance(ready.get('services'), dict): + raise snapshot.Failure('Desktop IPC did not report a ready configuration') + current = (pid, invocation) + if current != previous: + stable_at = time.monotonic() + previous = current + if stable_at is not None and time.monotonic() - stable_at >= 2: + break + except (snapshot.Failure, ValueError, subprocess.TimeoutExpired): + previous = None + stable_at = None + time.sleep(0.25) + else: + raise snapshot.Failure('The full desktop did not become stable and IPC-ready within 45 seconds') + journal = execute(as_user(uid, ['journalctl', '--user', '--no-pager', '-o', 'cat', + f'_SYSTEMD_INVOCATION_ID={invocation}'])) + if re.search(r'(ReferenceError:|TypeError:|SyntaxError:|Failed to load configuration)', journal): + raise snapshot.Failure('The updated desktop reported QML errors') + + +def save(directory, record): + record['updatedAt'] = int(time.time()) + snapshot.atomic_write(directory / 'transaction.json', json.dumps(record, indent=2) + '\n', 0o600) + execute(['sync', '-f', str(directory)]) + + +def read(directory): + value = json.loads((directory / 'transaction.json').read_text()) + if value.get('v') != 1 or value.get('state') not in ACTIVE | TERMINAL: + raise snapshot.Failure('Unsupported or corrupt transaction journal; refusing to overwrite it') + return value + + +def vendor_paths(): + path = Path(__file__).resolve().with_name('cybexos-vendor-paths.json') + values = json.loads(path.read_text()) + for relative in values: + parts = Path(relative).parts + if not parts or Path(relative).is_absolute() or '..' in parts: + raise snapshot.Failure('Unsafe vendor checkpoint manifest') + if relative.startswith(('.config/cybexos/', '.local/share/cybexos/plugins', + '.local/share/cybexos/themes', '.local/share/cybexos/plugin-data')): + raise snapshot.Failure('Personal data must never be part of the vendor checkpoint') + return values + + +def safe_destination(home, relative): + target = home / relative + # A user-controlled parent symlink must never redirect root writes elsewhere. + for parent in target.parents: + if parent == home: + break + if parent.is_symlink(): + raise snapshot.Failure(f'Vendor path has a symlink parent: {parent}') + return target + + +def home_command(uid, command): + # No privileged process traverses a writable home when copying/removing + # files. Checking symlink parents alone cannot prevent a rename race. + if uid == os.geteuid(): + return command + account = pwd.getpwuid(uid) + # Boot recovery runs before systemd-user-sessions. A PAM session may + # activate user@UID.service, which waits on that same login barrier. + # Filesystem work needs only the account's identity, never session setup. + return ['/usr/bin/setpriv', '--reuid', str(uid), '--regid', str(account.pw_gid), + '--init-groups', '--', '/usr/bin/env', f'HOME={account.pw_dir}', + f'USER={account.pw_name}', f'LOGNAME={account.pw_name}', *command] + + +def archive_path(directory, relative): + return directory / 'vendor' / (hashlib.sha256(relative.encode()).hexdigest() + '.tar') + + +def checkpoint(directory, home, uid): + present = [] + (directory / 'vendor').mkdir(mode=0o700) + for relative in vendor_paths(): + source = safe_destination(home, relative) + if source.exists() or source.is_symlink(): + with archive_path(directory, relative).open('xb') as stream: + subprocess.run(home_command(uid, ['/usr/bin/tar', '--acls', '--xattrs', + '--selinux', '--create', '--file=-', '--directory', str(home), '--', relative]), + stdout=stream, check=True, timeout=600) + stream.flush() + os.fsync(stream.fileno()) + present.append(relative) + return present + + +def restore_vendor(directory, record): + home = Path(pwd.getpwuid(record['uid']).pw_dir) + if str(home) != record['home']: + raise snapshot.Failure('Account home changed; refusing to restore into a different home') + for relative in record['paths']: + destination = safe_destination(home, relative) + backup = archive_path(directory, relative) + # Remove exactly the selected vendor object as its owner. A race can + # never turn this into a root write outside the account's privileges. + delete = ['/usr/bin/python3', '-I', '-c', + 'import pathlib,shutil,sys; p=pathlib.Path(sys.argv[1]); ' + 'shutil.rmtree(p) if p.is_dir() and not p.is_symlink() else p.unlink(missing_ok=True)', + str(destination)] + if relative in record['present']: + if not backup.is_file() or backup.is_symlink(): + raise snapshot.Failure(f'Vendor checkpoint is incomplete: {relative}') + execute(home_command(record['uid'], delete), timeout=600) + # Retain the archive until the journal is terminal. An interrupted + # extraction is safely retried, without exchanging roots twice. + with backup.open('rb') as stream: + subprocess.run(home_command(record['uid'], ['/usr/bin/tar', '--acls', '--xattrs', + '--selinux', '--extract', '--file=-', '--no-same-owner', '--same-permissions', + '--directory', str(home)]), stdin=stream, check=True, timeout=600) + else: + execute(home_command(record['uid'], delete), timeout=600) + + +def remove(path): + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink(missing_ok=True) + + +def cleanup_terminal(store, directory, record): + # A durable terminal journal is authoritative. A full disk or cleanup + # permission failure must not turn a successful restore into a retry that + # needs an archive which has already been partly removed. + for path in (directory / 'vendor', store.metadata / f"{record['snapshot']}.pin"): + try: + remove(path) + except OSError as error: + print(f'cybexos-update-transaction: retained cleanup artifact {path}: {error}', + file=sys.stderr) + + +@contextlib.contextmanager +def store_for_transaction(create=False): + layout = snapshot.detect_layout() + if not layout.usable: + raise snapshot.Failure(layout.message or 'Transactional updates require the managed Btrfs layout') + with snapshot.opened_store(layout, create=create) as store: + if store is None: + raise snapshot.Failure('Recovery store is missing') + yield layout, store + + +def begin(identifier, point, uid): + account = pwd.getpwuid(uid) + with store_for_transaction() as (layout, store): + if layout.pending_reboot or layout.kind == 'recovery': + raise snapshot.Failure('Restart into the selected system before updating') + if not snapshot.ID_PATTERN.fullmatch(point) or not (store.roots / point).is_dir(): + raise snapshot.Failure('The pre-update recovery point is missing') + root = store.path / 'transactions' + root.mkdir(mode=0o700, exist_ok=True) + for previous in root.glob('*/transaction.json'): + if read(previous.parent)['state'] in ACTIVE: + raise snapshot.Failure('Another transaction needs recovery before a new update') + directory = root / identifier + directory.mkdir(mode=0o700) + try: + record = {'v': 1, 'id': identifier, 'state': 'prepared', 'snapshot': point, + 'uid': uid, 'home': account.pw_dir, 'bootId': boot_id(), + 'paths': vendor_paths(), 'failedUnits': failed_units(), + 'desktopActive': desktop_active(uid), + 'rootUuid': snapshot.subvolume_uuid(store.top / 'root')} + record['present'] = checkpoint(directory, Path(account.pw_dir), uid) + save(directory, record) + except BaseException: + shutil.rmtree(directory) + raise + # Pin the point independently of normal five-point retention. + snapshot.atomic_write(store.metadata / f'{point}.pin', identifier + '\n', 0o600) + print(json.dumps(record)) + + +def transition(identifier, action, metadata=None): + with store_for_transaction() as (_layout, store): + directory = store.path / 'transactions' / identifier + record = read(directory) + if action == 'status': + print(json.dumps(record)) + return + if action == 'applying': + if record['state'] not in {'prepared', 'awaiting-upgrade'}: + raise snapshot.Failure('Only a prepared update may start applying') + record['state'] = 'applying' + elif action == 'commit': + if record['state'] not in {'applying', 'validating', 'awaiting-desktop'}: + raise snapshot.Failure('Only an applied update may be validated') + record['state'] = 'validating' + save(directory, record) + health(record) + record['state'] = 'committed' + elif action == 'await-desktop': + if record['state'] not in {'applying', 'validating'}: + raise snapshot.Failure('Only a converged upgrade can await desktop validation') + health(record, desktop=False) + record.update(state='awaiting-desktop', desktopActive=True) + elif action == 'abort': + if record['state'] not in {'prepared', 'awaiting-upgrade'}: + raise snapshot.Failure('An applied update must be rolled back') + record['state'] = 'aborted' + elif action == 'arm-upgrade': + if record['state'] != 'prepared' or not isinstance(metadata, dict): + raise snapshot.Failure('A major upgrade requires a prepared checkpoint and target metadata') + target = metadata.get('targetFedora') + if not isinstance(target, str) or not re.fullmatch(r'[1-9][0-9]{1,2}', target): + raise snapshot.Failure('Major upgrade targetFedora must be a release number string') + record.update(state='awaiting-upgrade', operation='major-upgrade', upgrade=metadata) + save(directory, record) + if record['state'] in {'committed', 'aborted'}: + cleanup_terminal(store, directory, record) + print(json.dumps(record)) + + +def rollback(identifier): + # Do not hold the store's flock while the snapshot helper acquires it. + with store_for_transaction() as (_layout, store): + directory = store.path / 'transactions' / identifier + record = read(directory) + if record['state'] in {'committed', 'aborted'}: + raise snapshot.Failure('A completed transaction cannot be automatically rolled back') + if record['state'] == 'rolled-back': + print(json.dumps(record)) + return + record['state'] = 'rolling-back' + save(directory, record) + # A crash after root exchange must not exchange it a second time. + snapshot.complete_interrupted(store) + already_restored = any(item.get('point') == record['snapshot'] + and item.get('state') == 'complete' + for item in (snapshot.read_record(store, path.stem) + for path in store.replaced.glob('*.json'))) + try: + if not already_restored: + with contextlib.redirect_stdout(sys.stderr): + snapshot.command_restore([record['snapshot']]) + with store_for_transaction() as (_layout, store): + directory = store.path / 'transactions' / identifier + restore_vendor(directory, record) + record['state'] = 'rolled-back' + record['restartRequired'] = snapshot.detect_layout().pending_reboot + save(directory, record) + cleanup_terminal(store, directory, record) + except BaseException: + with store_for_transaction() as (_layout, store): + record['state'] = 'rollback-failed' + save(store.path / 'transactions' / identifier, record) + raise + print(json.dumps(record)) + + +def recover(): + layout = snapshot.detect_layout() + if not layout.usable: + return + with snapshot.opened_store(layout, create=False) as store: + if store is None: + return + pending = [read(path.parent) for path in (store.path / 'transactions').glob('*/transaction.json')] + for record in pending: + # Installing the login dependency can start this oneshot during an + # existing session. Only another boot proves that the update worker + # was interrupted; never restore files beneath a same-boot worker. + if record['bootId'] == boot_id(): + continue + if record['state'] == 'awaiting-upgrade': + helper = Path(__file__).resolve().with_name('cybexos-major-upgrade') + result = subprocess.run([str(helper), 'finalize', record['id']], check=False, timeout=4200) + if result.returncode == 75: + raise SystemExit(75) + if result.returncode: + rollback(record['id']) + raise SystemExit(75) + elif record['state'] == 'awaiting-desktop': + continue + elif record['state'] == 'prepared': + transition(record['id'], 'abort') + elif record['state'] in ACTIVE: + rollback(record['id']) + # Boot recovery runs before logins. Do not start a desktop against + # the still-mounted failed root; return a distinct restart signal. + raise SystemExit(75) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + begin_parser = commands.add_parser('begin') + begin_parser.add_argument('id') + begin_parser.add_argument('snapshot') + begin_parser.add_argument('--uid', required=True, type=int) + for action in ('applying', 'commit', 'abort', 'rollback', 'status', 'await-desktop'): + commands.add_parser(action).add_argument('id') + arm = commands.add_parser('arm-upgrade') + arm.add_argument('id') + arm.add_argument('--metadata', type=json.loads, required=True) + commands.add_parser('recover') + args = parser.parse_args(argv) + if hasattr(args, 'id') and not IDENTIFIER.fullmatch(args.id): + parser.error('Invalid transaction identifier') + if os.geteuid() != 0: + parser.error('Run the transaction helper as root') + os.umask(0o077) + try: + if args.command == 'begin': + begin(args.id, args.snapshot, args.uid) + elif args.command == 'recover': + recover() + elif args.command == 'rollback': + rollback(args.id) + else: + transition(args.id, args.command, getattr(args, 'metadata', None)) + except (snapshot.Failure, OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print(f'cybexos-update-transaction: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/roles/base/files/cybexos-vendor-paths.json b/roles/base/files/cybexos-vendor-paths.json new file mode 100644 index 00000000..7cd8e9fb --- /dev/null +++ b/roles/base/files/cybexos-vendor-paths.json @@ -0,0 +1,76 @@ +[ + ".agents/skills/cybexos", + ".claude/skills/cybexos", + ".codex/skills/cybexos", + ".config/environment.d/10-cybexos.conf", + ".config/systemd/user/cybexos-input-method.service", + ".config/systemd/user/cybexos-session-lock.service", + ".config/systemd/user/external-monitor-toggle.service", + ".config/systemd/user/graphical-session.target.wants/hyprpolkitagent.service", + ".config/systemd/user/hermes-menubar-bridge.service", + ".config/systemd/user/hypridle.service", + ".config/systemd/user/hyprland-session.target", + ".config/systemd/user/hyprland-session.target.wants/cybexos-input-method.service", + ".config/systemd/user/hyprland-session.target.wants/external-monitor-toggle.service", + ".config/systemd/user/hyprland-session.target.wants/hermes-menubar-bridge.service", + ".config/systemd/user/hyprland-session.target.wants/hypridle.service", + ".config/systemd/user/hyprland-session.target.wants/hyprpolkitagent.service", + ".config/systemd/user/hyprland-session.target.wants/quickshell.service", + ".config/systemd/user/hyprland-session.target.wants/voxtype.service", + ".config/systemd/user/hyprpolkitagent.service", + ".config/systemd/user/quickshell.service", + ".config/systemd/user/voxtype.service", + ".config/systemd/user/xps-speaker-tuning.service", + ".config/xdg-desktop-portal/hyprland-portals.conf", + ".local/bin/app-backup", + ".local/bin/brightness-control", + ".local/bin/browser-backup", + ".local/bin/clipboard-copy-image", + ".local/bin/clipboard-history-store", + ".local/bin/clipboard-image-to-file", + ".local/bin/cybex", + ".local/bin/cybexos-agent", + ".local/bin/cybexos-firmware-update", + ".local/bin/cybexos-release-update", + ".local/bin/cybexos-runtime", + ".local/bin/cybexos-update-run", + ".local/bin/dev-arch-shell", + ".local/bin/dev-debian-shell", + ".local/bin/dev-distrobox-init", + ".local/bin/dev-fedora-shell", + ".local/bin/external-monitor-toggle", + ".local/bin/fastfetch-link-speed", + ".local/bin/localsend", + ".local/bin/localsend-share", + ".local/bin/portal-launcher", + ".local/bin/quickshell-reminder", + ".local/bin/screen-ocr", + ".local/bin/screen-record", + ".local/bin/screenshot", + ".local/bin/spotify", + ".local/bin/t3code-desktop", + ".local/bin/t3code-update", + ".local/bin/update-user-tools", + ".local/libexec/cybex_hermes", + ".local/libexec/cybexos-migrate-layering", + ".local/libexec/hermes-menubar-bridge", + ".local/share/applications/1password.desktop", + ".local/share/applications/brave-browser.desktop", + ".local/share/applications/brave-origin.desktop", + ".local/share/applications/chatgpt.desktop", + ".local/share/applications/com.onepassword.OnePassword.desktop", + ".local/share/applications/dev-arch.desktop", + ".local/share/applications/dev-debian.desktop", + ".local/share/applications/dev-fedora.desktop", + ".local/share/applications/localsend-share-clipboard.desktop", + ".local/share/applications/localsend-share-file.desktop", + ".local/share/applications/localsend-share-folder.desktop", + ".local/share/applications/nvim-kitty.desktop", + ".local/share/applications/t3code-nightly.desktop", + ".local/share/cybexos/current", + ".local/share/cybexos/releases/bootstrap", + ".local/share/cybexos/runtime", + ".local/share/nautilus-python/extensions/localsend.py", + ".local/state/cybexos/quickshell-manifest.txt", + ".local/state/cybexos/shell-recovery.json" +] diff --git a/roles/base/tasks/main.yml b/roles/base/tasks/main.yml index 76ef5564..032346c5 100644 --- a/roles/base/tasks/main.yml +++ b/roles/base/tasks/main.yml @@ -139,6 +139,78 @@ mode: "0755" register: base_snapshot_helper +- name: Install the durable update transaction helpers + ansible.builtin.copy: + src: "{{ item }}" + dest: "/usr/local/libexec/{{ item }}" + owner: root + group: root + mode: "{{ '0644' if item.endswith(('.json', '.service', '.conf')) else '0755' }}" + loop: + - cybexos-update-transaction + - cybexos-update-bootstrap + - cybexos-update-recover + - cybexos-update-recover.service + - cybexos-update-recover-login.conf + - cybexos-vendor-paths.json + - cybexos-major-upgrade + +- name: Install the interrupted-update boot recovery unit + ansible.builtin.copy: + src: cybexos-update-recover.service + dest: /etc/systemd/system/cybexos-update-recover.service + owner: root + group: root + mode: "0644" + register: base_update_recover_unit + +- name: Create the login recovery dependency directories + ansible.builtin.file: + path: "/etc/systemd/system/{{ item }}.d" + state: directory + owner: root + group: root + mode: "0755" + loop: + - systemd-user-sessions.service + - sddm.service + +- name: Block login if interrupted-update recovery fails + ansible.builtin.copy: + src: cybexos-update-recover-login.conf + dest: "/etc/systemd/system/{{ item }}.d/60-cybexos-update-recover.conf" + owner: root + group: root + mode: "0644" + loop: + - systemd-user-sessions.service + - sddm.service + register: base_update_recover_dependencies + +- name: Enable interrupted-update recovery for subsequent boots + ansible.builtin.systemd_service: + name: cybexos-update-recover.service + enabled: true + daemon_reload: "{{ base_update_recover_unit.changed or base_update_recover_dependencies.changed }}" + +- name: Install Fedora upgrade validation units + ansible.builtin.copy: + src: "{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - cybexos-major-upgrade-validate.service + - cybexos-major-upgrade-validate.timer + register: base_major_upgrade_units + +- name: Enable Fedora upgrade validation after the first desktop login + ansible.builtin.systemd_service: + name: cybexos-major-upgrade-validate.timer + enabled: true + daemon_reload: "{{ base_major_upgrade_units.changed }}" + # The documented `sudo cybexos-system-snapshot ...` form needs the helper on # sudo's secure_path; libexec is not on it. - name: Expose the recovery helper to administrators diff --git a/roles/desktop/files/90-cybexos-smb.gschema.override b/roles/desktop/files/90-cybexos-smb.gschema.override new file mode 100644 index 00000000..8915f082 --- /dev/null +++ b/roles/desktop/files/90-cybexos-smb.gschema.override @@ -0,0 +1,4 @@ +# Files/GVfs uses this workgroup for new SMB connections. +# Explicit per-user GSettings values take precedence. +[org.gnome.system.smb] +workgroup='WORKGROUP' diff --git a/roles/desktop/files/autostart.lua b/roles/desktop/files/autostart.lua index 4cd7f28e..743eaf8f 100644 --- a/roles/desktop/files/autostart.lua +++ b/roles/desktop/files/autostart.lua @@ -2,5 +2,14 @@ hl.on("hyprland.start", function() -- One process serializes environment publication, target activation, and -- portal refresh. Separate async execs race target units against a partially -- imported environment on a fresh login. - hl.exec_cmd([[/usr/local/libexec/cybexos-hyprland-session-start]]) + -- Development mode loads this file verbatim even on an ISO installation, + -- where the packaged helper lives in /usr/libexec. Resolve the installed + -- helper at startup instead of relying on the image's path rewriting. + hl.exec_cmd([[ + starter=/usr/local/libexec/cybexos-hyprland-session-start + if [ ! -x "$starter" ]; then + starter=/usr/libexec/cybexos-hyprland-session-start + fi + exec "$starter" + ]]) end) diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py new file mode 100644 index 00000000..4d8f31d4 --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/__init__.py @@ -0,0 +1,4 @@ +"""Hermes bridge protocol, credential transport and durable registry. + +The entrypoint owns conversation orchestration; these modules do not import it. +""" diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py new file mode 100644 index 00000000..c7939b1c --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py @@ -0,0 +1,1249 @@ +"""Origin-scoped HTTP credentials and authenticated remote transport.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from http.cookiejar import Cookie, CookieJar +import json +import os +from pathlib import Path +import re +import threading +import time +from typing import Any +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) + + +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() + 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", + ) + + @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) + + @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": "cybexos-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 asyncio.to_thread(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["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 asyncio.to_thread(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 asyncio.to_thread(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, + ) -> dict[str, Any]: + """Reusable authenticated request primitive for the remote adapter.""" + + return await asyncio.to_thread( + self._request_json_sync, method, path, payload, timeout + ) + + 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 + ) -> int: + """Return a multipart route's status after a fully consumed empty form.""" + + return await asyncio.to_thread( + self._probe_multipart_route_sync, path, timeout + ) + + def _probe_multipart_route_sync(self, path: str, timeout: float) -> 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() + 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) + 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, + ) -> dict[str, Any]: + base_url, jar = self._current_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) + 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) -> dict[str, Any]: + """Read the WebUI's non-streaming SSE capability probe and server tag.""" + + return await asyncio.to_thread(self._probe_contract_sync, timeout) + + def _probe_contract_sync(self, timeout: float) -> dict[str, Any]: + base_url, jar = self._current_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 + + 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) -> tuple[str, CookieJar]: + """Snapshot the origin and cookie jar one unlocked request will use.""" + + with self._lock: + 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, + ) -> 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() + 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": "cybexos-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" + ) + 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/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py new file mode 100644 index 00000000..80f427cb --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/gateway.py @@ -0,0 +1,442 @@ +"""Bounded downstream delivery and reconnecting local upstream transport.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from dataclasses import dataclass, field +import json +import os +import random +from typing import Any, Awaitable, Callable +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlparse, urlunparse +from urllib.request import ( + Request, + urlopen, +) + +import websockets +from websockets.asyncio.client import ClientConnection +from websockets.asyncio.server import ServerConnection +from websockets.exceptions import ConnectionClosed + + +from .protocol import ( + LOG, + MAX_UPSTREAM_MESSAGE, + MAX_PROVIDER_RESPONSE, + MAX_CLIENT_BACKLOG, + TOKEN_PATTERN, + RpcFault, + UpstreamUnavailable, + AmbiguousDelivery, + json_frame, +) + +@dataclass(eq=False) +class LocalClient: + """One loopback shell connection with its own bounded outbound queue. + + Producers never await the socket: a dedicated writer task drains the + queue, so one stalled client cannot block the upstream reader (and with + it the gateway heartbeat) or delay delivery to other clients. A client + that falls MAX_CLIENT_BACKLOG frames behind is disconnected; the shell + reconnects and reconciles through its normal hello/list/history path. + """ + + websocket: ServerConnection + tasks: set[asyncio.Task[Any]] = field(default_factory=set) + backlog: int = MAX_CLIENT_BACKLOG + closed: bool = False + queue: asyncio.Queue[str] = field(init=False) + writer: asyncio.Task[Any] | None = field(default=None, init=False) + + def __post_init__(self) -> None: + self.queue = asyncio.Queue(maxsize=max(1, self.backlog)) + + def start(self) -> None: + if self.writer is None: + self.writer = asyncio.create_task( + self._write(), name="hermes-local-writer" + ) + + async def _write(self) -> None: + try: + while True: + text = await self.queue.get() + await self.websocket.send(text) + except ConnectionClosed: + pass + except asyncio.CancelledError: + raise + except Exception as exc: + LOG.debug("local client write failed: %s", exc) + finally: + self.closed = True + + def enqueue_text(self, text: str) -> bool: + if self.closed: + return False + try: + self.queue.put_nowait(text) + except asyncio.QueueFull: + LOG.warning( + "local Hermes client fell %d frames behind; disconnecting it", + self.queue.maxsize, + ) + self.abort("client too slow") + return False + return True + + def abort(self, reason: str) -> None: + if self.closed: + return + self.closed = True + if self.writer is not None: + self.writer.cancel() + closer = asyncio.create_task( + self.websocket.close(code=1013, reason=reason), + name="hermes-local-close", + ) + self.tasks.add(closer) + closer.add_done_callback(self._closed) + + def _closed(self, task: asyncio.Task[Any]) -> None: + self.tasks.discard(task) + if not task.cancelled(): + task.exception() + + async def stop(self) -> None: + self.closed = True + if self.writer is not None: + self.writer.cancel() + with suppress(asyncio.CancelledError, Exception): + await self.writer + + async def send(self, frame: dict[str, Any]) -> None: + self.enqueue_text(json_frame(frame)) + + +@dataclass +class PendingUpstream: + method: str + future: asyncio.Future[Any] + written: bool = False + + +class HermesGateway: + """Authenticated, reconnecting JSON-RPC client for ``hermes serve``.""" + + def __init__( + self, + base_url: str, + on_event: Callable[[dict[str, Any]], Awaitable[None]], + on_state: Callable[[str, str], Awaitable[None]], + on_ready: Callable[[str], Awaitable[None]], + ): + self.base_url = base_url.rstrip("/") + self.on_event = on_event + self.on_state = on_state + self.on_ready = on_ready + self.websocket: ClientConnection | None = None + self.connected = asyncio.Event() + self.ready_epoch = "" + self._pending: dict[str, PendingUpstream] = {} + self._next_id = 0 + self._send_lock = asyncio.Lock() + self._stop = asyncio.Event() + self._runner: asyncio.Task[Any] | None = None + + def start(self) -> None: + if self._runner is None: + self._runner = asyncio.create_task(self._run(), name="hermes-upstream") + + async def stop(self) -> None: + self._stop.set() + websocket = self.websocket + if websocket is not None: + with suppress(Exception): + await websocket.close(code=1001, reason="bridge stopping") + if self._runner is not None: + self._runner.cancel() + with suppress(asyncio.CancelledError): + await self._runner + + async def request( + self, method: str, params: dict[str, Any] | None = None, timeout: float = 30.0 + ) -> Any: + if not self.connected.is_set() or self.websocket is None: + raise UpstreamUnavailable() + self._next_id += 1 + request_id = f"menubar-{self._next_id}" + future = asyncio.get_running_loop().create_future() + pending = PendingUpstream(method=method, future=future) + self._pending[request_id] = pending + frame = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params or {}, + } + try: + async with self._send_lock: + websocket = self.websocket + if websocket is None: + raise UpstreamUnavailable() + await websocket.send(json_frame(frame)) + pending.written = True + return await asyncio.wait_for(future, timeout=timeout) + except asyncio.TimeoutError as exc: + self._pending.pop(request_id, None) + if method == "prompt.submit" and pending.written: + raise AmbiguousDelivery(method) from exc + raise RpcFault( + -32012, + f"Hermes did not answer {method} within {int(timeout)} seconds", + {"method": method}, + ) from exc + except ConnectionClosed as exc: + self._pending.pop(request_id, None) + if pending.written: + raise AmbiguousDelivery(method) from exc + raise UpstreamUnavailable() from exc + finally: + self._pending.pop(request_id, None) + + async def api_request( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + timeout: float = 20.0, + ) -> Any: + """Call an authenticated Hermes dashboard API without exposing its token. + + Provider setup is a dashboard REST API rather than a gateway RPC. The + bridge obtains the same private session token it already uses for the + upstream WebSocket and keeps both that token and submitted credentials + out of downstream responses and logs. + """ + return await asyncio.to_thread( + self._api_request_sync, method, path, payload, timeout + ) + + def _api_request_sync( + self, + method: str, + path: str, + payload: dict[str, Any] | None, + timeout: float, + ) -> Any: + if not path.startswith("/api/") or "://" in path: + raise RpcFault(-32602, "invalid Hermes API path") + try: + token = self._fetch_token() + except Exception as exc: + raise UpstreamUnavailable("Hermes provider API is unavailable") from exc + body = ( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + if payload is not None + else None + ) + headers = { + "Accept": "application/json", + "User-Agent": "cybexos-hermes-menubar-bridge/1", + "X-Hermes-Session-Token": token, + } + if body is not None: + headers["Content-Type"] = "application/json" + request = Request( + f"{self.base_url}{path}", + data=body, + method=method.upper(), + headers=headers, + ) + try: + with urlopen(request, timeout=timeout) as response: + raw = response.read(MAX_PROVIDER_RESPONSE + 1) + except HTTPError as exc: + raw = exc.read(MAX_PROVIDER_RESPONSE + 1) + message = self._api_error_message(raw) + raise RpcFault( + -32030, + message or f"Hermes provider API returned HTTP {exc.code}", + ) from exc + except (URLError, TimeoutError, OSError) as exc: + raise UpstreamUnavailable("Hermes provider API is unavailable") from exc + if len(raw) > MAX_PROVIDER_RESPONSE: + raise RpcFault(-32030, "Hermes provider API response is too large") + if not raw: + return {} + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc: + raise RpcFault(-32030, "Hermes provider API returned invalid JSON") from exc + + @staticmethod + def _api_error_message(raw: bytes) -> str: + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError): + return "" + if not isinstance(value, dict): + return "" + detail = value.get("detail") or value.get("message") or value.get("error") + return str(detail)[:500] if isinstance(detail, (str, int, float)) else "" + + async def _run(self) -> None: + backoff = 0.5 + while not self._stop.is_set(): + await self.on_state("connecting", "Connecting to Hermes…") + receiver: asyncio.Task[Any] | None = None + heartbeat: asyncio.Task[Any] | None = None + try: + token = await asyncio.to_thread(self._fetch_token) + websocket_url = self._websocket_url(token) + async with websockets.connect( + websocket_url, + open_timeout=15, + close_timeout=5, + max_size=MAX_UPSTREAM_MESSAGE, + ping_interval=20, + ping_timeout=45, + ) as websocket: + self.websocket = websocket + receiver = asyncio.create_task( + self._receive(websocket), name="hermes-upstream-receive" + ) + await asyncio.wait_for(self.connected.wait(), timeout=30) + backoff = 0.5 + await self.on_state("connected", "Hermes connected") + ready_task = asyncio.create_task( + self.on_ready(self.ready_epoch), name="hermes-reconcile" + ) + ready_task.add_done_callback(self._log_background_failure) + heartbeat = asyncio.create_task( + self._heartbeat(websocket), name="hermes-upstream-heartbeat" + ) + await receiver + except asyncio.CancelledError: + raise + except Exception as exc: + if not self._stop.is_set(): + LOG.warning("Hermes connection unavailable: %s", exc) + finally: + self.connected.clear() + self.websocket = None + for task in (receiver, heartbeat): + if task is not None and not task.done(): + task.cancel() + self._reject_pending() + + if self._stop.is_set(): + break + await self.on_state("reconnecting", "Reconnecting to Hermes…") + try: + await asyncio.wait_for( + self._stop.wait(), timeout=backoff + random.random() * 0.25 + ) + except asyncio.TimeoutError: + pass + backoff = min(backoff * 2, 15.0) + + @staticmethod + def _log_background_failure(task: asyncio.Task[Any]) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + LOG.error("Hermes reconciliation failed: %s", exc) + + def _fetch_token(self) -> str: + configured = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN", "").strip() + if configured: + return configured + request = Request( + f"{self.base_url}/", + headers={"User-Agent": "cybexos-hermes-menubar-bridge/1"}, + ) + with urlopen(request, timeout=10) as response: + body = response.read(1024 * 1024).decode("utf-8", errors="replace") + match = TOKEN_PATTERN.search(body) + if not match: + raise RuntimeError("Hermes headless token was not present at the root URL") + token = json.loads(match.group(1)) + if not isinstance(token, str) or not token: + raise RuntimeError("Hermes returned an invalid headless token") + return token + + def _websocket_url(self, token: str) -> str: + parsed = urlparse(self.base_url) + if parsed.scheme not in {"http", "https"}: + raise RuntimeError("Hermes upstream must use http:// or https://") + scheme = "wss" if parsed.scheme == "https" else "ws" + path = f"{parsed.path.rstrip('/')}/api/ws" + return urlunparse( + (scheme, parsed.netloc, path, "", f"token={quote(token, safe='')}", "") + ) + + async def _receive(self, websocket: ClientConnection) -> None: + async for raw in websocket: + if not isinstance(raw, str): + continue + try: + frame = json.loads(raw) + except (json.JSONDecodeError, TypeError): + LOG.warning("Hermes sent malformed JSON") + continue + if not isinstance(frame, dict): + continue + request_id = frame.get("id") + if request_id is not None: + pending = self._pending.get(str(request_id)) + if pending is None or pending.future.done(): + continue + error = frame.get("error") + if isinstance(error, dict): + pending.future.set_exception( + RpcFault( + int(error.get("code") or -32000), + str(error.get("message") or "Hermes RPC failed"), + error.get("data"), + ) + ) + else: + pending.future.set_result(frame.get("result")) + continue + if frame.get("method") != "event" or not isinstance( + frame.get("params"), dict + ): + continue + event = frame["params"] + if event.get("type") == "gateway.ready": + payload = event.get("payload") + self.ready_epoch = ( + str(payload.get("replay_epoch") or "") + if isinstance(payload, dict) + else "" + ) + self.connected.set() + await self.on_event(event) + + async def _heartbeat(self, websocket: ClientConnection) -> None: + while websocket is self.websocket and not self._stop.is_set(): + await asyncio.sleep(15) + try: + await self.request("gateway.ping", {}, timeout=10) + except RpcFault: + with suppress(Exception): + await websocket.close(code=1011, reason="heartbeat failed") + return + + def _reject_pending(self) -> None: + for pending in list(self._pending.values()): + if pending.future.done(): + continue + if pending.written: + pending.future.set_exception(AmbiguousDelivery(pending.method)) + else: + pending.future.set_exception(UpstreamUnavailable()) diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py new file mode 100644 index 00000000..a329b0a8 --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py @@ -0,0 +1,122 @@ +"""Shared wire errors, limits and serialization; no bridge state.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import ipaddress +import json +import logging +import re +from typing import Any + + +LOG = logging.getLogger("hermes-menubar-bridge") +BRIDGE_VERSION = 1 +MAX_DOWNSTREAM_MESSAGE = 2 * 1024 * 1024 +MAX_UPSTREAM_MESSAGE = 384 * 1024 * 1024 +MAX_PROVIDER_RESPONSE = 4 * 1024 * 1024 +MAX_REMOTE_AUTH_RESPONSE = 2 * 1024 * 1024 +MAX_REMOTE_SSE_EVENT = 4 * 1024 * 1024 +MAX_REMOTE_STREAM_EVENT = 4 * 1024 * 1024 +MAX_REMOTE_ATTACHMENT_BYTES = 20 * 1024 * 1024 +MAX_REMOTE_ATTACHMENTS = 20 +REMOTE_HISTORY_PAGE = 80 +REMOTE_HISTORY_MAX_MESSAGES = 250 +REMOTE_HISTORY_MAX_TOOLS = 250 +MAX_REMOTE_TOOL_DETAIL = 4096 +MAX_REMOTE_REASONING = 12000 +DEFAULT_UPSTREAM = "http://127.0.0.1:9119" +DEFAULT_LISTEN = "127.0.0.1" +DEFAULT_PORT = 9120 +DEFAULT_PATH = "/ws" +# Live status churn (thinking/tool progress) is coalesced into one registry +# write per window; explicit saves and shutdown still write immediately. +REGISTRY_SAVE_DELAY = 1.0 +# Frames buffered per local client. A shell that stops reading falls behind +# by this many frames and is disconnected rather than stalling the event +# fan-out that also carries the upstream heartbeat. +MAX_CLIENT_BACKLOG = 2048 +TOKEN_PATTERN = re.compile( + r"window\.__HERMES_SESSION_TOKEN__\s*=\s*(\"(?:\\.|[^\"\\])*\")" +) +CONVERSATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$") +REMOTE_AUTH_VERSION = 1 +# Remote observer streams (the session list and the selected session) are +# long-lived. Only one that stayed open this long proves the server healthy +# and resets reconnect backoff; a server that accepts and promptly closes, or +# fails, is retried with jittered exponential delays between the floor and +# the cap instead of at a fixed sub-second rate. +REMOTE_OBSERVER_HEALTHY_SECONDS = 60.0 +REMOTE_OBSERVER_RETRY_FLOOR = 3.0 +REMOTE_OBSERVER_RETRY_CAP = 120.0 +# Session-list invalidations arrive in bursts. They share one in-flight list +# refresh plus at most one trailing refresh, started at least this far apart. +REMOTE_REFRESH_SPACING = 1.0 + + +class RpcFault(Exception): + """A JSON-RPC error safe to return to the local client.""" + + def __init__(self, code: int, message: str, data: Any = None): + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +class UpstreamUnavailable(RpcFault): + def __init__(self, message: str = "Hermes is offline"): + super().__init__(-32010, message) + + +class AmbiguousDelivery(RpcFault): + """The socket dropped after a write, so the server may have accepted it.""" + + def __init__(self, method: str): + super().__init__( + -32011, + f"Hermes disconnected while {method} was in flight; its outcome is " + "unknown and the bridge did not retry it", + {"method": method, "deliveryUnknown": True, "replayed": False}, + ) + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ) + + +def json_frame(frame: dict[str, Any]) -> str: + return json.dumps(frame, ensure_ascii=False, separators=(",", ":")) + + +def rpc_result(request_id: Any, result: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def rpc_error(request_id: Any, fault: RpcFault) -> dict[str, Any]: + error: dict[str, Any] = {"code": fault.code, "message": fault.message} + if fault.data is not None: + error["data"] = fault.data + return {"jsonrpc": "2.0", "id": request_id, "error": error} + + +def event_frame(event_type: str, payload: dict[str, Any]) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "method": "event", + "params": {"type": event_type, "payload": payload}, + } + + +def slugify(name: str) -> str: + value = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") + return (value or "conversation")[:48] + + +def is_loopback(host: str) -> bool: + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return host.lower() == "localhost" diff --git a/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py new file mode 100644 index 00000000..6f678649 --- /dev/null +++ b/roles/desktop/files/hermes-menubar-bridge/cybex_hermes/registry.py @@ -0,0 +1,182 @@ +"""Atomic, coalesced conversation metadata persistence.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +import json +import os +from pathlib import Path +from typing import Any + + +from .protocol import ( + LOG, + BRIDGE_VERSION, + REGISTRY_SAVE_DELAY, + CONVERSATION_ID_PATTERN, + utc_now, +) + +def default_conversations() -> list[dict[str, Any]]: + # New chat is a client-side virtual selection, not a persisted session. + # Historical rows are hydrated from the WebUI's native /api/sessions list. + return [] + + +class ConversationRegistry: + """Small atomic JSON registry; prompts and Hermes credentials never enter it.""" + + def __init__(self, path: Path): + self.path = path + self.conversations: dict[str, dict[str, Any]] = {} + self.selected_conversation_id = "" + self._save_handle: asyncio.TimerHandle | None = None + self._load() + + def _load(self) -> None: + document: dict[str, Any] | None = None + try: + document = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + pass + except (OSError, json.JSONDecodeError, TypeError) as exc: + LOG.error("could not load conversation registry %s: %s", self.path, exc) + + rows = document.get("conversations") if isinstance(document, dict) else None + if isinstance(rows, list): + for row in rows: + conversation = self._coerce_conversation(row) + if conversation is not None and conversation["id"] not in self.conversations: + self.conversations[conversation["id"]] = conversation + + # Selection intentionally never survives a bridge restart. The widget + # always opens on a fresh chat while the list remains available. + self.selected_conversation_id = "" + + @staticmethod + def _coerce_conversation(row: Any) -> dict[str, Any] | None: + if not isinstance(row, dict): + return None + conversation_id = str( + row.get("session_id") or row.get("sessionId") or row.get("id") or "" + ).strip() + title = str(row.get("title") or row.get("name") or "Untitled chat").strip() + if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id): + return None + status = str(row.get("status") or "idle") + if status not in { + "idle", + "working", + "waiting", + "done", + "error", + "offline", + "reconnecting", + }: + status = "idle" + stored_session_id = str( + row.get("stored_session_id") or row.get("storedSessionId") or "" + )[:256] + remote_origin = str( + row.get("remote_origin") or row.get("remoteOrigin") or "" + )[:2048] + remote_session_id = str( + row.get("remote_session_id") or row.get("remoteSessionId") or "" + )[:256] + return { + "id": conversation_id, + "name": title[:160], + "title": title[:160] or "Untitled chat", + "brief": str(row.get("brief") or "")[:4000], + "profile": str(row.get("profile") or "")[:128], + "cwd": str(row.get("cwd") or "")[:4096], + "stored_session_id": stored_session_id, + "remote_origin": remote_origin, + "remote_session_id": remote_session_id, + # Missing on old registries: be conservative and assume a durable + # id may contain user history. Only known-empty lazy sessions are + # safe to recreate after a session-not-found resume. + "has_messages": bool( + row.get("has_messages", bool(stored_session_id or remote_session_id)) + ), + "status": status, + "status_text": str(row.get("status_text") or "Ready")[:240], + "unread": bool(row.get("unread", False)), + "updated_at": str(row.get("updated_at") or utc_now()), + "created_at": str(row.get("created_at") or ""), + "model": str(row.get("model") or "")[:256], + "model_provider": str( + row.get("model_provider") or row.get("modelProvider") or "" + )[:128], + "source": str( + row.get("source") + or row.get("source_label") + or row.get("sourceLabel") + or row.get("session_source") + or row.get("sessionSource") + or "" + )[:128], + "read_only": bool(row.get("read_only", row.get("readOnly", False))), + "message_count": ConversationRegistry._message_count( + row.get("message_count") + ), + } + + @staticmethod + def _message_count(value: Any) -> int: + try: + return max(0, int(value or 0)) + except (TypeError, ValueError, OverflowError): + return 0 + + def save_later(self, delay: float = REGISTRY_SAVE_DELAY) -> None: + """Coalesce frequent status writes into one atomic save per window.""" + if self._save_handle is not None: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + self.save() + return + self._save_handle = loop.call_later(delay, self._deferred_save) + + def _deferred_save(self) -> None: + self._save_handle = None + try: + self.save() + except OSError as exc: + LOG.error("could not save conversation registry %s: %s", self.path, exc) + + def flush(self) -> None: + """Write a pending coalesced save now (used on shutdown).""" + if self._save_handle is not None: + self.save() + + def save(self) -> None: + if self._save_handle is not None: + self._save_handle.cancel() + self._save_handle = None + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with suppress(OSError): + os.chmod(self.path.parent, 0o700) + document = { + "version": BRIDGE_VERSION, + "selected_conversation_id": self.selected_conversation_id, + "conversations": list(self.conversations.values()), + } + temporary = self.path.with_name(f".{self.path.name}.tmp.{os.getpid()}") + descriptor = os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(document, stream, ensure_ascii=False, indent=2) + 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() diff --git a/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py b/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py index 3f31fa27..885b390c 100644 --- a/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py +++ b/roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py @@ -22,139 +22,88 @@ import argparse import asyncio from contextlib import suppress -from dataclasses import dataclass, field from datetime import datetime, timezone import hashlib -from http.cookiejar import Cookie, CookieJar -import ipaddress import json import logging import mimetypes import os from pathlib import Path import random -import re import signal import stat import sys import threading import time from typing import Any, Awaitable, Callable -from urllib.error import HTTPError, URLError -from urllib.parse import quote, urljoin, urlparse, urlunparse -from urllib.request import ( - HTTPRedirectHandler, - HTTPCookieProcessor, - Request, - build_opener, - urlopen, -) +from urllib.parse import quote, urlparse import uuid import websockets -from websockets.asyncio.client import ClientConnection from websockets.asyncio.server import ServerConnection from websockets.exceptions import ConnectionClosed -LOG = logging.getLogger("hermes-menubar-bridge") -BRIDGE_VERSION = 1 -MAX_DOWNSTREAM_MESSAGE = 2 * 1024 * 1024 -MAX_UPSTREAM_MESSAGE = 384 * 1024 * 1024 -MAX_PROVIDER_RESPONSE = 4 * 1024 * 1024 -MAX_REMOTE_AUTH_RESPONSE = 2 * 1024 * 1024 -MAX_REMOTE_SSE_EVENT = 4 * 1024 * 1024 -MAX_REMOTE_STREAM_EVENT = 4 * 1024 * 1024 -MAX_REMOTE_ATTACHMENT_BYTES = 20 * 1024 * 1024 -MAX_REMOTE_ATTACHMENTS = 20 -REMOTE_HISTORY_PAGE = 80 -REMOTE_HISTORY_MAX_MESSAGES = 250 -REMOTE_HISTORY_MAX_TOOLS = 250 -MAX_REMOTE_TOOL_DETAIL = 4096 -MAX_REMOTE_REASONING = 12000 -DEFAULT_UPSTREAM = "http://127.0.0.1:9119" -DEFAULT_LISTEN = "127.0.0.1" -DEFAULT_PORT = 9120 -DEFAULT_PATH = "/ws" -# Live status churn (thinking/tool progress) is coalesced into one registry -# write per window; explicit saves and shutdown still write immediately. -REGISTRY_SAVE_DELAY = 1.0 -# Frames buffered per local client. A shell that stops reading falls behind -# by this many frames and is disconnected rather than stalling the event -# fan-out that also carries the upstream heartbeat. -MAX_CLIENT_BACKLOG = 2048 -TOKEN_PATTERN = re.compile( - r"window\.__HERMES_SESSION_TOKEN__\s*=\s*(\"(?:\\.|[^\"\\])*\")" +from cybex_hermes.protocol import ( + LOG as LOG, + BRIDGE_VERSION as BRIDGE_VERSION, + MAX_DOWNSTREAM_MESSAGE as MAX_DOWNSTREAM_MESSAGE, + MAX_UPSTREAM_MESSAGE as MAX_UPSTREAM_MESSAGE, + MAX_PROVIDER_RESPONSE as MAX_PROVIDER_RESPONSE, + MAX_REMOTE_AUTH_RESPONSE as MAX_REMOTE_AUTH_RESPONSE, + MAX_REMOTE_SSE_EVENT as MAX_REMOTE_SSE_EVENT, + MAX_REMOTE_STREAM_EVENT as MAX_REMOTE_STREAM_EVENT, + MAX_REMOTE_ATTACHMENT_BYTES as MAX_REMOTE_ATTACHMENT_BYTES, + MAX_REMOTE_ATTACHMENTS as MAX_REMOTE_ATTACHMENTS, + REMOTE_HISTORY_PAGE as REMOTE_HISTORY_PAGE, + REMOTE_HISTORY_MAX_MESSAGES as REMOTE_HISTORY_MAX_MESSAGES, + REMOTE_HISTORY_MAX_TOOLS as REMOTE_HISTORY_MAX_TOOLS, + MAX_REMOTE_TOOL_DETAIL as MAX_REMOTE_TOOL_DETAIL, + MAX_REMOTE_REASONING as MAX_REMOTE_REASONING, + DEFAULT_UPSTREAM as DEFAULT_UPSTREAM, + DEFAULT_LISTEN as DEFAULT_LISTEN, + DEFAULT_PORT as DEFAULT_PORT, + DEFAULT_PATH as DEFAULT_PATH, + REGISTRY_SAVE_DELAY as REGISTRY_SAVE_DELAY, + MAX_CLIENT_BACKLOG as MAX_CLIENT_BACKLOG, + TOKEN_PATTERN as TOKEN_PATTERN, + CONVERSATION_ID_PATTERN as CONVERSATION_ID_PATTERN, + REMOTE_AUTH_VERSION as REMOTE_AUTH_VERSION, + REMOTE_OBSERVER_HEALTHY_SECONDS as REMOTE_OBSERVER_HEALTHY_SECONDS, + REMOTE_OBSERVER_RETRY_FLOOR as REMOTE_OBSERVER_RETRY_FLOOR, + REMOTE_OBSERVER_RETRY_CAP as REMOTE_OBSERVER_RETRY_CAP, + REMOTE_REFRESH_SPACING as REMOTE_REFRESH_SPACING, + RpcFault as RpcFault, + UpstreamUnavailable as UpstreamUnavailable, + AmbiguousDelivery as AmbiguousDelivery, + utc_now as utc_now, + json_frame as json_frame, + rpc_result as rpc_result, + rpc_error as rpc_error, + event_frame as event_frame, + slugify as slugify, + is_loopback as is_loopback, +) +from cybex_hermes.auth import ( + _RemoteAuthRequired as _RemoteAuthRequired, + _RemoteRedirectBlocked as _RemoteRedirectBlocked, + _RemoteTransportError as _RemoteTransportError, + _remote_origin as _remote_origin, + normalize_remote_url as normalize_remote_url, + _is_login_url as _is_login_url, + _SameOriginRedirectHandler as _SameOriginRedirectHandler, + RemoteLoginFault as RemoteLoginFault, + RemoteWebUIAuth as RemoteWebUIAuth, +) +from cybex_hermes.registry import ( + default_conversations as default_conversations, + ConversationRegistry as ConversationRegistry, +) +from cybex_hermes.gateway import ( + LocalClient as LocalClient, + PendingUpstream as PendingUpstream, + HermesGateway as HermesGateway, ) -CONVERSATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$") -REMOTE_AUTH_VERSION = 1 -# Remote observer streams (the session list and the selected session) are -# long-lived. Only one that stayed open this long proves the server healthy -# and resets reconnect backoff; a server that accepts and promptly closes, or -# fails, is retried with jittered exponential delays between the floor and -# the cap instead of at a fixed sub-second rate. -REMOTE_OBSERVER_HEALTHY_SECONDS = 60.0 -REMOTE_OBSERVER_RETRY_FLOOR = 3.0 -REMOTE_OBSERVER_RETRY_CAP = 120.0 -# Session-list invalidations arrive in bursts. They share one in-flight list -# refresh plus at most one trailing refresh, started at least this far apart. -REMOTE_REFRESH_SPACING = 1.0 - - -class RpcFault(Exception): - """A JSON-RPC error safe to return to the local client.""" - - def __init__(self, code: int, message: str, data: Any = None): - super().__init__(message) - self.code = code - self.message = message - self.data = data - - -class UpstreamUnavailable(RpcFault): - def __init__(self, message: str = "Hermes is offline"): - super().__init__(-32010, message) - - -class AmbiguousDelivery(RpcFault): - """The socket dropped after a write, so the server may have accepted it.""" - - def __init__(self, method: str): - super().__init__( - -32011, - f"Hermes disconnected while {method} was in flight; its outcome is " - "unknown and the bridge did not retry it", - {"method": method, "deliveryUnknown": True, "replayed": False}, - ) - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace( - "+00:00", "Z" - ) - - -def json_frame(frame: dict[str, Any]) -> str: - return json.dumps(frame, ensure_ascii=False, separators=(",", ":")) - - -def rpc_result(request_id: Any, result: Any) -> dict[str, Any]: - return {"jsonrpc": "2.0", "id": request_id, "result": result} - - -def rpc_error(request_id: Any, fault: RpcFault) -> dict[str, Any]: - error: dict[str, Any] = {"code": fault.code, "message": fault.message} - if fault.data is not None: - error["data"] = fault.data - return {"jsonrpc": "2.0", "id": request_id, "error": error} - - -def event_frame(event_type: str, payload: dict[str, Any]) -> dict[str, Any]: - return { - "jsonrpc": "2.0", - "method": "event", - "params": {"type": event_type, "payload": payload}, - } def remote_observer_retry_delay(failures: int) -> float: @@ -176,1805 +125,6 @@ def remote_observer_failures(failures: int, connected_at: float | None) -> int: return failures + 1 -def slugify(name: str) -> str: - value = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") - return (value or "conversation")[:48] - - -def is_loopback(host: str) -> bool: - try: - return ipaddress.ip_address(host).is_loopback - except ValueError: - return host.lower() == "localhost" - - -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) - - -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() - 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", - ) - - @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) - - @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": "cybexos-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 asyncio.to_thread(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["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 asyncio.to_thread(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 asyncio.to_thread(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, - ) -> dict[str, Any]: - """Reusable authenticated request primitive for the remote adapter.""" - - return await asyncio.to_thread( - self._request_json_sync, method, path, payload, timeout - ) - - 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 - ) -> int: - """Return a multipart route's status after a fully consumed empty form.""" - - return await asyncio.to_thread( - self._probe_multipart_route_sync, path, timeout - ) - - def _probe_multipart_route_sync(self, path: str, timeout: float) -> 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() - 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) - 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, - ) -> dict[str, Any]: - base_url, jar = self._current_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) - 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) -> dict[str, Any]: - """Read the WebUI's non-streaming SSE capability probe and server tag.""" - - return await asyncio.to_thread(self._probe_contract_sync, timeout) - - def _probe_contract_sync(self, timeout: float) -> dict[str, Any]: - base_url, jar = self._current_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 - - 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) -> tuple[str, CookieJar]: - """Snapshot the origin and cookie jar one unlocked request will use.""" - - with self._lock: - 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, - ) -> 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() - 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": "cybexos-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" - ) - 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 {} - - -def default_conversations() -> list[dict[str, Any]]: - # New chat is a client-side virtual selection, not a persisted session. - # Historical rows are hydrated from the WebUI's native /api/sessions list. - return [] - - -class ConversationRegistry: - """Small atomic JSON registry; prompts and Hermes credentials never enter it.""" - - def __init__(self, path: Path): - self.path = path - self.conversations: dict[str, dict[str, Any]] = {} - self.selected_conversation_id = "" - self._save_handle: asyncio.TimerHandle | None = None - self._load() - - def _load(self) -> None: - document: dict[str, Any] | None = None - try: - document = json.loads(self.path.read_text(encoding="utf-8")) - except FileNotFoundError: - pass - except (OSError, json.JSONDecodeError, TypeError) as exc: - LOG.error("could not load conversation registry %s: %s", self.path, exc) - - rows = document.get("conversations") if isinstance(document, dict) else None - if isinstance(rows, list): - for row in rows: - conversation = self._coerce_conversation(row) - if conversation is not None and conversation["id"] not in self.conversations: - self.conversations[conversation["id"]] = conversation - - # Selection intentionally never survives a bridge restart. The widget - # always opens on a fresh chat while the list remains available. - self.selected_conversation_id = "" - - @staticmethod - def _coerce_conversation(row: Any) -> dict[str, Any] | None: - if not isinstance(row, dict): - return None - conversation_id = str( - row.get("session_id") or row.get("sessionId") or row.get("id") or "" - ).strip() - title = str(row.get("title") or row.get("name") or "Untitled chat").strip() - if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id): - return None - status = str(row.get("status") or "idle") - if status not in { - "idle", - "working", - "waiting", - "done", - "error", - "offline", - "reconnecting", - }: - status = "idle" - stored_session_id = str( - row.get("stored_session_id") or row.get("storedSessionId") or "" - )[:256] - remote_origin = str( - row.get("remote_origin") or row.get("remoteOrigin") or "" - )[:2048] - remote_session_id = str( - row.get("remote_session_id") or row.get("remoteSessionId") or "" - )[:256] - return { - "id": conversation_id, - "name": title[:160], - "title": title[:160] or "Untitled chat", - "brief": str(row.get("brief") or "")[:4000], - "profile": str(row.get("profile") or "")[:128], - "cwd": str(row.get("cwd") or "")[:4096], - "stored_session_id": stored_session_id, - "remote_origin": remote_origin, - "remote_session_id": remote_session_id, - # Missing on old registries: be conservative and assume a durable - # id may contain user history. Only known-empty lazy sessions are - # safe to recreate after a session-not-found resume. - "has_messages": bool( - row.get("has_messages", bool(stored_session_id or remote_session_id)) - ), - "status": status, - "status_text": str(row.get("status_text") or "Ready")[:240], - "unread": bool(row.get("unread", False)), - "updated_at": str(row.get("updated_at") or utc_now()), - "created_at": str(row.get("created_at") or ""), - "model": str(row.get("model") or "")[:256], - "model_provider": str( - row.get("model_provider") or row.get("modelProvider") or "" - )[:128], - "source": str( - row.get("source") - or row.get("source_label") - or row.get("sourceLabel") - or row.get("session_source") - or row.get("sessionSource") - or "" - )[:128], - "read_only": bool(row.get("read_only", row.get("readOnly", False))), - "message_count": ConversationRegistry._message_count( - row.get("message_count") - ), - } - - @staticmethod - def _message_count(value: Any) -> int: - try: - return max(0, int(value or 0)) - except (TypeError, ValueError, OverflowError): - return 0 - - def save_later(self, delay: float = REGISTRY_SAVE_DELAY) -> None: - """Coalesce frequent status writes into one atomic save per window.""" - if self._save_handle is not None: - return - try: - loop = asyncio.get_running_loop() - except RuntimeError: - self.save() - return - self._save_handle = loop.call_later(delay, self._deferred_save) - - def _deferred_save(self) -> None: - self._save_handle = None - try: - self.save() - except OSError as exc: - LOG.error("could not save conversation registry %s: %s", self.path, exc) - - def flush(self) -> None: - """Write a pending coalesced save now (used on shutdown).""" - if self._save_handle is not None: - self.save() - - def save(self) -> None: - if self._save_handle is not None: - self._save_handle.cancel() - self._save_handle = None - self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with suppress(OSError): - os.chmod(self.path.parent, 0o700) - document = { - "version": BRIDGE_VERSION, - "selected_conversation_id": self.selected_conversation_id, - "conversations": list(self.conversations.values()), - } - temporary = self.path.with_name(f".{self.path.name}.tmp.{os.getpid()}") - descriptor = os.open( - temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 - ) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - json.dump(document, stream, ensure_ascii=False, indent=2) - 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() - - -@dataclass(eq=False) -class LocalClient: - """One loopback shell connection with its own bounded outbound queue. - - Producers never await the socket: a dedicated writer task drains the - queue, so one stalled client cannot block the upstream reader (and with - it the gateway heartbeat) or delay delivery to other clients. A client - that falls MAX_CLIENT_BACKLOG frames behind is disconnected; the shell - reconnects and reconciles through its normal hello/list/history path. - """ - - websocket: ServerConnection - tasks: set[asyncio.Task[Any]] = field(default_factory=set) - backlog: int = MAX_CLIENT_BACKLOG - closed: bool = False - queue: asyncio.Queue[str] = field(init=False) - writer: asyncio.Task[Any] | None = field(default=None, init=False) - - def __post_init__(self) -> None: - self.queue = asyncio.Queue(maxsize=max(1, self.backlog)) - - def start(self) -> None: - if self.writer is None: - self.writer = asyncio.create_task( - self._write(), name="hermes-local-writer" - ) - - async def _write(self) -> None: - try: - while True: - text = await self.queue.get() - await self.websocket.send(text) - except ConnectionClosed: - pass - except asyncio.CancelledError: - raise - except Exception as exc: - LOG.debug("local client write failed: %s", exc) - finally: - self.closed = True - - def enqueue_text(self, text: str) -> bool: - if self.closed: - return False - try: - self.queue.put_nowait(text) - except asyncio.QueueFull: - LOG.warning( - "local Hermes client fell %d frames behind; disconnecting it", - self.queue.maxsize, - ) - self.abort("client too slow") - return False - return True - - def abort(self, reason: str) -> None: - if self.closed: - return - self.closed = True - if self.writer is not None: - self.writer.cancel() - closer = asyncio.create_task( - self.websocket.close(code=1013, reason=reason), - name="hermes-local-close", - ) - self.tasks.add(closer) - closer.add_done_callback(self._closed) - - def _closed(self, task: asyncio.Task[Any]) -> None: - self.tasks.discard(task) - if not task.cancelled(): - task.exception() - - async def stop(self) -> None: - self.closed = True - if self.writer is not None: - self.writer.cancel() - with suppress(asyncio.CancelledError, Exception): - await self.writer - - async def send(self, frame: dict[str, Any]) -> None: - self.enqueue_text(json_frame(frame)) - - -@dataclass -class PendingUpstream: - method: str - future: asyncio.Future[Any] - written: bool = False - - -class HermesGateway: - """Authenticated, reconnecting JSON-RPC client for ``hermes serve``.""" - - def __init__( - self, - base_url: str, - on_event: Callable[[dict[str, Any]], Awaitable[None]], - on_state: Callable[[str, str], Awaitable[None]], - on_ready: Callable[[str], Awaitable[None]], - ): - self.base_url = base_url.rstrip("/") - self.on_event = on_event - self.on_state = on_state - self.on_ready = on_ready - self.websocket: ClientConnection | None = None - self.connected = asyncio.Event() - self.ready_epoch = "" - self._pending: dict[str, PendingUpstream] = {} - self._next_id = 0 - self._send_lock = asyncio.Lock() - self._stop = asyncio.Event() - self._runner: asyncio.Task[Any] | None = None - - def start(self) -> None: - if self._runner is None: - self._runner = asyncio.create_task(self._run(), name="hermes-upstream") - - async def stop(self) -> None: - self._stop.set() - websocket = self.websocket - if websocket is not None: - with suppress(Exception): - await websocket.close(code=1001, reason="bridge stopping") - if self._runner is not None: - self._runner.cancel() - with suppress(asyncio.CancelledError): - await self._runner - - async def request( - self, method: str, params: dict[str, Any] | None = None, timeout: float = 30.0 - ) -> Any: - if not self.connected.is_set() or self.websocket is None: - raise UpstreamUnavailable() - self._next_id += 1 - request_id = f"menubar-{self._next_id}" - future = asyncio.get_running_loop().create_future() - pending = PendingUpstream(method=method, future=future) - self._pending[request_id] = pending - frame = { - "jsonrpc": "2.0", - "id": request_id, - "method": method, - "params": params or {}, - } - try: - async with self._send_lock: - websocket = self.websocket - if websocket is None: - raise UpstreamUnavailable() - await websocket.send(json_frame(frame)) - pending.written = True - return await asyncio.wait_for(future, timeout=timeout) - except asyncio.TimeoutError as exc: - self._pending.pop(request_id, None) - if method == "prompt.submit" and pending.written: - raise AmbiguousDelivery(method) from exc - raise RpcFault( - -32012, - f"Hermes did not answer {method} within {int(timeout)} seconds", - {"method": method}, - ) from exc - except ConnectionClosed as exc: - self._pending.pop(request_id, None) - if pending.written: - raise AmbiguousDelivery(method) from exc - raise UpstreamUnavailable() from exc - finally: - self._pending.pop(request_id, None) - - async def api_request( - self, - method: str, - path: str, - payload: dict[str, Any] | None = None, - timeout: float = 20.0, - ) -> Any: - """Call an authenticated Hermes dashboard API without exposing its token. - - Provider setup is a dashboard REST API rather than a gateway RPC. The - bridge obtains the same private session token it already uses for the - upstream WebSocket and keeps both that token and submitted credentials - out of downstream responses and logs. - """ - return await asyncio.to_thread( - self._api_request_sync, method, path, payload, timeout - ) - - def _api_request_sync( - self, - method: str, - path: str, - payload: dict[str, Any] | None, - timeout: float, - ) -> Any: - if not path.startswith("/api/") or "://" in path: - raise RpcFault(-32602, "invalid Hermes API path") - try: - token = self._fetch_token() - except Exception as exc: - raise UpstreamUnavailable("Hermes provider API is unavailable") from exc - body = ( - json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode( - "utf-8" - ) - if payload is not None - else None - ) - headers = { - "Accept": "application/json", - "User-Agent": "cybexos-hermes-menubar-bridge/1", - "X-Hermes-Session-Token": token, - } - if body is not None: - headers["Content-Type"] = "application/json" - request = Request( - f"{self.base_url}{path}", - data=body, - method=method.upper(), - headers=headers, - ) - try: - with urlopen(request, timeout=timeout) as response: - raw = response.read(MAX_PROVIDER_RESPONSE + 1) - except HTTPError as exc: - raw = exc.read(MAX_PROVIDER_RESPONSE + 1) - message = self._api_error_message(raw) - raise RpcFault( - -32030, - message or f"Hermes provider API returned HTTP {exc.code}", - ) from exc - except (URLError, TimeoutError, OSError) as exc: - raise UpstreamUnavailable("Hermes provider API is unavailable") from exc - if len(raw) > MAX_PROVIDER_RESPONSE: - raise RpcFault(-32030, "Hermes provider API response is too large") - if not raw: - return {} - try: - return json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError, TypeError) as exc: - raise RpcFault(-32030, "Hermes provider API returned invalid JSON") from exc - - @staticmethod - def _api_error_message(raw: bytes) -> str: - try: - value = json.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError, TypeError): - return "" - if not isinstance(value, dict): - return "" - detail = value.get("detail") or value.get("message") or value.get("error") - return str(detail)[:500] if isinstance(detail, (str, int, float)) else "" - - async def _run(self) -> None: - backoff = 0.5 - while not self._stop.is_set(): - await self.on_state("connecting", "Connecting to Hermes…") - receiver: asyncio.Task[Any] | None = None - heartbeat: asyncio.Task[Any] | None = None - try: - token = await asyncio.to_thread(self._fetch_token) - websocket_url = self._websocket_url(token) - async with websockets.connect( - websocket_url, - open_timeout=15, - close_timeout=5, - max_size=MAX_UPSTREAM_MESSAGE, - ping_interval=20, - ping_timeout=45, - ) as websocket: - self.websocket = websocket - receiver = asyncio.create_task( - self._receive(websocket), name="hermes-upstream-receive" - ) - await asyncio.wait_for(self.connected.wait(), timeout=30) - backoff = 0.5 - await self.on_state("connected", "Hermes connected") - ready_task = asyncio.create_task( - self.on_ready(self.ready_epoch), name="hermes-reconcile" - ) - ready_task.add_done_callback(self._log_background_failure) - heartbeat = asyncio.create_task( - self._heartbeat(websocket), name="hermes-upstream-heartbeat" - ) - await receiver - except asyncio.CancelledError: - raise - except Exception as exc: - if not self._stop.is_set(): - LOG.warning("Hermes connection unavailable: %s", exc) - finally: - self.connected.clear() - self.websocket = None - for task in (receiver, heartbeat): - if task is not None and not task.done(): - task.cancel() - self._reject_pending() - - if self._stop.is_set(): - break - await self.on_state("reconnecting", "Reconnecting to Hermes…") - try: - await asyncio.wait_for( - self._stop.wait(), timeout=backoff + random.random() * 0.25 - ) - except asyncio.TimeoutError: - pass - backoff = min(backoff * 2, 15.0) - - @staticmethod - def _log_background_failure(task: asyncio.Task[Any]) -> None: - if task.cancelled(): - return - exc = task.exception() - if exc is not None: - LOG.error("Hermes reconciliation failed: %s", exc) - - def _fetch_token(self) -> str: - configured = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN", "").strip() - if configured: - return configured - request = Request( - f"{self.base_url}/", - headers={"User-Agent": "cybexos-hermes-menubar-bridge/1"}, - ) - with urlopen(request, timeout=10) as response: - body = response.read(1024 * 1024).decode("utf-8", errors="replace") - match = TOKEN_PATTERN.search(body) - if not match: - raise RuntimeError("Hermes headless token was not present at the root URL") - token = json.loads(match.group(1)) - if not isinstance(token, str) or not token: - raise RuntimeError("Hermes returned an invalid headless token") - return token - - def _websocket_url(self, token: str) -> str: - parsed = urlparse(self.base_url) - if parsed.scheme not in {"http", "https"}: - raise RuntimeError("Hermes upstream must use http:// or https://") - scheme = "wss" if parsed.scheme == "https" else "ws" - path = f"{parsed.path.rstrip('/')}/api/ws" - return urlunparse( - (scheme, parsed.netloc, path, "", f"token={quote(token, safe='')}", "") - ) - - async def _receive(self, websocket: ClientConnection) -> None: - async for raw in websocket: - if not isinstance(raw, str): - continue - try: - frame = json.loads(raw) - except (json.JSONDecodeError, TypeError): - LOG.warning("Hermes sent malformed JSON") - continue - if not isinstance(frame, dict): - continue - request_id = frame.get("id") - if request_id is not None: - pending = self._pending.get(str(request_id)) - if pending is None or pending.future.done(): - continue - error = frame.get("error") - if isinstance(error, dict): - pending.future.set_exception( - RpcFault( - int(error.get("code") or -32000), - str(error.get("message") or "Hermes RPC failed"), - error.get("data"), - ) - ) - else: - pending.future.set_result(frame.get("result")) - continue - if frame.get("method") != "event" or not isinstance( - frame.get("params"), dict - ): - continue - event = frame["params"] - if event.get("type") == "gateway.ready": - payload = event.get("payload") - self.ready_epoch = ( - str(payload.get("replay_epoch") or "") - if isinstance(payload, dict) - else "" - ) - self.connected.set() - await self.on_event(event) - - async def _heartbeat(self, websocket: ClientConnection) -> None: - while websocket is self.websocket and not self._stop.is_set(): - await asyncio.sleep(15) - try: - await self.request("gateway.ping", {}, timeout=10) - except RpcFault: - with suppress(Exception): - await websocket.close(code=1011, reason="heartbeat failed") - return - - def _reject_pending(self) -> None: - for pending in list(self._pending.values()): - if pending.future.done(): - continue - if pending.written: - pending.future.set_exception(AmbiguousDelivery(pending.method)) - else: - pending.future.set_exception(UpstreamUnavailable()) - - class HermesBridge: def __init__( self, diff --git a/roles/desktop/files/hyprland.lua b/roles/desktop/files/hyprland.lua index 8cc91305..b738e0f3 100644 --- a/roles/desktop/files/hyprland.lua +++ b/roles/desktop/files/hyprland.lua @@ -26,7 +26,7 @@ package.path = source_dir .. "/?.lua;" .. source_dir .. "/?/init.lua;" .. generated_dir .. "/?.lua;" .. generated_dir .. "/?/init.lua;" .. user_dir .. "/?.lua;" .. user_dir .. "/?/init.lua;" .. package.path -for _, module in ipairs({ "features", "monitors", "input", "bindings", "looknfeel", "autostart", "displays" }) do +for _, module in ipairs({ "features", "monitors", "input", "input_preferences", "bindings", "looknfeel", "autostart", "displays" }) do package.loaded[module] = nil end @@ -40,6 +40,8 @@ if not displays_ok then _G.__cybexos_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 require("bindings") require("looknfeel") require("autostart") diff --git a/roles/desktop/files/input_preferences.lua b/roles/desktop/files/input_preferences.lua new file mode 100644 index 00000000..8edb28ed --- /dev/null +++ b/roles/desktop/files/input_preferences.lua @@ -0,0 +1,69 @@ +-- Personal input data loads after vendor input and before user.lua. Reuse the +-- display module's bounded JSON reader; strings from this file are never code. +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" +local shortcuts = { [""] = true, + ["grp:alt_shift_toggle"] = true, ["grp:ctrl_shift_toggle"] = true, ["grp:caps_toggle"] = true } +local function token(value) + return type(value) == "string" and #value >= 1 and #value <= 64 and value:match("^[%w_-]+$") +end +function M.apply(document) + assert(type(document) == "table" and document.v == 1, "unsupported input preferences version") + local keyboard = document.keyboard == nil and {} or document.keyboard + local touchpad = document.touchpad == nil and {} or document.touchpad + assert(type(keyboard) == "table" and keyboard.n == nil and keyboard ~= json.null, "invalid keyboard preferences") + assert(type(touchpad) == "table" and touchpad.n == nil and touchpad ~= json.null, "invalid touchpad preferences") + local input = {} + if keyboard.layouts ~= nil then + assert(type(keyboard.layouts) == "table" and type(keyboard.layouts.n) == "number" + and keyboard.layouts.n >= 1 and keyboard.layouts.n <= 4, "invalid keyboard layouts") + local layouts, variants = {}, {} + for _, entry in ipairs(keyboard.layouts) do + assert(type(entry) == "table" and token(entry.layout), "invalid keyboard layout") + assert(entry.variant == nil or entry.variant == "" or token(entry.variant), "invalid keyboard variant") + layouts[#layouts + 1], variants[#variants + 1] = entry.layout, entry.variant or "" + end + input.kb_layout, input.kb_variant = table.concat(layouts, ","), table.concat(variants, ",") + end + if keyboard.shortcut ~= nil then + 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 + if not option:match("^grp:") and not (keyboard.shortcut == "grp:caps_toggle" and option == "compose:caps") then + options[#options + 1] = option + end + end + if keyboard.shortcut ~= "" then options[#options + 1] = keyboard.shortcut end + input.kb_options = table.concat(options, ",") + end + input.touchpad = {} + for saved, native in pairs({tap = "tap_to_click", naturalScroll = "natural_scroll"}) do + if touchpad[saved] ~= nil then + assert(type(touchpad[saved]) == "boolean", "invalid touchpad switch") + input.touchpad[native] = touchpad[saved] + end + end + if touchpad.sensitivity ~= nil then + assert(type(touchpad.sensitivity) == "number" and touchpad.sensitivity >= -1 and touchpad.sensitivity <= 1, + "invalid touchpad sensitivity") + -- Hyprland exposes pointer sensitivity at input level; touchpads use it + -- too, unless a user's per-device rule overrides it. + input.sensitivity = touchpad.sensitivity + end + hl.config({ input = input }) +end +local file = io.open(M.path, "rb") +if file then + local contents = file:read(json.MAX_BYTES + 1) + file:close() + 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 +else + _G.__cybexos_input_error = nil +end +return M diff --git a/roles/desktop/files/quickshell/Common/CommandRequest.qml b/roles/desktop/files/quickshell/Common/CommandRequest.qml new file mode 100644 index 00000000..575796ee --- /dev/null +++ b/roles/desktop/files/quickshell/Common/CommandRequest.qml @@ -0,0 +1,90 @@ +import QtQuick +import Quickshell.Io +import "ProcHelpers.js" as ProcHelpers + +// One bounded subprocess request. Owns launch failure, stream collection and +// cancellation; domain owners only receive a completed response. A timed-out +// process never publishes partial stdout as a successful response. +Item { + id: root + property alias command: process.command + // Request intent is separate from QProcess state. A failed start never + // emits running=true, so resetting on that edge would reuse the previous + // completion flag and strand every subsequent request. + property bool running: false + onRunningChanged: { + if (!running) { + if (process.running) + process.running = false; + return; + } + process.body = ""; + process.error = ""; + process.exitSeen = false; + process.exitCode = ProcHelpers.NOT_STARTED; + timedOut = false; + settled = false; + watchdog.interval = Math.max(1, timeoutMs); + if (timeoutMs > 0) + watchdog.restart(); + process.running = true; + } + property bool stdinEnabled: false + property string inputText: "" + property int timeoutMs: 30000 + property int killGraceMs: 5000 + property string timeoutMessage: "Command timed out" + property bool timedOut: false + property bool settled: false + signal completed(int code, string body, string error) + signal available() + + function finish(code, body, error) { + if (settled) + return; + settled = true; + completed(code, body, error); + } + + function expire() { + if (!process.running) + return; + if (!timedOut) { + timedOut = true; + watchdog.interval = killGraceMs; + watchdog.restart(); + process.running = false; + } else { + process.signal(9); + finish(124, "", timeoutMessage); + } + } + + Process { + id: process + stdinEnabled: root.stdinEnabled + onStarted: if (root.stdinEnabled) write(root.inputText) + property string body: "" + property string error: "" + property bool exitSeen: false + property int exitCode: ProcHelpers.NOT_STARTED + stdout: StdioCollector { onStreamFinished: process.body = text } + stderr: StdioCollector { onStreamFinished: process.error = text } + onExited: code => { + exitSeen = true; + exitCode = code; + } + onRunningChanged: { + if (running) + return; + root.running = false; + watchdog.stop(); + if (root.timedOut) + root.finish(124, "", root.timeoutMessage); + else + root.finish(exitSeen ? exitCode : ProcHelpers.NOT_STARTED, body, error); + root.available(); + } + } + Timer { id: watchdog; onTriggered: root.expire() } +} diff --git a/roles/desktop/files/quickshell/Common/GitHub.qml b/roles/desktop/files/quickshell/Common/GitHub.qml index 9b267509..c242d6f7 100644 --- a/roles/desktop/files/quickshell/Common/GitHub.qml +++ b/roles/desktop/files/quickshell/Common/GitHub.qml @@ -3,6 +3,7 @@ import QtQuick import Quickshell import Quickshell.Io import "GitHubHelpers.js" as Helpers +import "GitHubQueue.js" as Queue import "ProcHelpers.js" as ProcHelpers import "ExternalUrl.js" as ExternalUrl @@ -264,45 +265,15 @@ Singleton { property var active: null function jobKey(job) { - switch (job.kind) { - case "watch": return "watch:" + job.slug; - case "commits": return "commits:" + job.slug; - case "stats": return "stats:" + job.sha; - case "runs": return "runs:" + job.slug + ":" + job.generation; - case "events": return "events:" + job.slug + ":" + job.generation; - case "notifications": return "notifications:" + job.generation; - default: return job.kind; - } + return Queue.jobKey(job); } function enqueue(job) { - const key = jobKey(job); - if (active !== null && jobKey(active) === key) - return false; - const queuedAt = queue.findIndex(queued => jobKey(queued) === key); - if (queuedAt >= 0) { - // A popover request can overlap a background toast/cache read. - // Keep the richer existing job, but move it into the interactive - // lane so deduplication never costs the user's priority. - if (job.interactive === true && !queue[queuedAt].interactive) { - const promoted = Object.assign({}, queue[queuedAt], { interactive: true }); - const without = queue.slice(0, queuedAt).concat(queue.slice(queuedAt + 1)); - const firstBackground = without.findIndex(queued => !queued.interactive); - const at = firstBackground < 0 ? without.length : firstBackground; - queue = without.slice(0, at).concat([promoted], without.slice(at)); - } - return false; - } - const next = Object.assign({}, job, { interactive: job.interactive === true }); - if (!next.interactive) { - queue = queue.concat([next]); - } else { - const firstBackground = queue.findIndex(queued => !queued.interactive); - const at = firstBackground < 0 ? queue.length : firstBackground; - queue = queue.slice(0, at).concat([next], queue.slice(at)); - } - pump(); - return true; + const result = Queue.enqueue(queue, active, job); + queue = result.queue; + if (result.added) + pump(); + return result.added; } function pump() { @@ -333,8 +304,8 @@ Singleton { ghProc.command = command; // Armed before the launch: a binary that cannot start reports its // falling edge at once, and that edge is what stops the watchdog. - ghWatchdog.interval = Helpers.ghTimeoutMs(job); - ghWatchdog.restart(); + ghProc.timeoutMs = Helpers.ghTimeoutMs(job); + ghProc.timeoutMessage = Helpers.ghTimeoutMessage(job); ghProc.running = true; } @@ -363,31 +334,6 @@ Singleton { } } - // A stalled `gh api` would otherwise hold `active` forever: the queue - // stops, and `polling`/`inboxPolling` never fall, so no refresh can start. - // First firing: SIGTERM, reported through the normal falling edge as a - // timeout. If that edge still has not arrived after the grace period, - // SIGKILL and settle the job here so the flags are released regardless. - function ghWatchdogFired() { - if (active === null) - return; - if (ghProc.running && !ghProc.timedOut) { - ghProc.timedOut = true; - ghProc.timeoutText = Helpers.ghTimeoutMessage(active); - console.warn("github:", ghProc.timeoutText + ":", jobKey(active)); - ghWatchdog.interval = Helpers.GH_KILL_GRACE_MS; - ghWatchdog.restart(); - ghProc.running = false; - return; - } - const message = ghProc.timeoutText !== "" ? ghProc.timeoutText - : Helpers.ghTimeoutMessage(active); - if (ghProc.running) - ghProc.signal(9); - ghProc.abandoned = true; - settle(Helpers.GH_TIMEOUT_EXIT, "", message); - } - // Rate-limit headers arrive on every included response, 304s too. function noteRateLimit(headers) { const pause = Helpers.rateLimitPause(headers, Date.now()); @@ -1153,58 +1099,11 @@ Singleton { }); } - Process { + CommandRequest { id: ghProc - property string body: "" - property string errText: "" - property bool exitSeen: false - property int lastExit: 0 - // Watchdog state for the current run; see root.ghWatchdogFired(). - property bool timedOut: false - property bool abandoned: false - property string timeoutText: "" - - stdout: StdioCollector { - onStreamFinished: ghProc.body = text - } - stderr: StdioCollector { - onStreamFinished: ghProc.errText = text - } - onExited: (exitCode, exitStatus) => { - ghProc.exitSeen = true; - ghProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - errText = ""; - exitSeen = false; - lastExit = 0; - timedOut = false; - abandoned = false; - timeoutText = ""; - return; - } - ghWatchdog.stop(); - if (abandoned) { - // The watchdog already settled this job as timed out. - abandoned = false; - root.pump(); - return; - } - // A terminated run's partial output is not a response, and its - // exit status is whatever the signal left — it may even read as 0. - if (timedOut) - root.settle(Helpers.GH_TIMEOUT_EXIT, "", timeoutText); - else - root.settle(exitSeen ? lastExit : ProcHelpers.NOT_STARTED, body, errText); - } - } - - Timer { - id: ghWatchdog - interval: Helpers.GH_TIMEOUT_MS - onTriggered: root.ghWatchdogFired() + killGraceMs: Helpers.GH_KILL_GRACE_MS + onCompleted: (code, body, error) => root.settle(code, body, error) + onAvailable: root.pump() } Timer { diff --git a/roles/desktop/files/quickshell/Common/GitHubQueue.js b/roles/desktop/files/quickshell/Common/GitHubQueue.js new file mode 100644 index 00000000..1bcf0240 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/GitHubQueue.js @@ -0,0 +1,39 @@ +// Pure scheduling policy for gh jobs. No processes, timers or domain cache. +// Interactive work stays FIFO ahead of background polling; duplicate reads +// retain their richer original payload while being promoted when requested. +function jobKey(job) { + switch (job.kind) { + case "watch": return "watch:" + job.slug; + case "commits": return "commits:" + job.slug; + case "stats": return "stats:" + job.sha; + case "runs": return "runs:" + job.slug + ":" + job.generation; + case "events": return "events:" + job.slug + ":" + job.generation; + case "notifications": return "notifications:" + job.generation; + default: return job.kind; + } +} + +function enqueue(queue, active, job) { + const key = jobKey(job); + if (active !== null && jobKey(active) === key) + return { queue: queue, added: false }; + const queuedAt = queue.findIndex(queued => jobKey(queued) === key); + let next; + let remaining = queue; + if (queuedAt >= 0) { + if (job.interactive !== true || queue[queuedAt].interactive) + return { queue: queue, added: false }; + next = Object.assign({}, queue[queuedAt], { interactive: true }); + remaining = queue.slice(0, queuedAt).concat(queue.slice(queuedAt + 1)); + } else { + next = Object.assign({}, job, { interactive: job.interactive === true }); + } + let at = next.interactive ? remaining.findIndex(queued => !queued.interactive) : -1; + if (at < 0) + at = remaining.length; + return { queue: remaining.slice(0, at).concat([next], remaining.slice(at)), + added: queuedAt < 0 }; +} + +if (typeof module !== "undefined" && module.exports) + module.exports = { jobKey: jobKey, enqueue: enqueue }; diff --git a/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml b/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml new file mode 100644 index 00000000..0377e637 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml @@ -0,0 +1,50 @@ +import QtQuick +import "../SettingsHelpers.js" as Helpers + +// The production document state is independent of FileView/Process so the +// same asynchronous transitions run under QtTest and the desktop engine. +QtObject { + id: document + property var values: ({}) + property var context: ({}) + property var source: ({}) + property var explicitKeys: [] + property string baseline: "" + property string submitted: "" + property bool busy: false + + function text() { + return Helpers.serializeDocument(values, source, explicitKeys); + } + + function begin() { + if (busy) + return false; + submitted = text(); + busy = true; + return true; + } + + function complete(committed) { + // A second UI change may arrive while fsync or another writer holds + // the lock. Rebase only that pending change onto the committed result. + const before = Helpers.parse(submitted).value || ({}); + const pending = Helpers.parse(text()).value || ({}); + const saved = Helpers.parse(committed); + if (saved.status !== "ok") + throw new Error("Settings writer returned an invalid document"); + const rebased = Helpers.rebaseDocuments(before, pending, saved.value); + baseline = committed; + source = saved.value; + explicitKeys = Helpers.overrideKeys(rebased); + submitted = ""; + busy = false; + return { values: Helpers.merge(rebased, context), + pending: JSON.stringify(rebased) !== JSON.stringify(saved.value) }; + } + + function abandon() { + submitted = ""; + busy = false; + } +} diff --git a/roles/desktop/files/quickshell/Common/Persistence/qmldir b/roles/desktop/files/quickshell/Common/Persistence/qmldir new file mode 100644 index 00000000..fbbe9153 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/Persistence/qmldir @@ -0,0 +1 @@ +SettingsDocument SettingsDocument.qml diff --git a/roles/desktop/files/quickshell/Common/Settings.qml b/roles/desktop/files/quickshell/Common/Settings.qml index 9240fc62..e1b1ecab 100644 --- a/roles/desktop/files/quickshell/Common/Settings.qml +++ b/roles/desktop/files/quickshell/Common/Settings.qml @@ -5,6 +5,7 @@ import Quickshell.Io import Quickshell.Services.Notifications import "SettingsHelpers.js" as SettingsHelpers import "ProcHelpers.js" as ProcHelpers +import "Persistence" // Shell settings store (design v2, "Shell settings"). Single source of truth // for user-tunable shell configuration: merged over defaults on load, @@ -135,12 +136,13 @@ Singleton { // Change counter for dirty-state bindings; see scheduleSave(). property int revision: 0 property bool migrationPending: false - property bool writeInFlight: false - property string writeSnapshot: "" - property string lastPersistedText: "" - // What FileView compares the next setText against: the bytes it last - // read or tried to write, which after a failed save is not the file. - // See saveNow(). + property alias writeInFlight: document.busy + property alias writeSnapshot: document.submitted + property alias lastPersistedText: document.baseline + property alias sourceDocument: document.source + property alias explicitOverrides: document.explicitKeys + property var resetOverrides: [] + // Last observed file bytes, retained for read-error diagnostics. property string storeText: "" // A reload that came due while a write was in flight; see reloadStore(). property bool reloadAfterWrite: false @@ -187,11 +189,13 @@ Singleton { { id: "network", group: "Devices", label: "Network", glyph: "wifi", description: "Connections, IP addresses and DNS", system: true }, { id: "touchpad", group: "Devices", label: "Touchpad", glyph: "mouse", - description: "Scrolling" }, + description: "Tap, natural scrolling and sensitivity", system: true }, + { id: "keyboard", group: "Devices", label: "Keyboard", glyph: "keyboard", + description: "Layouts and layout switching", system: true }, { id: "power", group: "System", label: "Power", glyph: "power", description: "Screen off, lock, suspend and stay awake" }, { id: "region", group: "System", label: "Region & formats", glyph: "language", - description: "Clock and temperature formats" }, + description: "Timezone, language, clock and temperature formats" }, { id: "accounts", group: "System", label: "Online accounts", glyph: "account_circle", description: "Connected accounts and calendar access", system: true }, { id: "plugins", group: "System", label: "Omarchy plugins", glyph: "extension", @@ -319,9 +323,22 @@ Singleton { resetLabel = ""; } + function markExplicit(key) { + if (!Object.prototype.hasOwnProperty.call(defaults, key)) + return false; + migrationPending = false; + if (explicitOverrides.indexOf(key) === -1) + explicitOverrides = explicitOverrides.concat([key]); + // Selecting the current default is still an explicit choice, even + // when QML emits no property change signal for its equal value. + scheduleSave(); + return true; + } + function set(key, value) { + if (!markExplicit(key)) + return; clearUndo(); - migrationPending = false; root[key] = SettingsHelpers.normalizeKey(key, value); } @@ -332,7 +349,7 @@ Singleton { function setModuleEnabled(id, on) { clearUndo(); - migrationPending = false; + markExplicit("mods"); const next = { left: [], center: [], right: [] }; for (const col of ["left", "center", "right"]) next[col] = mods[col].map(m => m.id === id @@ -342,7 +359,7 @@ Singleton { function setModuleDetail(id, detail) { clearUndo(); - migrationPending = false; + markExplicit("mods"); const next = { left: [], center: [], right: [] }; for (const col of ["left", "center", "right"]) next[col] = mods[col].map(m => m.id === id @@ -358,7 +375,7 @@ Singleton { function setModuleOptions(id, changes) { clearUndo(); - migrationPending = false; + markExplicit("modOpts"); const next = SettingsHelpers.clone(modOpts); for (const key of Object.keys(changes || ({}))) next[id][key] = changes[key]; @@ -367,20 +384,20 @@ Singleton { function setModuleOrder(left, center, right) { clearUndo(); - migrationPending = false; + markExplicit("mods"); mods = SettingsHelpers.normalizeMods({ left: left, center: center, right: right }); } function setDrawerTabEnabled(id, on) { clearUndo(); - migrationPending = false; + markExplicit("drawerTabs"); drawerTabs = SettingsHelpers.normalizeDrawerTabs(drawerTabs.map(tab => tab.id === id ? ({ id: tab.id, on: on }) : tab)); } function setDrawerTabOrder(ids) { clearUndo(); - migrationPending = false; + markExplicit("drawerTabs"); const held = {}; for (const tab of drawerTabs) held[tab.id] = tab.on; @@ -390,7 +407,7 @@ Singleton { function setDrawerOverviewKey(key, on) { clearUndo(); - migrationPending = false; + markExplicit("drawerOverview"); const next = SettingsHelpers.clone(drawerOverview); next[key] = on; drawerOverview = SettingsHelpers.normalizeDrawerOverview(next); @@ -410,6 +427,8 @@ Singleton { const enabled = modulePresetIds(name); clearUndo(); migrationPending = false; + resetOverrides = explicitOverrides.slice(); + markExplicit("mods"); resetSnapshot = { mods: SettingsHelpers.clone(mods) }; resetLabel = "Widget profile"; const next = { left: [], center: [], right: [] }; @@ -427,6 +446,8 @@ Singleton { function resetKeys(keys, label) { migrationPending = false; + resetOverrides = explicitOverrides.slice(); + explicitOverrides = explicitOverrides.filter(key => keys.indexOf(key) === -1); const previous = {}; for (const key of keys) previous[key] = SettingsHelpers.clone(root[key]); @@ -436,6 +457,7 @@ Singleton { root[key] = key === "mods" ? SettingsHelpers.clone(defaults.mods) : key === "modOpts" ? SettingsHelpers.defaultModOpts() : defaults[key]; + scheduleSave(); announcement = resetLabel + " reset. Undo available for eight seconds."; resetTimer.restart(); } @@ -449,6 +471,9 @@ Singleton { return; } migrationPending = false; + resetOverrides = explicitOverrides.slice(); + markExplicit("mods"); + markExplicit("modOpts"); resetSnapshot = { mods: SettingsHelpers.clone(mods), modOpts: SettingsHelpers.clone(modOpts) }; resetLabel = label || "Widget"; const next = { left: [], center: [], right: [] }; @@ -460,6 +485,7 @@ Singleton { if (options[id] !== undefined) options[id] = SettingsHelpers.clone(defaults.modOpts[id]); modOpts = SettingsHelpers.normalizeModOpts(options); + scheduleSave(); announcement = resetLabel + " reset. Undo available for eight seconds."; resetTimer.restart(); } @@ -487,12 +513,14 @@ Singleton { if (!resetSnapshot) return; const previous = resetSnapshot; + explicitOverrides = resetOverrides.slice(); resetTimer.stop(); for (const key of Object.keys(previous)) root[key] = SettingsHelpers.clone(previous[key]); resetSnapshot = null; const label = resetLabel; resetLabel = ""; + scheduleSave(); announcement = label + " restored."; } @@ -518,6 +546,16 @@ Singleton { return out; } + SettingsDocument { + id: document + values: root.snapshot() + context: ({ connectedWidgets: root.connectedWidgetsConfigured }) + } + + function documentText() { + return document.text(); + } + // One-time migration of the old QS_WEATHER_* env configuration: only a // file that predates modOpts (or no file at all) takes the env values; // after the first save modOpts exists on disk and the seed never re-fires. @@ -604,7 +642,22 @@ Singleton { } if (newerSchema) protectNewerFile(result.value.v); - const parsed = result.value; + let parsed = result.value; + if (loaded && savePending && !newerSchema && result.status === "ok") { + try { + parsed = SettingsHelpers.rebaseDocuments(sourceDocument, + SettingsHelpers.parse(documentText()).value, parsed); + } catch (error) { + // The authoritative writer will refuse the conflict and keep + // the pending candidate in a recoverable sidecar before the + // external values replace the form. Keep this base unchanged. + announcement = String(error); + reloadAfterWrite = true; + saveTimer.restart(); + return; + } + } + const retainPending = savePending; const previousText = lastPersistedText; if (result.status !== "corrupt") lastPersistedText = rawText; @@ -615,6 +668,8 @@ Singleton { ready = true; return; } + sourceDocument = result.value || ({}); + explicitOverrides = SettingsHelpers.overrideKeys(parsed); const merged = SettingsHelpers.merge(parsed, { connectedWidgets: root.connectedWidgetsConfigured }); if (loaded && SettingsHelpers.serialize(merged) === SettingsHelpers.serialize(snapshot())) { @@ -633,13 +688,21 @@ Singleton { // The one key that is not a straight copy: a file predating modOpts // (or no file at all) still takes the retired QS_WEATHER_* env // configuration on its way in. - assignChanged("modOpts", seedWeatherFromEnv(parsed, merged.modOpts)); + const seededOptions = seedWeatherFromEnv(parsed, merged.modOpts); + assignChanged("modOpts", seededOptions); + if (JSON.stringify(seededOptions) !== JSON.stringify(merged.modOpts) + && explicitOverrides.indexOf("modOpts") === -1) + explicitOverrides = explicitOverrides.concat(["modOpts"]); ready = true; migrationPending = parsed !== null && parsed.v !== SettingsHelpers.VERSION; firstRun = result.status === "empty"; loaded = true; applyScrollFactor(); applyGlassEffect(); + if (retainPending && !newerSchema) { + migrationPending = false; + scheduleSave(); + } } // After a rollback to an older shell the file carries keys and option @@ -708,26 +771,27 @@ Singleton { } } - function handleSaveSucceeded() { - const completedSnapshot = writeSnapshot; - lastPersistedText = completedSnapshot; - storeText = completedSnapshot; + function handleSaveSucceeded(committed) { const wasRetry = saveError; + const state = document.complete(committed || writeSnapshot); + storeText = lastPersistedText; + ready = false; + for (const key of Object.keys(root.defaults)) + assignChanged(key, state.values[key]); + ready = true; releaseWriteGuard(); saveError = false; lastSavedAt = Date.now(); - const changedWhileSaving = !sameContent(SettingsHelpers.serialize(snapshot()), - completedSnapshot); - savePending = changedWhileSaving; + savePending = state.pending; if (wasRetry) announcement = "Settings saved."; - if (changedWhileSaving) + if (state.pending) saveTimer.restart(); } function handleSaveFailure(error) { - // FileView keeps the attempted bytes even though they never reached - // the file; saveNow() has to write around them. + // Keep the pending document available for retry. The transaction + // helper preserves the old file when publication fails. storeText = writeSnapshot; releaseWriteGuard(); savePending = false; @@ -740,7 +804,7 @@ Singleton { if (!ready || migrationPending || corruptBackupPending || loadError || writeInFlight) return; - const next = SettingsHelpers.serialize(snapshot()); + const next = documentText(); // Already on disk: settle without writing. That includes a failed // save whose change was undone before Retry — the atomic write left // the previous file in place. @@ -752,23 +816,38 @@ Singleton { } return; } - // FileView.setText compares against the bytes the view last read or - // tried to write, not against the file, and skips a match without - // emitting saved or saveFailed. After a failed save that is the - // attempt itself, so a Retry of the same content would hold the write - // guard for the rest of the session. The same JSON with one more - // trailing newline makes it a real write. - writeSnapshot = next === storeText ? next + "\n" : next; - writeInFlight = true; - try { - // Completion arrives only through saved/saveFailed. Quickshell - // logs a failed atomic commit (the fsync or the rename) and still - // emits saved, so saved means the bytes were written, not that - // they replaced the file. - store.setText(writeSnapshot); - } catch (error) { - handleSaveFailure(FileViewError.Unknown); - console.warn("settings save threw:", error); + if (!document.begin()) + return; + settingsWriter.inputText = JSON.stringify({ baseline: lastPersistedText, + candidate: writeSnapshot, version: SettingsHelpers.VERSION }) + "\n"; + settingsWriter.running = true; + } + + CommandRequest { + id: settingsWriter + command: ["python3", "-B", Quickshell.shellDir + "/scripts/settings-store", root.filePath] + stdinEnabled: true + timeoutMs: 10000 + timeoutMessage: "Saving settings timed out. Your previous file is intact." + onCompleted: (code, body, error) => { + let result = {}; + try { result = JSON.parse(body); } catch (_) {} + if (code === 0 && result.ok && typeof result.text === "string") { + try { + root.handleSaveSucceeded(result.text); + return; + } catch (failure) { + result.error = String(failure); + } + } + root.handleSaveFailure(FileViewError.Unknown); + root.announcement = result.error || error || "Could not save settings. Retry is available."; + if (result.error && result.error.indexOf("Your pending edit is saved at ") !== -1) { + // The rejected edit is retained byte-for-byte in its sidecar; + // refresh the form so Retry cannot repeat the same conflict. + root.saveError = false; + store.reload(); + } } } @@ -1038,10 +1117,9 @@ Singleton { id: store path: root.filePath printErrors: false + // Reads/watch notifications only. The settings-store process verifies + // merge, fsync and atomic publication before reporting save success. atomicWrites: true - // The atomic write syncs to disk before its rename, which can take - // seconds under heavy IO; off the GUI thread the shell keeps drawing - // meanwhile. saveNow() never starts a write under another one. blockWrites: false blockLoading: true watchChanges: true @@ -1055,8 +1133,6 @@ Singleton { } onLoaded: root.applyLoaded(text()) onLoadFailed: error => root.handleLoadFailure(error) - onSaved: root.handleSaveSucceeded() - onSaveFailed: error => root.handleSaveFailure(error) } // Force the load to complete during singleton construction so the first diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/roles/desktop/files/quickshell/Common/SettingsHelpers.js index 22d6a0cc..6f7213b9 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/roles/desktop/files/quickshell/Common/SettingsHelpers.js @@ -1,7 +1,7 @@ // Pure settings-schema helpers shared by QML and Node tests. // Keep this file free of Qt APIs so persistence stays deterministic. -var VERSION = 26; +var VERSION = 27; var BAR_STYLES = ["hug", "floating", "attached"]; var PALETTE_MODES = ["wallpaper", "fixed"]; @@ -980,18 +980,7 @@ function migrateModOpts(raw, sourceVersion, rawSettings) { next.indicators.order.splice(recordingIndex === -1 ? next.indicators.order.length : recordingIndex + 1, 0, "ocr"); - var priorIds = INDICATOR_ACTION_IDS.filter(function(id) { - return id !== "ocr"; - }); - var priorEnabled = Array.isArray(rawIndicators.enabled) - && priorIds.every(function(id) { - return rawIndicators.enabled.indexOf(id) !== -1; - }); - if (priorEnabled) { - var enabledRecordingIndex = next.indicators.enabled.indexOf("recording"); - next.indicators.enabled.splice(enabledRecordingIndex === -1 - ? next.indicators.enabled.length : enabledRecordingIndex + 1, 0, "ocr"); - } + } return next; } @@ -1134,145 +1123,20 @@ function migrateMods(raw, sourceVersion, context) { return normalizeMods(migrated); } -// Schema 4 is the glass menubar: a taller bar, full-radius corners, a wider -// floating gap and the design's accent. A settings file written by schema 3 -// carries the old geometry for every one of those keys, so loading it as-is -// would silently keep the previous design's proportions. -// -// Only values the user never moved are adopted — a key still holding its -// schema-3 default takes the schema-4 one, anything else is theirs and stays. -var V3_DEFAULTS = { - barHeight: 30, barRadius: 9, gap: 8, accent: "#9ecbeb", font: "oppo", - osd: "top" -}; - -var V3_MOD_OPT_DEFAULTS = { - ws: { style: "numbers" }, - media: { maxWidth: 220 }, - clock: { dateFormat: "ddd dd" } -}; - -function adoptRedesign(parsed) { - if (!parsed || typeof parsed !== "object" - || (typeof parsed.v === "number" && parsed.v >= 4)) - return parsed; - var next = clone(parsed); - Object.keys(V3_DEFAULTS).forEach(function(key) { - if (next[key] === V3_DEFAULTS[key]) - delete next[key]; - }); - if (next.modOpts && typeof next.modOpts === "object") { - Object.keys(V3_MOD_OPT_DEFAULTS).forEach(function(id) { - var entry = next.modOpts[id]; - if (!entry || typeof entry !== "object") - return; - Object.keys(V3_MOD_OPT_DEFAULTS[id]).forEach(function(key) { - if (entry[key] === V3_MOD_OPT_DEFAULTS[id][key]) - delete entry[key]; - }); - }); - } - return next; -} - -// Schema 7 makes the softer variable face the shell default. As with the -// schema-4 redesign, a stored value equal to the previous default is treated -// as untouched; every other valid font remains an explicit user choice. -// A missing font must use today's default, including unversioned installer -// seeds. It is not evidence of a saved schema-6 font preference. -function adoptSofterTypography(parsed) { - if (!parsed || typeof parsed !== "object" - || (typeof parsed.v === "number" && parsed.v >= 7)) - return parsed; - var next = clone(parsed); - if (next.font === "urbanist") - next.font = "google"; - return next; -} - -// Schema 10 restores the compact August menubar shown in the repository's -// desktop screenshot, without reviving its fixed layout. As with the earlier -// redesign migration, only values still equal to schema 9's defaults move to -// the new visual baseline; customized geometry, glass, colors and workspace -// presentation remain the user's choices. -var V9_CLASSIC_DEFAULTS = { - glassEnabled: true, - barHeight: 46, - barRadius: 23, - gap: 10, - accent: "#5e9bff" -}; - -var V9_CLASSIC_MOD_OPT_DEFAULTS = { - ws: { style: "dots" }, - clock: { dateFormat: "ddd d MMM" } -}; - -function adoptClassicMenubar(parsed) { - if (!parsed || typeof parsed !== "object" - || (typeof parsed.v === "number" && parsed.v >= 10)) - return parsed; - // Schema 3 and unversioned files first pass through adoptRedesign(), - // which already removes their untouched defaults. Do not then mistake a - // deliberate old-style value for schema 9's default on the second hop. - if (typeof parsed.v !== "number" || parsed.v < 4) - return parsed; - var next = clone(parsed); - var untouchedCustomColor = (next.barColorMode === undefined - || next.barColorMode === "default") - && next.barCustomHue === 247 - && next.barCustomSaturation === 29 - && next.barCustomLightness === 11; - if (untouchedCustomColor) { - delete next.barCustomHue; - delete next.barCustomSaturation; - delete next.barCustomLightness; - } - Object.keys(V9_CLASSIC_DEFAULTS).forEach(function(key) { - if (next[key] === V9_CLASSIC_DEFAULTS[key]) - delete next[key]; - }); - if (next.modOpts && typeof next.modOpts === "object") { - Object.keys(V9_CLASSIC_MOD_OPT_DEFAULTS).forEach(function(id) { - var entry = next.modOpts[id]; - if (!entry || typeof entry !== "object") - return; - Object.keys(V9_CLASSIC_MOD_OPT_DEFAULTS[id]).forEach(function(key) { - if (entry[key] === V9_CLASSIC_MOD_OPT_DEFAULTS[id][key]) - delete entry[key]; - }); - }); - } - return next; -} - -// Schema 6 replaces two booleans with explicit visual modes. A v5 floating -// bar whose geometry was never changed becomes the new edge-hugging default; -// customized floating geometry remains floating, and the old edge-to-edge -// option remains attached. The fixed color values are intentionally never -// discarded: paletteMode only chooses which palette is active. -var V5_DEFAULTS = { - barHeight: 46, - barRadius: 23, - gap: 10, - accent: "#5e9bff", - barColorMode: "default", - barCustomHue: 247, - barCustomSaturation: 29, - barCustomLightness: 11 -}; +// Stored legacy values are explicit choices, even when equal to an old +// default. Visual redesigns must never infer ownership from value equality. +// Structural migration keeps the old mode and every explicit value. A +// legacy file with geometry/color fields but no mode retains its old mode; +// a sparse file with neither follows today's default. function migrateBarStyle(parsed, defaultsValue) { if (typeof parsed.v === "number" && parsed.v >= 6) return enumIn(parsed.barStyle, BAR_STYLES, defaultsValue); if (parsed.floating === false) return "attached"; - var pristine = intIn(parsed.barHeight, 28, 60, 1, V5_DEFAULTS.barHeight) - === V5_DEFAULTS.barHeight - && intIn(parsed.barRadius, 0, 30, 1, V5_DEFAULTS.barRadius) - === V5_DEFAULTS.barRadius - && intIn(parsed.gap, 4, 24, 1, V5_DEFAULTS.gap) === V5_DEFAULTS.gap; - return pristine ? "hug" : "floating"; + return parsed.floating === true || ["barHeight", "barRadius", "gap"].some(function(key) { + return Object.prototype.hasOwnProperty.call(parsed, key); + }) ? "floating" : defaultsValue; } function migratePaletteMode(parsed, defaultsValue) { @@ -1280,30 +1144,27 @@ function migratePaletteMode(parsed, defaultsValue) { return enumIn(parsed.paletteMode, PALETTE_MODES, defaultsValue); if (parsed.accentWall === true) return "wallpaper"; - var accent = hexIn(parsed.accent, V5_DEFAULTS.accent).toLowerCase(); - var barMode = enumIn(parsed.barColorMode, BAR_COLOR_IDS, - V5_DEFAULTS.barColorMode); - var customHue = intIn(parsed.barCustomHue, 0, 359, 1, - V5_DEFAULTS.barCustomHue); - var customSaturation = intIn(parsed.barCustomSaturation, 0, 100, 1, - V5_DEFAULTS.barCustomSaturation); - var customLightness = intIn(parsed.barCustomLightness, 0, 100, 1, - V5_DEFAULTS.barCustomLightness); - return accent === V5_DEFAULTS.accent && barMode === V5_DEFAULTS.barColorMode - && customHue === V5_DEFAULTS.barCustomHue - && customSaturation === V5_DEFAULTS.barCustomSaturation - && customLightness === V5_DEFAULTS.barCustomLightness - ? "wallpaper" : "fixed"; + return parsed.accentWall === false || ["accent", "barColorMode", "barCustomHue", + "barCustomSaturation", "barCustomLightness"].some(function(key) { + return Object.prototype.hasOwnProperty.call(parsed, key); + }) ? "fixed" : defaultsValue; } // `context.connectedWidgets` is the install's connected-service feature; it // decides whether a widget that schema migration adds starts on. function merge(raw, context) { var d = defaults(); + if (context && context.connectedWidgets) { + ["left", "center", "right"].forEach(function(column) { + d.mods[column].forEach(function(entry) { + if (["modelusage", "gh", "t3", "hermes"].indexOf(entry.id) !== -1) + entry.on = true; + }); + }); + } if (!raw || typeof raw !== "object") return d; - var parsed = adoptClassicMenubar( - adoptSofterTypography(adoptRedesign(raw))); + var parsed = raw; var idleMode = enumIn(parsed.idleInhibitMode, ["off", "30m", "1h", "unplugged", "always"], parsed.idleInhibited === true ? "always" : d.idleInhibitMode); @@ -1387,7 +1248,7 @@ function merge(raw, context) { drawerOverview: normalizeDrawerOverview(parsed.drawerOverview), drawerHover: enumIn(parsed.drawerHover, DRAWER_HOVER_MODES, d.drawerHover), drawerWidth: intIn(parsed.drawerWidth, 320, 480, 10, d.drawerWidth), - mods: migrateMods(parsed.mods, parsed.v, context), + mods: parsed.mods === undefined ? d.mods : migrateMods(parsed.mods, parsed.v, context), modOpts: migrateModOpts(parsed.modOpts, parsed.v, parsed) }; } @@ -1499,6 +1360,131 @@ function serialize(settings) { return JSON.stringify(ordered, null, 2) + "\n"; } +// Presence is ownership: a value explicitly set to today's default remains +// an override. New files are sparse; every stored legacy key is conservatively +// adopted as explicit. Unknown fields are carried through without validation. +function overrideKeys(raw) { + var known = defaults(); + return Object.keys(raw || {}).filter(function(key) { + return Object.prototype.hasOwnProperty.call(known, key) + && JSON.stringify(unknownFields(raw[key], known[key])) !== JSON.stringify(raw[key]); + }); +} + +function unknownFields(original, schema) { + if (!original || typeof original !== "object") + return undefined; + if (Array.isArray(original)) { + if (!Array.isArray(schema)) + return undefined; + var entries = []; + original.forEach(function(item) { + if (!item || typeof item.id !== "string") + return; + var known = schema.find(function(value) { return value && value.id === item.id; }); + if (!known) { + entries.push(clone(item)); + return; + } + var extra = unknownFields(item, known); + if (extra) { + extra.id = item.id; + entries.push(extra); + } + }); + return entries.length ? entries : undefined; + } + if (!schema || typeof schema !== "object") + return undefined; + var out = {}; + Object.keys(original).forEach(function(key) { + if (key === "__proto__" || key === "constructor" || key === "prototype") + return; + var value = Object.prototype.hasOwnProperty.call(schema, key) + ? unknownFields(original[key], schema[key]) : clone(original[key]); + if (value !== undefined) + out[key] = value; + }); + return Object.keys(out).length ? out : undefined; +} + +function preserveUnknown(original, replacement) { + if (Array.isArray(replacement)) { + if (!Array.isArray(original)) + return clone(replacement); + // Layout entries carry stable ids. Preserve future entries and fields + // while retaining the current user's ordering of understood entries. + if (replacement.every(function(item) { return item && typeof item.id === "string"; })) { + var result = replacement.map(function(item) { + return preserveUnknown(original.find(function(old) { + return old && old.id === item.id; + }), item); + }); + original.forEach(function(item) { + if (item && typeof item.id === "string" + && !replacement.some(function(next) { return next.id === item.id; })) + result.push(clone(item)); + }); + return result; + } + return clone(replacement); + } + if (!replacement || typeof replacement !== "object") + return clone(replacement); + var out = original && typeof original === "object" && !Array.isArray(original) + ? clone(original) : {}; + Object.keys(replacement).forEach(function(key) { + if (key !== "__proto__" && key !== "constructor" && key !== "prototype") + out[key] = preserveUnknown(out[key], replacement[key]); + }); + return out; +} + +function rebaseDocuments(base, desired, current, path) { + if (JSON.stringify(desired) === JSON.stringify(base) + || JSON.stringify(desired) === JSON.stringify(current)) + return current; + if (JSON.stringify(current) === JSON.stringify(base)) + return desired; + if (base && desired && current && [base, desired, current].every(function(value) { + return typeof value === "object" && !Array.isArray(value); + })) { + var out = clone(current); + Object.keys(base).concat(Object.keys(desired)).forEach(function(key) { + if (key === "__proto__" || key === "constructor" || key === "prototype") + return; + if (!path && key === "v") { + out.v = desired.v; + return; + } + var result = rebaseDocuments(base[key], desired[key], current[key], (path || "") + "/" + key); + if (result === undefined) + delete out[key]; + else + out[key] = result; + }); + return out; + } + throw new Error("Another writer changed " + (path || "/") + "; reload before retrying"); +} + +function serializeDocument(settings, original, explicitKeys) { + var out = clone(original || {}); + out.v = VERSION; + Object.keys(defaults()).forEach(function(key) { + if (explicitKeys.indexOf(key) !== -1) + out[key] = preserveUnknown(out[key], settings[key]); + else { + var extra = unknownFields(out[key], defaults()[key]); + if (extra === undefined) + delete out[key]; + else + out[key] = extra; + } + }); + return JSON.stringify(out, null, 2) + "\n"; +} + // Distinguishing "no settings yet" from "settings we could not read" is what // keeps a corrupt file recoverable: both merge to defaults, but only the empty // case may be overwritten. Returns { status, value } where status is one of: @@ -1591,6 +1577,11 @@ var exported = { normalizeKey: normalizeKey, isNewerSchema: isNewerSchema, serialize: serialize, + overrideKeys: overrideKeys, + preserveUnknown: preserveUnknown, + unknownFields: unknownFields, + serializeDocument: serializeDocument, + rebaseDocuments: rebaseDocuments, parse: parse }; diff --git a/roles/desktop/files/quickshell/Common/SettingsSearchData.js b/roles/desktop/files/quickshell/Common/SettingsSearchData.js index b9352979..c8f0f7cd 100644 --- a/roles/desktop/files/quickshell/Common/SettingsSearchData.js +++ b/roles/desktop/files/quickshell/Common/SettingsSearchData.js @@ -9,6 +9,13 @@ // against SettingsHelpers' schema under Node. var ROWS = [ + { page: "keyboard", pageLabel: "Keyboard", group: "Keyboard layouts", label: "Keyboard layouts", key: "", terms: "input language variant qwerty azerty dvorak colemak" }, + { page: "keyboard", pageLabel: "Keyboard", group: "Switch layouts", label: "Layout switching", key: "", terms: "keyboard shortcut caps lock alt shift next layout" }, + { page: "touchpad", pageLabel: "Touchpad", group: "Touchpad", label: "Tap to click", key: "", terms: "touchpad tapping click input" }, + { page: "touchpad", pageLabel: "Touchpad", group: "Touchpad", label: "Natural scrolling", key: "", terms: "touchpad reverse scroll direction" }, + { page: "touchpad", pageLabel: "Touchpad", group: "Touchpad", label: "Pointer sensitivity", key: "", terms: "touchpad mouse speed acceleration" }, + { page: "region", pageLabel: "Region & formats", group: "System timezone", label: "Timezone", key: "", terms: "time zone city country daylight saving clock" }, + { page: "region", pageLabel: "Region & formats", group: "System language", label: "System language", key: "", terms: "locale language region lang regional formats" }, // Appearance { page: "network", pageLabel: "Network", group: "Connections", label: "Network connections", key: "", terms: "wifi ethernet saved profile adapter vpn advanced editor" }, { page: "network", pageLabel: "Network", group: "Connection", label: "Autoconnect and metered", key: "", terms: "automatic join metered data limit background" }, diff --git a/roles/desktop/files/quickshell/Common/SystemSettings.qml b/roles/desktop/files/quickshell/Common/SystemSettings.qml index 71ff9355..42098957 100644 --- a/roles/desktop/files/quickshell/Common/SystemSettings.qml +++ b/roles/desktop/files/quickshell/Common/SystemSettings.qml @@ -27,6 +27,8 @@ Singleton { } readonly property SystemSettingsBackend sound: SystemSettingsBackend { domain: "sound" } readonly property SystemSettingsBackend network: SystemSettingsBackend { domain: "network" } + readonly property SystemSettingsBackend input: SystemSettingsBackend { domain: "input" } + readonly property SystemSettingsBackend region: SystemSettingsBackend { domain: "region" } readonly property SystemSettingsBackend accounts: SystemSettingsBackend { domain: "accounts" onCompleted: result => { if (result.success) Calendar.refreshDefault(); } diff --git a/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml b/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml index 29ea4879..8bf2e88f 100644 --- a/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml +++ b/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml @@ -40,7 +40,9 @@ Item { if (watchers && !busy) snapshotProc.running = true; } - onWatchersChanged: monitor.running = watchers > 0 + // Input is polled: no extra compositor socket reader lives beyond the page. + readonly property bool hasMonitor: domain !== "input" && domain !== "region" + onWatchersChanged: monitor.running = watchers > 0 && hasMonitor function run(value) { if (busy) return false; @@ -76,7 +78,7 @@ Item { repeat: true onTriggered: { root.refresh(); - if (!monitor.running) + if (root.hasMonitor && !monitor.running) monitor.running = true; } } diff --git a/roles/desktop/files/quickshell/Common/T3Actions.qml b/roles/desktop/files/quickshell/Common/T3Actions.qml new file mode 100644 index 00000000..f26b1984 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/T3Actions.qml @@ -0,0 +1,385 @@ +pragma Singleton +import QtQuick +import Quickshell +import "T3CodeHelpers.js" as Helpers + +// Domain commands and their per-action feedback. T3Rpc owns wire correlation; +// views continue using its facade, so this boundary changes no public API. +// A command is never retried on transport loss or after partial batch success. +Singleton { + id: root + + // Per-command UI state, keyed by actionKey(kind, threadId, requestId): + // { pending, error, commandId, ... }. A finished entry is left in place so + // the button can keep showing why it failed, which is why the deadline + // sweep below only ever looks at pending ones. + property var actionStates: ({}) + readonly property int actionTimeoutMs: 15000 + + // Optional-capability gates for the lifecycle commands below. Derived from + // the connection here — where the commands live — and re-exported by + // T3Code; a missing key means an older server, so the command is never + // sent under version skew. + readonly property bool supportsSettlement: + T3Connection.environmentCapabilities.threadSettlement === true + readonly property bool supportsSnooze: + T3Connection.environmentCapabilities.threadSnooze === true + readonly property bool supportsTitleRegeneration: + T3Connection.environmentCapabilities.threadTitleRegeneration === true + readonly property bool supportsPinning: + T3Connection.environmentCapabilities.threadPinning === true + + // ---- commands and action state --------------------------------------- + + function actionKey(kind, threadId, requestId) { + return kind + "|" + threadId + "|" + (requestId ?? ""); + } + + function actionState(kind, threadId, requestId) { + const states = actionStates; + return states[actionKey(kind, threadId, requestId)] ?? null; + } + + function actionPending(kind, threadId, requestId) { + const current = actionState(kind, threadId, requestId); + return current !== null && current.pending === true; + } + + function actionError(kind, threadId, requestId) { + const current = actionState(kind, threadId, requestId); + return current && typeof current.error === "string" ? current.error : ""; + } + + function putActionState(key, value) { + const next = Object.assign({}, actionStates); + if (value === null) + delete next[key]; + else + next[key] = value; + actionStates = next; + } + + function beginAction(key, commandId, awaitResolution, timeoutMs) { + const state = { + pending: true, + error: "", + commandId: commandId, + awaitResolution: awaitResolution === true, + startedAt: Date.now() + }; + if (typeof timeoutMs === "number" && timeoutMs > 0) + state.timeoutMs = timeoutMs; + putActionState(key, state); + } + + function failAllPendingActions(message) { + const next = Object.assign({}, actionStates); + let changed = false; + for (const key in next) { + if (!next[key] || next[key].pending !== true) + continue; + next[key] = Object.assign({}, next[key], { + pending: false, + error: message || "Disconnected before confirmation" + }); + changed = true; + } + if (changed) + actionStates = next; + } + + function failAction(key, message) { + const current = actionStates[key]; + if (!current) + return; + putActionState(key, Object.assign({}, current, { + pending: false, + error: message || "Action failed" + })); + } + + function clearAction(key) { + if (actionStates[key] !== undefined) + putActionState(key, null); + } + + + + + + function rejectAction(key, message, awaitResolution) { + putActionState(key, { + pending: false, + error: message, + commandId: "", + awaitResolution: awaitResolution === true, + startedAt: Date.now() + }); + return ""; + } + + // Dispatch commands one at a time. A later command is never attempted + // after an earlier rejection, and reconnecting never replays the batch. + function dispatchBatch(commands, key, options) { + const opts = options ?? {}; + if (!Helpers.canBeginAction(actionStates, key)) + return ""; + if (!T3Connection.canOperate) + return rejectAction(key, "This pairing is read-only", opts.awaitResolution); + if (T3Connection.state !== "connected") + return rejectAction(key, "Not connected", opts.awaitResolution); + if (!Array.isArray(commands) || commands.length === 0) + return rejectAction(key, "Nothing to send", opts.awaitResolution); + + const firstId = commands[0].commandId ?? T3Rpc.genId(); + commands[0].commandId = firstId; + beginAction(key, firstId, opts.awaitResolution); + + function sendAt(index) { + if (!root.actionStates[key] || root.actionStates[key].pending !== true) + return; + if (index >= commands.length) { + if (opts.awaitResolution !== true && opts.holdAfterSuccess !== true) + root.clearAction(key); + opts.onSuccess?.(); + return; + } + const command = commands[index]; + if (!command.commandId) + command.commandId = T3Rpc.genId(); + const current = root.actionStates[key]; + root.putActionState(key, Object.assign({}, current, { + commandId: command.commandId, + startedAt: Date.now() + })); + T3Rpc.requestOnce("orchestration.dispatchCommand", command, () => { + sendAt(index + 1); + }, error => { + root.failAction(key, error || "Command rejected"); + opts.onFailure?.(error); + console.warn("t3code: command rejected:", error); + }, { actionKey: key, fallback: "Command rejected" }); + } + + sendAt(0); + return firstId; + } + + // Approval/input actions remain pending after RPC acceptance until the + // provider's matching resolution activity arrives. + function dispatch(command, key, awaitResolution) { + return dispatchBatch([command], key, { awaitResolution: awaitResolution === true }); + } + + // decision: "accept" | "acceptForSession" | "decline" + function respondApproval(threadId, requestId, decision) { + const key = actionKey("approval", threadId, requestId); + return dispatch({ + type: "thread.approval.respond", + commandId: T3Rpc.genId(), + threadId: threadId, + requestId: requestId, + decision: decision, + createdAt: new Date().toISOString() + }, key, true); + } + + // Answers are deliberately narrowed to the two provider contract shapes + // the dropdown can author: a string or an array of strings. + function respondUserInput(threadId, requestId, answers) { + const key = actionKey("input", threadId, requestId); + const normalized = {}; + let answerCount = 0; + if (!answers || typeof answers !== "object" || Array.isArray(answers)) { + putActionState(key, { pending: false, error: "Every question needs an answer", + commandId: "", awaitResolution: true, startedAt: Date.now() }); + return ""; + } + for (const questionId in answers) { + const value = answers[questionId]; + if (typeof value === "string") { + const answer = value.trim(); + if (answer === "") + continue; + normalized[questionId] = answer; + answerCount++; + } else if (Array.isArray(value)) { + const labels = value.filter(label => typeof label === "string") + .map(label => label.trim()).filter(label => label !== ""); + if (labels.length === 0) + continue; + normalized[questionId] = Array.from(new Set(labels)); + answerCount++; + } else { + putActionState(key, { pending: false, error: "Unsupported answer format", + commandId: "", awaitResolution: true, startedAt: Date.now() }); + return ""; + } + } + if (answerCount === 0) { + putActionState(key, { pending: false, error: "Every question needs an answer", + commandId: "", awaitResolution: true, startedAt: Date.now() }); + return ""; + } + return dispatch({ + type: "thread.user-input.respond", + commandId: T3Rpc.genId(), + threadId: threadId, + requestId: requestId, + answers: normalized, + createdAt: new Date().toISOString() + }, key, true); + } + + function settle(threadId) { + const key = actionKey("settle", threadId, ""); + const thread = T3Threads.threadMap[threadId]; + if (!supportsSettlement) + return rejectAction(key, "Settlement is not supported by this server", false); + if (!Helpers.canOperateLifecycle(thread, Date.now())) + return rejectAction(key, "Wait for the thread to become idle", false); + return dispatch({ + type: "thread.settle", + commandId: T3Rpc.genId(), + threadId: threadId + }, key, false); + } + + function unsettle(threadId) { + const key = actionKey("unsettle", threadId, ""); + if (!supportsSettlement) + return rejectAction(key, "Settlement is not supported by this server", false); + return dispatch({ + type: "thread.unsettle", + commandId: T3Rpc.genId(), + threadId: threadId, + reason: "user" + }, key, false); + } + + function settleMany(threadIds) { + const key = actionKey("bulk-settle", "", ""); + if (!supportsSettlement) + return rejectAction(key, "Settlement is not supported by this server", false); + const ids = Array.isArray(threadIds) ? threadIds.filter(id => + Helpers.canOperateLifecycle(T3Threads.threadMap[id], Date.now())) : []; + const commands = ids.map(id => ({ + type: "thread.settle", commandId: T3Rpc.genId(), threadId: id + })); + return dispatchBatch(commands, key, {}); + } + + function snooze(threadId, snoozedUntil) { + const key = actionKey("snooze", threadId, ""); + const thread = T3Threads.threadMap[threadId]; + if (!supportsSnooze) + return rejectAction(key, "Snooze is not supported by this server", false); + if (!Helpers.canOperateLifecycle(thread, Date.now())) + return rejectAction(key, "Wait for the thread to become idle", false); + if (isNaN(Date.parse(snoozedUntil)) || Date.parse(snoozedUntil) <= Date.now()) + return rejectAction(key, "Choose a future wake time", false); + return dispatch({ + type: "thread.snooze", + commandId: T3Rpc.genId(), + threadId: threadId, + snoozedUntil: snoozedUntil + }, key, false); + } + + function unsnooze(threadId) { + const key = actionKey("unsnooze", threadId, ""); + if (!supportsSnooze) + return rejectAction(key, "Snooze is not supported by this server", false); + return dispatch({ + type: "thread.unsnooze", + commandId: T3Rpc.genId(), + threadId: threadId, + reason: "user" + }, key, false); + } + + // Pinning is metadata, not lifecycle: the reference client offers it on + // running and blocked threads alike, so there is no idleness gate here. + // orderKey is omitted — the bar never reorders pins. + function pin(threadId) { + const key = actionKey("pin", threadId, ""); + if (!supportsPinning) + return rejectAction(key, "Pinning is not supported by this server", false); + return dispatch({ + type: "thread.pin", + commandId: T3Rpc.genId(), + threadId: threadId + }, key, false); + } + + function unpin(threadId) { + const key = actionKey("unpin", threadId, ""); + if (!supportsPinning) + return rejectAction(key, "Pinning is not supported by this server", false); + return dispatch({ + type: "thread.unpin", + commandId: T3Rpc.genId(), + threadId: threadId + }, key, false); + } + + function interrupt(threadId) { + return dispatch({ + type: "thread.turn.interrupt", + commandId: T3Rpc.genId(), + threadId: threadId, + createdAt: new Date().toISOString() + }, actionKey("interrupt", threadId, ""), false); + } + + function stopSession(threadId) { + return dispatch({ + type: "thread.session.stop", + commandId: T3Rpc.genId(), + threadId: threadId, + createdAt: new Date().toISOString() + }, actionKey("session-stop", threadId, ""), false); + } + + function renameThread(threadId, title) { + const key = actionKey("rename", threadId, ""); + const normalized = typeof title === "string" ? title.trim() : ""; + if (normalized === "") + return rejectAction(key, "Title cannot be empty", false); + return dispatch({ + type: "thread.meta.update", + commandId: T3Rpc.genId(), + threadId: threadId, + title: normalized + }, key, false); + } + + function regenerateTitle(threadId) { + const key = actionKey("regenerate-title", threadId, ""); + if (!supportsTitleRegeneration) + return rejectAction(key, "Title regeneration is not supported", false); + if (T3Threads.threadMap[threadId]?.titleRegeneration) + return rejectAction(key, "Title regeneration is already running", false); + return dispatch({ + type: "thread.meta.update", + commandId: T3Rpc.genId(), + threadId: threadId, + regenerateTitle: true + }, key, false); + } + + + function expire(now) { + const result = Helpers.expireActionStates(actionStates, now, actionTimeoutMs); + if (result.expiredKeys.length > 0) + actionStates = result.states; + } + + Timer { + interval: 500 + repeat: true + running: T3Connection.state === "connected" + && Object.values(root.actionStates).some(state => state && state.pending === true) + onTriggered: root.expire(Date.now()) + } +} diff --git a/roles/desktop/files/quickshell/Common/T3Rpc.qml b/roles/desktop/files/quickshell/Common/T3Rpc.qml index 758d3e4f..0d6d73dc 100644 --- a/roles/desktop/files/quickshell/Common/T3Rpc.qml +++ b/roles/desktop/files/quickshell/Common/T3Rpc.qml @@ -3,8 +3,8 @@ import QtQuick import Quickshell import "T3CodeHelpers.js" as Helpers -// Request/response over the T3 socket, and the state machine that tracks a -// dispatched command until the server confirms it. +// Request/response correlation over the T3 socket. Domain actions live in +// T3Actions; this facade retains the existing public command surface. // // This is the layer between the transport (Common/T3Connection.qml, which // knows only frames) and the domain (Common/T3Code.qml, which knows threads). @@ -17,25 +17,12 @@ import "T3CodeHelpers.js" as Helpers Singleton { id: root - // Per-command UI state, keyed by actionKey(kind, threadId, requestId): - // { pending, error, commandId, ... }. A finished entry is left in place so - // the button can keep showing why it failed, which is why the deadline - // sweep below only ever looks at pending ones. - property var actionStates: ({}) - readonly property int actionTimeoutMs: 15000 - - // Optional-capability gates for the lifecycle commands below. Derived from - // the connection here — where the commands live — and re-exported by - // T3Code; a missing key means an older server, so the command is never - // sent under version skew. - readonly property bool supportsSettlement: - T3Connection.environmentCapabilities.threadSettlement === true - readonly property bool supportsSnooze: - T3Connection.environmentCapabilities.threadSnooze === true - readonly property bool supportsTitleRegeneration: - T3Connection.environmentCapabilities.threadTitleRegeneration === true - readonly property bool supportsPinning: - T3Connection.environmentCapabilities.threadPinning === true + readonly property var actionStates: T3Actions.actionStates + readonly property int actionTimeoutMs: T3Actions.actionTimeoutMs + readonly property bool supportsSettlement: T3Actions.supportsSettlement + readonly property bool supportsSnooze: T3Actions.supportsSnooze + readonly property bool supportsTitleRegeneration: T3Actions.supportsTitleRegeneration + readonly property bool supportsPinning: T3Actions.supportsPinning function putRpcHandler(id, handler) { dropRpcHandler(id); @@ -140,17 +127,13 @@ Singleton { } - // Deadline sweep for in-flight RPCs and pending actions. Both are empty - // most of the time, so the tick is gated on there being something to - // expire: rpcDeadlineCount notifies where rpcHandlers cannot, and - // expireActionStates only ever touches pending entries — a finished - // action left in place to show its error must not keep this running. + // Only wire requests with deadlines keep this sweep awake. Action + // feedback has its own lifecycle in T3Actions, independent of streams. Timer { interval: 500 repeat: true running: T3Connection.state === "connected" - && (root.rpcDeadlineCount > 0 - || Object.values(root.actionStates).some(state => state && state.pending === true)) + && root.rpcDeadlineCount > 0 onTriggered: { const now = Date.now(); for (const id in root.rpcHandlers) { @@ -161,365 +144,127 @@ Singleton { root.dropRpcHandler(id); handler.timeout?.(); } - const expired = Helpers.expireActionStates(root.actionStates, now, - root.actionTimeoutMs); - if (expired.expiredKeys.length > 0) - root.actionStates = expired.states; } } - // ---- commands and action state --------------------------------------- + // Compatibility facade for domain commands and per-action feedback. function actionKey(kind, threadId, requestId) { - return kind + "|" + threadId + "|" + (requestId ?? ""); + return T3Actions.actionKey(kind, threadId, requestId); } function actionState(kind, threadId, requestId) { - const states = actionStates; - return states[actionKey(kind, threadId, requestId)] ?? null; + return T3Actions.actionState(kind, threadId, requestId); } function actionPending(kind, threadId, requestId) { - const current = actionState(kind, threadId, requestId); - return current !== null && current.pending === true; + return T3Actions.actionPending(kind, threadId, requestId); } function actionError(kind, threadId, requestId) { - const current = actionState(kind, threadId, requestId); - return current && typeof current.error === "string" ? current.error : ""; + return T3Actions.actionError(kind, threadId, requestId); } function putActionState(key, value) { - const next = Object.assign({}, actionStates); - if (value === null) - delete next[key]; - else - next[key] = value; - actionStates = next; + return T3Actions.putActionState(key, value); } function beginAction(key, commandId, awaitResolution, timeoutMs) { - const state = { - pending: true, - error: "", - commandId: commandId, - awaitResolution: awaitResolution === true, - startedAt: Date.now() - }; - if (typeof timeoutMs === "number" && timeoutMs > 0) - state.timeoutMs = timeoutMs; - putActionState(key, state); + return T3Actions.beginAction(key, commandId, awaitResolution, timeoutMs); } function failAllPendingActions(message) { - const next = Object.assign({}, actionStates); - let changed = false; - for (const key in next) { - if (!next[key] || next[key].pending !== true) - continue; - next[key] = Object.assign({}, next[key], { - pending: false, - error: message || "Disconnected before confirmation" - }); - changed = true; - } - if (changed) - actionStates = next; + return T3Actions.failAllPendingActions(message); } function failAction(key, message) { - const current = actionStates[key]; - if (!current) - return; - putActionState(key, Object.assign({}, current, { - pending: false, - error: message || "Action failed" - })); + return T3Actions.failAction(key, message); } function clearAction(key) { - if (actionStates[key] !== undefined) - putActionState(key, null); - } - - function cancelActionRequests(key) { - for (const id in rpcHandlers) { - const handler = rpcHandlers[id]; - if (!handler || handler.actionKey !== key) - continue; - T3Connection.send(JSON.stringify({ _tag: "Interrupt", requestId: id })); - dropRpcHandler(id); - } - clearAction(key); + return T3Actions.clearAction(key); } - function failureMessage(msg, fallback) { - const found = Helpers.findErrorText(msg ? msg.exit : null, 0); - return found !== "" ? found.slice(0, 240) : fallback; + function rejectAction(key, message, awaitResolution) { + return T3Actions.rejectAction(key, message, awaitResolution); } - function rejectAction(key, message, awaitResolution) { - putActionState(key, { - pending: false, - error: message, - commandId: "", - awaitResolution: awaitResolution === true, - startedAt: Date.now() - }); - return ""; - } - - // Dispatch commands one at a time. A later command is never attempted - // after an earlier rejection, and reconnecting never replays the batch. function dispatchBatch(commands, key, options) { - const opts = options ?? {}; - if (!Helpers.canBeginAction(actionStates, key)) - return ""; - if (!T3Connection.canOperate) - return rejectAction(key, "This pairing is read-only", opts.awaitResolution); - if (T3Connection.state !== "connected") - return rejectAction(key, "Not connected", opts.awaitResolution); - if (!Array.isArray(commands) || commands.length === 0) - return rejectAction(key, "Nothing to send", opts.awaitResolution); - - const firstId = commands[0].commandId ?? genId(); - commands[0].commandId = firstId; - beginAction(key, firstId, opts.awaitResolution); - - function sendAt(index) { - if (!root.actionStates[key] || root.actionStates[key].pending !== true) - return; - if (index >= commands.length) { - if (opts.awaitResolution !== true && opts.holdAfterSuccess !== true) - root.clearAction(key); - opts.onSuccess?.(); - return; - } - const command = commands[index]; - if (!command.commandId) - command.commandId = root.genId(); - const current = root.actionStates[key]; - root.putActionState(key, Object.assign({}, current, { - commandId: command.commandId, - startedAt: Date.now() - })); - requestOnce("orchestration.dispatchCommand", command, () => { - sendAt(index + 1); - }, error => { - root.failAction(key, error || "Command rejected"); - opts.onFailure?.(error); - console.warn("t3code: command rejected:", error); - }, { actionKey: key, fallback: "Command rejected" }); - } - - sendAt(0); - return firstId; + return T3Actions.dispatchBatch(commands, key, options); } - // Approval/input actions remain pending after RPC acceptance until the - // provider's matching resolution activity arrives. function dispatch(command, key, awaitResolution) { - return dispatchBatch([command], key, { awaitResolution: awaitResolution === true }); + return T3Actions.dispatch(command, key, awaitResolution); } - // decision: "accept" | "acceptForSession" | "decline" function respondApproval(threadId, requestId, decision) { - const key = actionKey("approval", threadId, requestId); - return dispatch({ - type: "thread.approval.respond", - commandId: genId(), - threadId: threadId, - requestId: requestId, - decision: decision, - createdAt: new Date().toISOString() - }, key, true); - } - - // Answers are deliberately narrowed to the two provider contract shapes - // the dropdown can author: a string or an array of strings. + return T3Actions.respondApproval(threadId, requestId, decision); + } + function respondUserInput(threadId, requestId, answers) { - const key = actionKey("input", threadId, requestId); - const normalized = {}; - let answerCount = 0; - if (!answers || typeof answers !== "object" || Array.isArray(answers)) { - putActionState(key, { pending: false, error: "Every question needs an answer", - commandId: "", awaitResolution: true, startedAt: Date.now() }); - return ""; - } - for (const questionId in answers) { - const value = answers[questionId]; - if (typeof value === "string") { - const answer = value.trim(); - if (answer === "") - continue; - normalized[questionId] = answer; - answerCount++; - } else if (Array.isArray(value)) { - const labels = value.filter(label => typeof label === "string") - .map(label => label.trim()).filter(label => label !== ""); - if (labels.length === 0) - continue; - normalized[questionId] = Array.from(new Set(labels)); - answerCount++; - } else { - putActionState(key, { pending: false, error: "Unsupported answer format", - commandId: "", awaitResolution: true, startedAt: Date.now() }); - return ""; - } - } - if (answerCount === 0) { - putActionState(key, { pending: false, error: "Every question needs an answer", - commandId: "", awaitResolution: true, startedAt: Date.now() }); - return ""; - } - return dispatch({ - type: "thread.user-input.respond", - commandId: genId(), - threadId: threadId, - requestId: requestId, - answers: normalized, - createdAt: new Date().toISOString() - }, key, true); + return T3Actions.respondUserInput(threadId, requestId, answers); } function settle(threadId) { - const key = actionKey("settle", threadId, ""); - const thread = T3Threads.threadMap[threadId]; - if (!supportsSettlement) - return rejectAction(key, "Settlement is not supported by this server", false); - if (!Helpers.canOperateLifecycle(thread, Date.now())) - return rejectAction(key, "Wait for the thread to become idle", false); - return dispatch({ - type: "thread.settle", - commandId: genId(), - threadId: threadId - }, key, false); + return T3Actions.settle(threadId); } function unsettle(threadId) { - const key = actionKey("unsettle", threadId, ""); - if (!supportsSettlement) - return rejectAction(key, "Settlement is not supported by this server", false); - return dispatch({ - type: "thread.unsettle", - commandId: genId(), - threadId: threadId, - reason: "user" - }, key, false); + return T3Actions.unsettle(threadId); } function settleMany(threadIds) { - const key = actionKey("bulk-settle", "", ""); - if (!supportsSettlement) - return rejectAction(key, "Settlement is not supported by this server", false); - const ids = Array.isArray(threadIds) ? threadIds.filter(id => - Helpers.canOperateLifecycle(T3Threads.threadMap[id], Date.now())) : []; - const commands = ids.map(id => ({ - type: "thread.settle", commandId: genId(), threadId: id - })); - return dispatchBatch(commands, key, {}); + return T3Actions.settleMany(threadIds); } function snooze(threadId, snoozedUntil) { - const key = actionKey("snooze", threadId, ""); - const thread = T3Threads.threadMap[threadId]; - if (!supportsSnooze) - return rejectAction(key, "Snooze is not supported by this server", false); - if (!Helpers.canOperateLifecycle(thread, Date.now())) - return rejectAction(key, "Wait for the thread to become idle", false); - if (isNaN(Date.parse(snoozedUntil)) || Date.parse(snoozedUntil) <= Date.now()) - return rejectAction(key, "Choose a future wake time", false); - return dispatch({ - type: "thread.snooze", - commandId: genId(), - threadId: threadId, - snoozedUntil: snoozedUntil - }, key, false); + return T3Actions.snooze(threadId, snoozedUntil); } function unsnooze(threadId) { - const key = actionKey("unsnooze", threadId, ""); - if (!supportsSnooze) - return rejectAction(key, "Snooze is not supported by this server", false); - return dispatch({ - type: "thread.unsnooze", - commandId: genId(), - threadId: threadId, - reason: "user" - }, key, false); - } - - // Pinning is metadata, not lifecycle: the reference client offers it on - // running and blocked threads alike, so there is no idleness gate here. - // orderKey is omitted — the bar never reorders pins. + return T3Actions.unsnooze(threadId); + } + function pin(threadId) { - const key = actionKey("pin", threadId, ""); - if (!supportsPinning) - return rejectAction(key, "Pinning is not supported by this server", false); - return dispatch({ - type: "thread.pin", - commandId: genId(), - threadId: threadId - }, key, false); + return T3Actions.pin(threadId); } function unpin(threadId) { - const key = actionKey("unpin", threadId, ""); - if (!supportsPinning) - return rejectAction(key, "Pinning is not supported by this server", false); - return dispatch({ - type: "thread.unpin", - commandId: genId(), - threadId: threadId - }, key, false); + return T3Actions.unpin(threadId); } function interrupt(threadId) { - return dispatch({ - type: "thread.turn.interrupt", - commandId: genId(), - threadId: threadId, - createdAt: new Date().toISOString() - }, actionKey("interrupt", threadId, ""), false); + return T3Actions.interrupt(threadId); } function stopSession(threadId) { - return dispatch({ - type: "thread.session.stop", - commandId: genId(), - threadId: threadId, - createdAt: new Date().toISOString() - }, actionKey("session-stop", threadId, ""), false); + return T3Actions.stopSession(threadId); } function renameThread(threadId, title) { - const key = actionKey("rename", threadId, ""); - const normalized = typeof title === "string" ? title.trim() : ""; - if (normalized === "") - return rejectAction(key, "Title cannot be empty", false); - return dispatch({ - type: "thread.meta.update", - commandId: genId(), - threadId: threadId, - title: normalized - }, key, false); + return T3Actions.renameThread(threadId, title); } function regenerateTitle(threadId) { - const key = actionKey("regenerate-title", threadId, ""); - if (!supportsTitleRegeneration) - return rejectAction(key, "Title regeneration is not supported", false); - if (T3Threads.threadMap[threadId]?.titleRegeneration) - return rejectAction(key, "Title regeneration is already running", false); - return dispatch({ - type: "thread.meta.update", - commandId: genId(), - threadId: threadId, - regenerateTitle: true - }, key, false); + return T3Actions.regenerateTitle(threadId); } + function cancelActionRequests(key) { + for (const id in rpcHandlers) { + const handler = rpcHandlers[id]; + if (!handler || handler.actionKey !== key) + continue; + T3Connection.send(JSON.stringify({ _tag: "Interrupt", requestId: id })); + dropRpcHandler(id); + } + clearAction(key); + } + + function failureMessage(msg, fallback) { + const found = Helpers.findErrorText(msg ? msg.exit : null, 0); + return found !== "" ? found.slice(0, 240) : fallback; + } readonly property string shellReqId: "1" property int nextReqId: 2 diff --git a/roles/desktop/files/quickshell/Common/UpdateLogReader.qml b/roles/desktop/files/quickshell/Common/UpdateLogReader.qml new file mode 100644 index 00000000..9d690a69 --- /dev/null +++ b/roles/desktop/files/quickshell/Common/UpdateLogReader.qml @@ -0,0 +1,28 @@ +import QtQuick +import "UpdatesHelpers.js" as UpdatesHelpers + +// A byte-range read is bound to the run and offset that issued it. The +// transaction model owns offsets/parsing; this transport rejects stale reads +// and publishes only complete successful responses. +CommandRequest { + id: root + required property string kind + property string currentRun: "" + property int currentOffset: 0 + property string targetRunStamp: "" + property int sourceOffset: 0 + property int targetOffset: 0 + signal accepted(string body, int offset) + signal stale() + timeoutMessage: kind + " update log read timed out" + onCompleted: (code, body, error) => { + if (UpdatesHelpers.acceptsLogRead(currentRun, currentOffset, + targetRunStamp, sourceOffset, targetOffset, true, code)) { + accepted(body, targetOffset); + } else if (targetRunStamp !== currentRun || sourceOffset !== currentOffset) { + stale(); + } else if (code !== 0) { + console.warn(kind + " update log read failed:", code, error); + } + } +} diff --git a/roles/desktop/files/quickshell/Common/Updates.qml b/roles/desktop/files/quickshell/Common/Updates.qml index 04e6bf8d..5fb25aa8 100644 --- a/roles/desktop/files/quickshell/Common/Updates.qml +++ b/roles/desktop/files/quickshell/Common/Updates.qml @@ -1274,105 +1274,31 @@ Singleton { } } - Process { + UpdateLogReader { id: dnfLogReadProc - property string targetRunStamp: "" - property int sourceOffset: 0 - property int targetOffset: 0 - property string body: "" - property bool exitSeen: false - property int lastExit: -1 - stdout: StdioCollector { - onStreamFinished: dnfLogReadProc.body = text - } - onExited: (exitCode, exitStatus) => { - dnfLogReadProc.exitSeen = true; - dnfLogReadProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - exitSeen = false; - lastExit = -1; - } else if (UpdatesHelpers.acceptsLogRead(root.runStamp, - root.dnfLogOffset, targetRunStamp, sourceOffset, - targetOffset, exitSeen, lastExit)) { - root.consumeBackendLog("dnf", body, targetOffset); - } else if (exitSeen && (targetRunStamp !== root.runStamp - || sourceOffset !== root.dnfLogOffset)) { - // The process slot is free again; immediately service the - // current run rather than waiting for its next status poll. - root.drainBackendLogs(); - } else if (exitSeen && lastExit !== 0) { - console.warn("dnf update log read exited with status", lastExit); - } - } + kind: "dnf" + currentRun: root.runStamp + currentOffset: root.dnfLogOffset + onAccepted: (body, offset) => root.consumeBackendLog("dnf", body, offset) + onStale: root.drainBackendLogs() } - Process { + UpdateLogReader { id: flatpakLogReadProc - property string targetRunStamp: "" - property int sourceOffset: 0 - property int targetOffset: 0 - property string body: "" - property bool exitSeen: false - property int lastExit: -1 - stdout: StdioCollector { - onStreamFinished: flatpakLogReadProc.body = text - } - onExited: (exitCode, exitStatus) => { - flatpakLogReadProc.exitSeen = true; - flatpakLogReadProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - exitSeen = false; - lastExit = -1; - } else if (UpdatesHelpers.acceptsLogRead(root.runStamp, - root.flatpakLogOffset, targetRunStamp, sourceOffset, - targetOffset, exitSeen, lastExit)) { - root.consumeBackendLog("flatpak", body, targetOffset); - } else if (exitSeen && (targetRunStamp !== root.runStamp - || sourceOffset !== root.flatpakLogOffset)) { - root.drainBackendLogs(); - } else if (exitSeen && lastExit !== 0) { - console.warn("flatpak update log read exited with status", lastExit); - } - } + kind: "flatpak" + currentRun: root.runStamp + currentOffset: root.flatpakLogOffset + onAccepted: (body, offset) => root.consumeBackendLog("flatpak", body, offset) + onStale: root.drainBackendLogs() } - Process { + UpdateLogReader { id: firmwareLogReadProc - property string targetRunStamp: "" - property int sourceOffset: 0 - property int targetOffset: 0 - property string body: "" - property bool exitSeen: false - property int lastExit: -1 - stdout: StdioCollector { - onStreamFinished: firmwareLogReadProc.body = text - } - onExited: (exitCode, exitStatus) => { - firmwareLogReadProc.exitSeen = true; - firmwareLogReadProc.lastExit = exitCode; - } - onRunningChanged: { - if (running) { - body = ""; - exitSeen = false; - lastExit = -1; - } else if (UpdatesHelpers.acceptsLogRead(root.runStamp, - root.firmwareLogOffset, targetRunStamp, sourceOffset, - targetOffset, exitSeen, lastExit)) { - root.consumeBackendLog("firmware", body, targetOffset); - } else if (exitSeen && (targetRunStamp !== root.runStamp - || sourceOffset !== root.firmwareLogOffset)) { - root.drainBackendLogs(); - } else if (exitSeen && lastExit !== 0) { - console.warn("firmware update log read exited with status", lastExit); - } - } + kind: "firmware" + currentRun: root.runStamp + currentOffset: root.firmwareLogOffset + onAccepted: (body, offset) => root.consumeBackendLog("firmware", body, offset) + onStale: root.drainBackendLogs() } // The falling edge, so a cancel client that never started still asks diff --git a/roles/desktop/files/quickshell/Common/qmldir b/roles/desktop/files/quickshell/Common/qmldir index 58a0aa22..f20d482c 100644 --- a/roles/desktop/files/quickshell/Common/qmldir +++ b/roles/desktop/files/quickshell/Common/qmldir @@ -21,6 +21,7 @@ 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 @@ -97,3 +98,6 @@ singleton SystemSettings SystemSettings.qml singleton DisplaySettings DisplaySettings.qml SystemSettingsBackend SystemSettingsBackend.qml singleton RemoteServer RemoteServer.qml + +CommandRequest CommandRequest.qml +UpdateLogReader UpdateLogReader.qml diff --git a/roles/desktop/files/quickshell/Settings/InputDraft.js b/roles/desktop/files/quickshell/Settings/InputDraft.js new file mode 100644 index 00000000..ee10389f --- /dev/null +++ b/roles/desktop/files/quickshell/Settings/InputDraft.js @@ -0,0 +1,32 @@ +// Kept free of Qt APIs so the page's actual draft behavior runs under QtTest. +function clone(value) { return JSON.parse(JSON.stringify(value)); } +function patch(current, original, keys) { + var result = {}; + keys.forEach(function(key) { + if (JSON.stringify(current[key]) !== JSON.stringify(original[key])) result[key] = clone(current[key]); + }); + return result; +} +function changeLayout(layouts, index, layout, variant) { + var result = clone(layouts); + result[index] = {layout: layout, variant: variant || ""}; + return result; +} +function move(layouts, index, delta) { + var result = clone(layouts); + var other = index + delta; + if (index >= 0 && index < result.length && other >= 0 && other < result.length) { + var value = result[index]; result[index] = result[other]; result[other] = value; + } + return result; +} +function filtered(choices, query, selected) { + var text = query.trim().toLowerCase(); + var result = choices.filter(function(choice) { + return choice.value === selected || (choice.label + " " + choice.value).toLowerCase().indexOf(text) !== -1; + }); + if (selected && !choices.some(function(choice) { return choice.value === selected; })) + result.unshift({value: selected, label: selected + " (current)"}); + return result; +} +if (typeof module !== "undefined") module.exports = {clone: clone, patch: patch, changeLayout: changeLayout, move: move, filtered: filtered}; diff --git a/roles/desktop/files/quickshell/Settings/KeyboardPage.qml b/roles/desktop/files/quickshell/Settings/KeyboardPage.qml new file mode 100644 index 00000000..64f477da --- /dev/null +++ b/roles/desktop/files/quickshell/Settings/KeyboardPage.qml @@ -0,0 +1,145 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "../Common" +import "InputDraft.js" as Draft + +SettingsPage { + id: page + readonly property SystemSettingsBackend service: SystemSettings.input + property var draft: ({layouts: [], shortcut: ""}) + property var original: ({layouts: [], shortcut: ""}) + property string version: "" + property string query: "" + readonly property var changes: Draft.patch(draft, original, ["layouts", "shortcut"]) + readonly property bool dirty: Object.keys(changes).length > 0 + readonly property var catalog: service.snapshot.catalog || [] + readonly property string disabledReason: service.busy ? "Applying…" : !service.loaded ? "Loading…" : "" + bottomInset: applyBar.reservedHeight + + function load() { + if (!service.loaded || !service.snapshot.keyboard) return; + original = Draft.clone(service.snapshot.keyboard); + draft = Draft.clone(original); + version = service.snapshot.version; + } + function editLayouts(value) { draft = Object.assign({}, draft, {layouts: value}); } + function variants(layout) { + const entry = catalog.find(choice => choice.value === layout); + return entry ? entry.variants : [{value: "", label: "Default"}]; + } + Claim { + active: page.visible && Settings.panelOpen + onClaimed: { page.service.acquire(); page.load(); } + onReleased: page.service.release() + } + Connections { + target: page.service + function onSnapshotChanged() { if (!page.dirty) page.load(); } + function onLoadedChanged() { if (!page.dirty) page.load(); } + function onCompleted(result) { if (result.success) page.original = Draft.clone(page.draft); } + } + overlay: ApplyBar { + id: applyBar + pending: page.dirty + title: "Keyboard changes not applied yet" + detail: "Layouts apply to your desktop session." + busy: page.service.busy + applyEnabled: page.service.loaded && page.draft.layouts.length > 0 + onDiscard: page.load() + onApply: page.service.run({action: "apply", section: "keyboard", version: page.version, values: page.changes}) + } + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Theme.settingsGroupSpacing + SystemServiceStatus { width: parent.width; service: page.service; notice: page.service.message } + SettingsGroup { + width: parent.width + title: "Keyboard layouts" + DraftFieldRow { + width: parent.width + label: "Find a layout" + value: page.query + mono: false + placeholder: "Language or country" + onEdited: text => page.query = text + } + Repeater { + model: page.draft.layouts.length + delegate: RowCluster { + id: entry + required property int index + readonly property var layout: page.draft.layouts[index] + width: parent.width + SelectRow { + width: parent.width + label: "Layout " + (entry.index + 1) + model: Draft.filtered(page.catalog, page.query, entry.layout.layout) + current: entry.layout.layout + disabledReason: page.disabledReason + onPicked: value => page.editLayouts(Draft.changeLayout(page.draft.layouts, entry.index, value, "")) + } + SelectRow { + width: parent.width + label: "Variant" + model: page.variants(entry.layout.layout) + current: entry.layout.variant || "" + disabledReason: page.disabledReason + onPicked: value => page.editLayouts(Draft.changeLayout(page.draft.layouts, entry.index, entry.layout.layout, value)) + } + ValueRow { + width: parent.width + label: entry.index === 0 ? "Default layout" : "Layout order" + SettingsAction { + text: "Move up" + visible: entry.index > 0 + enabled: !page.service.busy + onTriggered: page.editLayouts(Draft.move(page.draft.layouts, entry.index, -1)) + } + SettingsAction { + text: "Remove" + enabled: page.draft.layouts.length > 1 && !page.service.busy + onTriggered: page.editLayouts(page.draft.layouts.filter((_, at) => at !== entry.index)) + } + } + } + } + ValueRow { + width: parent.width + label: "Add another layout" + hint: "Up to four layouts; the first is used when you sign in." + SettingsAction { + text: "Add layout" + glyph: "add" + enabled: page.service.loaded && !page.service.busy && page.draft.layouts.length < 4 && page.catalog.length > 0 + onTriggered: page.editLayouts(page.draft.layouts.concat([{layout: page.catalog.some(c => c.value === "us") ? "us" : page.catalog[0].value, variant: ""}])) + } + } + } + SettingsGroup { + width: parent.width + title: "Switch layouts" + SelectRow { + width: parent.width + label: "Shortcut" + current: page.draft.shortcut + model: Draft.filtered([{value: "", label: "None"}, + {value: "grp:alt_shift_toggle", label: "Alt + Shift"}, {value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift"}, + {value: "grp:caps_toggle", label: "Caps Lock"}], "", page.draft.shortcut) + disabledReason: page.disabledReason + onPicked: value => page.draft = Object.assign({}, page.draft, {shortcut: value}) + } + ValueRow { + width: parent.width + label: "Current layout" + value: (page.service.snapshot.keyboard?.active || []).map(device => device.layout).join(", ") + SettingsAction { + text: "Next layout" + enabled: page.service.loaded && !page.service.busy && !page.dirty + onTriggered: page.service.run({action: "switch"}) + } + } + } + } +} diff --git a/roles/desktop/files/quickshell/Settings/RegionPage.qml b/roles/desktop/files/quickshell/Settings/RegionPage.qml index 73461f6f..902834d1 100644 --- a/roles/desktop/files/quickshell/Settings/RegionPage.qml +++ b/roles/desktop/files/quickshell/Settings/RegionPage.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import "../Common" +import "InputDraft.js" as Draft // Region & formats: how the shell writes the time and the temperature. Each // row's caption reads the result back just before its choices ("Now 19:16", @@ -10,6 +11,40 @@ import "../Common" SettingsPage { id: page pageReset: true + readonly property SystemSettingsBackend service: SystemSettings.region + property string timezone: "" + property string locale: "" + property string timezoneQuery: "" + property string localeQuery: "" + property string originalTimezone: "" + property string originalLocale: "" + property var originalLocaleValues: [] + readonly property bool timezoneDirty: timezone !== originalTimezone + readonly property bool localeDirty: locale !== originalLocale + readonly property string disabledReason: service.busy ? "Applying…" : !service.loaded ? "Loading…" : "" + function load() { + if (!service.loaded) return; + if (!timezoneDirty) timezone = originalTimezone = service.snapshot.timezone || ""; + if (!localeDirty) { + locale = originalLocale = service.snapshot.locale || ""; + originalLocaleValues = service.snapshot.localeValues || []; + } + } + Claim { + active: page.visible && Settings.panelOpen + onClaimed: { page.service.acquire(); page.load(); } + onReleased: page.service.release() + } + Connections { + target: page.service + function onSnapshotChanged() { page.load(); } + function onLoadedChanged() { page.load(); } + function onCompleted(result) { + if (!result.success) return; + if (page.service.request.action === "timezone") page.originalTimezone = page.timezone; + if (page.service.request.action === "locale") page.originalLocale = page.locale; + } + } // The clock caption shows hours and minutes, so tick on the minute, and // only while the page is on screen. @@ -25,6 +60,82 @@ SettingsPage { anchors.top: parent.top spacing: Theme.settingsGroupSpacing + SystemServiceStatus { width: parent.width; service: page.service; notice: page.service.message } + SettingsGroup { + width: parent.width + title: "System timezone" + DraftFieldRow { + width: parent.width + label: "Find a timezone" + value: page.timezoneQuery + placeholder: "City or region" + mono: false + onEdited: text => page.timezoneQuery = text + } + SelectRow { + width: parent.width + label: "Timezone" + current: page.timezone + model: Draft.filtered((page.service.snapshot.timezones || []).map(value => ({value: value, label: value.replace(/_/g, " ")})), page.timezoneQuery, page.timezone) + disabledReason: page.disabledReason + hint: "Changes the timezone for everyone on this computer. Authorization may be required." + onPicked: value => page.timezone = value + } + ValueRow { + width: parent.width + visible: page.timezoneDirty + label: "Timezone change" + SettingsAction { + text: "Discard" + enabled: !page.service.busy + onTriggered: { page.timezone = page.originalTimezone; page.load(); } + } + SettingsAction { + text: "Apply timezone" + primary: true + enabled: page.service.loaded && !page.service.busy + onTriggered: page.service.run({action: "timezone", value: page.timezone, previous: page.originalTimezone}) + } + } + } + SettingsGroup { + width: parent.width + title: "System language" + DraftFieldRow { + width: parent.width + label: "Find a locale" + value: page.localeQuery + placeholder: "For example en_US or nl_NL" + onEdited: text => page.localeQuery = text + } + SelectRow { + width: parent.width + label: "Language and region" + current: page.locale + model: Draft.filtered((page.service.snapshot.locales || []).map(value => ({value: value, label: value})), page.localeQuery, page.locale) + disabledReason: page.disabledReason + hint: "Installed locales only. Applies system wide after signing out; explicit regional format overrides are preserved." + onPicked: value => page.locale = value + } + ValueRow { + width: parent.width + visible: page.localeDirty + label: "Language change" + hint: "Authorization may be required." + SettingsAction { + text: "Discard" + enabled: !page.service.busy + onTriggered: { page.locale = page.originalLocale; page.load(); } + } + SettingsAction { + text: "Apply language" + primary: true + enabled: page.service.loaded && !page.service.busy + onTriggered: page.service.run({action: "locale", value: page.locale, previous: page.originalLocaleValues}) + } + } + } + SettingsGroup { width: parent.width title: "Formats" diff --git a/roles/desktop/files/quickshell/Settings/SettingsView.qml b/roles/desktop/files/quickshell/Settings/SettingsView.qml index 86eb650d..df3ddd28 100644 --- a/roles/desktop/files/quickshell/Settings/SettingsView.qml +++ b/roles/desktop/files/quickshell/Settings/SettingsView.qml @@ -612,6 +612,7 @@ PopoutPanel { case "sound": return soundPage; case "network": return networkPage; case "touchpad": return touchpadPage; + case "keyboard": return keyboardPage; case "power": return powerPage; case "region": return regionPage; case "accounts": return accountsPage; @@ -801,6 +802,7 @@ PopoutPanel { Component { id: displaysPage; DisplaysPage {} } Component { id: accountsPage; AccountsPage {} } Component { id: touchpadPage; TouchpadPage {} } + Component { id: keyboardPage; KeyboardPage {} } Component { id: powerPage; PowerPage {} } Component { id: regionPage; RegionPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/roles/desktop/files/quickshell/Settings/TouchpadPage.qml b/roles/desktop/files/quickshell/Settings/TouchpadPage.qml index 7ba72df5..708f40d4 100644 --- a/roles/desktop/files/quickshell/Settings/TouchpadPage.qml +++ b/roles/desktop/files/quickshell/Settings/TouchpadPage.qml @@ -1,22 +1,91 @@ import QtQuick import "../Common" +import "InputDraft.js" as Draft // Touchpad: how far a two-finger scroll moves. Pointer and keyboard settings // would join it here. SettingsPage { id: page pageReset: true + readonly property SystemSettingsBackend service: SystemSettings.input + property var draft: ({tap: true, naturalScroll: true, sensitivity: 0}) + property var original: ({tap: true, naturalScroll: true, sensitivity: 0}) + property string version: "" + readonly property var changes: Draft.patch(draft, original, ["tap", "naturalScroll", "sensitivity"]) + readonly property bool dirty: Object.keys(changes).length > 0 + readonly property string disabledReason: service.busy ? "Applying…" : !service.loaded ? "Loading…" : "" + bottomInset: applyBar.reservedHeight + function load() { + if (!service.loaded || !service.snapshot.touchpad) return; + original = Draft.clone(service.snapshot.touchpad); + draft = Draft.clone(original); + version = service.snapshot.version; + } + function edit(key, value) { + const updated = Draft.clone(draft); + updated[key] = value; + draft = updated; + } + Claim { + active: page.visible && Settings.panelOpen + onClaimed: { page.service.acquire(); page.load(); } + onReleased: page.service.release() + } + Connections { + target: page.service + function onSnapshotChanged() { if (!page.dirty) page.load(); } + function onLoadedChanged() { if (!page.dirty) page.load(); } + function onCompleted(result) { if (result.success) page.original = Draft.clone(page.draft); } + } + overlay: ApplyBar { + id: applyBar + pending: page.dirty + title: "Touchpad changes not applied yet" + busy: page.service.busy + onDiscard: page.load() + onApply: page.service.run({action: "apply", section: "touchpad", version: page.version, values: page.changes}) + } Column { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top spacing: Theme.settingsGroupSpacing + SystemServiceStatus { width: parent.width; service: page.service; notice: page.service.message } SettingsGroup { width: parent.width title: "Touchpad" + SwitchRow { + width: parent.width + label: "Tap to click" + checked: page.draft.tap + disabledReason: page.disabledReason + onToggled: value => page.edit("tap", value) + } + SwitchRow { + width: parent.width + label: "Natural scrolling" + description: "Move content in the direction your fingers move." + checked: page.draft.naturalScroll + disabledReason: page.disabledReason + onToggled: value => page.edit("naturalScroll", value) + } + SliderRow { + width: parent.width + label: "Pointer sensitivity" + hint: "Affects touchpads and mice. Per-device Hyprland rules take precedence." + min: -1 + max: 1 + step: 0.05 + decimals: 2 + unit: "" + value: page.draft.sensitivity + disabledReason: page.disabledReason + onMoved: value => page.edit("sensitivity", value) + } + SliderRow { width: parent.width label: "Scroll speed" diff --git a/roles/desktop/files/quickshell/Settings/qmldir b/roles/desktop/files/quickshell/Settings/qmldir index b809fa6e..b8a96cb1 100644 --- a/roles/desktop/files/quickshell/Settings/qmldir +++ b/roles/desktop/files/quickshell/Settings/qmldir @@ -25,6 +25,7 @@ SwitchRow SwitchRow.qml PowerPage PowerPage.qml RegionPage RegionPage.qml TouchpadPage TouchpadPage.qml +KeyboardPage KeyboardPage.qml NightLightGroup NightLightGroup.qml RecoveryGroup RecoveryGroup.qml UndoChip UndoChip.qml diff --git a/roles/desktop/files/quickshell/safe-mode/shell.qml b/roles/desktop/files/quickshell/safe-mode/shell.qml new file mode 100644 index 00000000..218221fd --- /dev/null +++ b/roles/desktop/files/quickshell/safe-mode/shell.qml @@ -0,0 +1,91 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Wayland + +// 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(["kitty"]); } + function recover() { Quickshell.execDetached(["cybexos-runtime", "shell", "recover"]); } + + GlobalShortcut { + appid: "quickshell" + name: "launcherToggle" + description: "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: "CybexOS 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: "Widgets paused · Your settings are preserved · cybex 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/settings-store b/roles/desktop/files/quickshell/scripts/settings-store new file mode 100644 index 00000000..102d8d38 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/settings-store @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Merge a settings edit with current disk contents and atomically commit it.""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import sys +import tempfile + +MISSING = object() +LIMIT = 2 * 1024 * 1024 + + +def parse(text): + value = json.loads(text) if text.strip() else {} + if not isinstance(value, dict): + raise ValueError('Settings must contain a JSON object') + return value + + +def merge(base, desired, current, path=''): + """Three-way merge: unrelated edits survive; conflicting edits are refused.""" + if desired == base or desired == current: + return current + if current == base: + return desired + if all(isinstance(value, dict) for value in (base, desired, current)): + out = dict(current) + for key in set(base) | set(desired): + # The caller separately verifies that the running schema supports + # the latest file; a concurrent migration to that schema is safe. + if not path and key == 'v': + out[key] = desired.get(key, current.get(key)) + continue + value = merge(base.get(key, MISSING), desired.get(key, MISSING), + current.get(key, MISSING), path + '/' + key) + if value is MISSING: + out.pop(key, None) + else: + out[key] = value + return out + raise ValueError('Another writer changed ' + (path or '/') + '; reload before retrying') + + +def backup(path, text, label): + saved = path.with_name(path.name + '.' + label + '-' + hashlib.sha256(text.encode()).hexdigest()[:16]) + try: + with saved.open('x') as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + except FileExistsError: + pass + return str(saved) + + +def commit(path, baseline, candidate, version): + path = Path(path) + if path.is_symlink() or any(parent.is_symlink() for parent in path.parents): + raise ValueError('Settings path must not contain symlinks') + base, desired = parse(baseline), parse(candidate) + if desired.get('v') != version: + raise ValueError('Candidate settings schema does not match the running shell') + path.parent.mkdir(parents=True, exist_ok=True) + with path.with_name(path.name + '.lock').open('a') as lock: + os.fchmod(lock.fileno(), 0o600) + fcntl.flock(lock, fcntl.LOCK_EX) + for _attempt in range(3): + if path.exists() and path.stat().st_size > LIMIT: + raise ValueError('Settings file is too large') + current_text = path.read_text() if path.exists() else '' + current = parse(current_text) + if isinstance(current.get('v'), (int, float)) and current['v'] > version: + raise ValueError('Settings were saved by a newer shell; refusing to downgrade them') + try: + merged = merge(base, desired, current) + except ValueError as error: + saved = backup(path, candidate, 'conflict') + raise ValueError(str(error) + '. Your pending edit is saved at ' + saved) from error + text = json.dumps(merged, indent=2, ensure_ascii=False) + '\n' + if text == current_text: + return text + if current_text and current.get('v') != version: + backup(path, current_text, 'before-migration') + fd, temporary = tempfile.mkstemp(prefix='.shell-settings-', 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()) + # Cooperative writers are locked. A normal editor is not, so + # re-read immediately before publication and retry its edit. + if (path.read_text() if path.exists() else '') != current_text: + continue + os.replace(temporary, path) + directory = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + return text + finally: + Path(temporary).unlink(missing_ok=True) + raise ValueError('Settings changed repeatedly; retry after the other editor finishes') + + +def main(): + try: + request = json.loads(sys.stdin.readline(LIMIT + 1)) + text = commit(sys.argv[1], request['baseline'], request['candidate'], request['version']) + print(json.dumps({'ok': True, 'text': text})) + except (OSError, ValueError, KeyError, IndexError, TypeError) as error: + print(json.dumps({'ok': False, 'error': str(error)})) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/roles/desktop/files/quickshell/scripts/shell-recovery.py b/roles/desktop/files/quickshell/scripts/shell-recovery.py new file mode 100644 index 00000000..13c68728 --- /dev/null +++ b/roles/desktop/files/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"))) / "cybexos/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["CYBEXOS_SHELL_SAFE_MODE"] = "1" + print("cybexos: starting the recovery desktop; use cybex shell recover to retry", file=sys.stderr, flush=True) + os.execv("/usr/bin/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/roles/desktop/files/quickshell/scripts/system-settings.py index 46e34d56..f6afaa4b 100644 --- a/roles/desktop/files/quickshell/scripts/system-settings.py +++ b/roles/desktop/files/quickshell/scripts/system-settings.py @@ -6,11 +6,13 @@ from system_settings_audio import AudioSettings from system_settings_network import NetworkSettings from system_settings_accounts import AccountSettings +from system_settings_input import InputSettings +from system_settings_region import RegionSettings def main(): try: - if len(sys.argv) != 2 or sys.argv[1] not in ('sound', 'network', 'accounts'): + if len(sys.argv) != 2 or sys.argv[1] not in ('sound', 'network', 'accounts', 'input', 'region'): raise ValueError('Unknown settings service') raw = sys.stdin.buffer.readline(65537) if len(raw) > 65536: @@ -19,7 +21,8 @@ def main(): if not isinstance(request, dict): raise ValueError('Invalid settings request') service = {'sound': AudioSettings, 'network': NetworkSettings, - 'accounts': AccountSettings}[sys.argv[1]]() + 'accounts': AccountSettings, 'input': InputSettings, + 'region': RegionSettings}[sys.argv[1]]() result = service.dispatch(request) print(json.dumps({'success': True, **result}), flush=True) except ValueError as error: diff --git a/roles/desktop/files/quickshell/scripts/system_settings_input.py b/roles/desktop/files/quickshell/scripts/system_settings_input.py new file mode 100644 index 00000000..8941f6b1 --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/system_settings_input.py @@ -0,0 +1,205 @@ +"""Personal input preferences. Reloads preserve the final user.lua layer.""" +import copy +import fcntl +import hashlib +import json +import math +import os +from pathlib import Path +import re +import subprocess +import tempfile +import xml.etree.ElementTree as ET + +MAX_BYTES = 262144 +TOKEN = re.compile(r'^[A-Za-z0-9_-]{1,64}$') +SHORTCUTS = ('', 'grp:alt_shift_toggle', 'grp:ctrl_shift_toggle', 'grp:caps_toggle') + + +def run(args): + result = subprocess.run(args, capture_output=True, text=True, timeout=8, check=False) + if result.returncode: + raise ValueError('Hyprland could not apply this change. Check your session and retry.') + return result.stdout.strip() + + +def reject_duplicates(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError('Duplicate input preference key') + result[key] = value + return result + + +def validate(document): + if not isinstance(document, dict) or type(document.get('v')) not in (int, float) or document['v'] != 1: + raise ValueError('Unsupported input preferences version') + keyboard = document.get('keyboard', {}) + touchpad = document.get('touchpad', {}) + if not isinstance(keyboard, dict) or not isinstance(touchpad, dict): + raise ValueError('Invalid input preferences') + if 'layouts' in keyboard: + layouts = keyboard['layouts'] + if not isinstance(layouts, list) or not 1 <= len(layouts) <= 4: + raise ValueError('Choose between one and four keyboard layouts') + for entry in layouts: + if (not isinstance(entry, dict) or not isinstance(entry.get('layout'), str) + or not TOKEN.fullmatch(entry['layout']) or not isinstance(entry.get('variant', ''), str) + or (entry.get('variant') and not TOKEN.fullmatch(entry['variant']))): + raise ValueError('Invalid keyboard layout or variant') + if 'shortcut' in keyboard and keyboard['shortcut'] not in SHORTCUTS: + raise ValueError('Invalid layout switching shortcut') + for key in ('tap', 'naturalScroll'): + if key in touchpad and type(touchpad[key]) is not bool: + raise ValueError('Touchpad switches must be true or false') + if 'sensitivity' in touchpad: + value = touchpad['sensitivity'] + if type(value) not in (int, float) or not math.isfinite(value) or not -1 <= value <= 1: + raise ValueError('Touchpad sensitivity must be between -1 and 1') + return document + + +def catalog(path=Path('/usr/share/X11/xkb/rules/evdev.xml')): + result = [] + for layout in ET.parse(path).getroot().findall('./layoutList/layout'): + info = layout.find('configItem') + name = info.findtext('name', '') + if not TOKEN.fullmatch(name): + continue + variants = [{'value': '', 'label': 'Default'}] + for entry in layout.findall('./variantList/variant/configItem'): + variant = entry.findtext('name', '') + if TOKEN.fullmatch(variant): + variants.append({'value': variant, 'label': entry.findtext('description', variant)}) + result.append({'value': name, 'label': info.findtext('description', name), 'variants': variants}) + return result + + +class InputSettings: + def __init__(self): + self.path = Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'cybexos/input.json' + + def read(self): + if self.path.is_symlink(): + raise ValueError('Input preferences are a symlink; edit the linked file directly.') + try: + with self.path.open('rb') as source: + raw = source.read(MAX_BYTES + 1) + except FileNotFoundError: + raw = b'' + if len(raw) > MAX_BYTES: + raise ValueError('Input preferences are too large') + try: + document = validate(json.loads(raw, object_pairs_hook=reject_duplicates)) if raw else {'v': 1} + except (ValueError, UnicodeError, RecursionError) as error: + raise ValueError('Input preferences are invalid. Repair input.json before applying changes.') from error + return document, hashlib.sha256(raw).hexdigest(), raw + + def option(self, name, field): + value = json.loads(run(['hyprctl', '-j', 'getoption', 'input:' + name])) + if not isinstance(value, dict): + raise ValueError('Hyprland did not report its input configuration') + if field == 'bool': + # Current Hyprland reports Boolean options as JSON booleans; + # older releases encoded the same switches as integer 0/1. + # Do not coerce strings such as "false" into a true switch. + if 'bool' in value and type(value['bool']) is bool: + return value['bool'] + if 'bool' not in value and type(value.get('int')) is int and value['int'] in (0, 1): + return bool(value['int']) + raise ValueError('Hyprland did not report its input configuration') + if field not in value: + raise ValueError('Hyprland did not report its input configuration') + return value[field] + + def snapshot(self): + document, version, _ = self.read() + layouts = str(self.option('kb_layout', 'str')).split(',') + variants = str(self.option('kb_variant', 'str')).split(',') + options = str(self.option('kb_options', 'str')).split(',') + devices = json.loads(run(['hyprctl', '-j', 'devices'])) + return {'version': version, 'catalog': catalog(), 'preferences': document, + 'keyboard': {'layouts': [{'layout': name, 'variant': variants[index] if index < len(variants) else ''} + for index, name in enumerate(layouts)], + 'shortcut': next((option for option in options if option.startswith('grp:')), ''), + 'active': [{'name': item.get('name', ''), 'layout': item.get('active_keymap', '')} + for item in devices.get('keyboards', []) if item.get('main', False)]}, + 'touchpad': {'tap': self.option('touchpad:tap_to_click', 'bool'), + 'naturalScroll': self.option('touchpad:natural_scroll', 'bool'), + 'sensitivity': float(self.option('sensitivity', 'float'))}} + + def atomic_write(self, raw): + fd, name = tempfile.mkstemp(prefix='.input-', dir=self.path.parent) + try: + with os.fdopen(fd, 'wb') as target: + target.write(raw) + target.flush() + os.fsync(target.fileno()) + os.replace(name, self.path) + directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(name): + os.unlink(name) + + def dispatch(self, request): + action = request.get('action', 'snapshot') + if action == 'snapshot': + return self.snapshot() + if action == 'switch': + run(['hyprctl', 'switchxkblayout', 'all', 'next']) + return {'message': 'Keyboard layout switched'} + if action != 'apply' or request.get('section') not in ('keyboard', 'touchpad'): + raise ValueError('Unknown input operation') + section = request['section'] + patch = request.get('values') + allowed = {'keyboard': {'layouts', 'shortcut'}, 'touchpad': {'tap', 'naturalScroll', 'sensitivity'}}[section] + if not isinstance(patch, dict) or not patch or set(patch) - allowed: + raise ValueError('Invalid input change') + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + lock_fd = os.open(self.path.with_suffix('.lock'), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(lock_fd, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + document, version, previous = self.read() + if request.get('version') != version: + raise ValueError('Input preferences changed elsewhere. Discard the pending edits and refresh before applying.') + candidate = copy.deepcopy(document) + candidate.setdefault(section, {}).update(patch) + validate(candidate) + if section == 'keyboard' and 'layouts' in patch: + available = {item['value']: {variant['value'] for variant in item['variants']} for item in catalog()} + for entry in patch['layouts']: + if entry.get('variant', '') not in available.get(entry['layout'], set()): + raise ValueError('That keyboard layout or variant is not installed') + # Preserve future per-layout metadata for unchanged identities. + old = {(entry['layout'], entry.get('variant', '')): entry for entry in document.get('keyboard', {}).get('layouts', [])} + candidate[section]['layouts'] = [{**old.get((entry['layout'], entry.get('variant', '')), {}), **entry} + for entry in patch['layouts']] + payload = (json.dumps(candidate, ensure_ascii=False, allow_nan=False, indent=2) + '\n').encode() + if len(payload) > MAX_BYTES: + raise ValueError('Input preferences are too large') + try: + self.atomic_write(payload) + output = run(['hyprctl', 'reload']) + if output.lower() != 'ok': + 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)']) + if output.lower() != 'ok': + raise ValueError('Hyprland rejected the saved input preferences') + except Exception as error: + if previous: + self.atomic_write(previous) + else: + self.path.unlink(missing_ok=True) + try: + run(['hyprctl', 'reload']) + except Exception: + raise ValueError('Input preferences were restored, but Hyprland could not reload. Sign out to restore the session.') from error + raise ValueError('Hyprland could not apply the change. Previous preferences were restored.') from error + return {'message': 'Input preferences saved. Personal Hyprland overrides still take precedence.'} diff --git a/roles/desktop/files/quickshell/scripts/system_settings_region.py b/roles/desktop/files/quickshell/scripts/system_settings_region.py new file mode 100644 index 00000000..fd5c4bcb --- /dev/null +++ b/roles/desktop/files/quickshell/scripts/system_settings_region.py @@ -0,0 +1,79 @@ +"""System region settings through timedated/localed's normal Polkit policy.""" +import os +import subprocess + +TIME = 'org.freedesktop.timedate1' +LOCALE = 'org.freedesktop.locale1' + + +def choices(command): + result = subprocess.run(command, capture_output=True, text=True, timeout=5, check=False, + env={**os.environ, 'LC_ALL': 'C', 'SYSTEMD_PAGER': ''}) + if result.returncode: + raise ValueError('System region choices are unavailable. Check systemd and language packages.') + return sorted(set(result.stdout.splitlines())) + + +class RegionSettings: + def __init__(self): + import gi + gi.require_version('Gio', '2.0') + from gi.repository import Gio, GLib + self.Gio, self.GLib = Gio, GLib + self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + + def call(self, service, interface, method, signature, values, interactive=False): + try: + flags = (self.Gio.DBusCallFlags.ALLOW_INTERACTIVE_AUTHORIZATION if interactive + else self.Gio.DBusCallFlags.NONE) + return self.bus.call_sync(service, '/' + service.replace('.', '/'), interface, method, + self.GLib.Variant(signature, values), None, flags, + 45000 if interactive else 5000, None).unpack() + except self.GLib.Error as error: + # Do not expose arbitrary D-Bus error text; identify the actionable cases. + remote = self.Gio.DBusError.get_remote_error(error) or '' + if any(word in remote.lower() for word in ('accessdenied', 'notauthorized', 'authfailed', 'cancelled')): + raise ValueError('Authorization was cancelled or denied. Retry and approve the system prompt.') from error + raise ValueError('The system region service did not finish. Refresh to check the current value before retrying.') from error + + def properties(self, service): + return self.call(service, 'org.freedesktop.DBus.Properties', 'GetAll', '(s)', (service,))[0] + + def snapshot(self): + time = self.properties(TIME) + locale = self.properties(LOCALE).get('Locale', []) + return {'timezone': time.get('Timezone', ''), + 'locale': next((value[5:] for value in locale if value.startswith('LANG=')), ''), + 'localeValues': locale, + 'timezones': choices(['timedatectl', 'list-timezones', '--no-pager']), + 'locales': choices(['localectl', 'list-locales', '--no-pager'])} + + def dispatch(self, request): + action = request.get('action', 'snapshot') + if action == 'snapshot': + return self.snapshot() + if action == 'timezone': + value = request.get('value') + if not isinstance(value, str) or value not in choices(['timedatectl', 'list-timezones', '--no-pager']): + 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'} + if action == 'locale': + value = request.get('value') + if not isinstance(value, str) or value not in choices(['localectl', 'list-locales', '--no-pager']): + raise ValueError('Choose an installed locale. Install its language pack first if it is missing.') + 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.'} + raise ValueError('Unknown region operation') diff --git a/roles/desktop/files/quickshell/shell.qml b/roles/desktop/files/quickshell/shell.qml index 3db3d63a..ca66af15 100644 --- a/roles/desktop/files/quickshell/shell.qml +++ b/roles/desktop/files/quickshell/shell.qml @@ -66,10 +66,13 @@ ShellRoot { 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"]) { + 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, diff --git a/roles/desktop/tasks/hermes-menubar.yml b/roles/desktop/tasks/hermes-menubar.yml index 0ae8de9e..a328bedc 100644 --- a/roles/desktop/tasks/hermes-menubar.yml +++ b/roles/desktop/tasks/hermes-menubar.yml @@ -138,6 +138,17 @@ notify: Restart Hermes menubar bridge tags: [quickshell, hermes-menubar] +- name: Install the remote Hermes domain modules + become: true + become_user: "{{ primary_user }}" + ansible.builtin.copy: + src: hermes-menubar-bridge/cybex_hermes/ + dest: "{{ primary_home }}/.local/libexec/cybex_hermes/" + mode: "0644" + directory_mode: "0755" + notify: Restart Hermes menubar bridge + tags: [quickshell, hermes-menubar] + - name: Install the remote Hermes menubar bridge user unit become: true become_user: "{{ primary_user }}" diff --git a/roles/desktop/tasks/main.yml b/roles/desktop/tasks/main.yml index dadca2ab..b1b0528c 100644 --- a/roles/desktop/tasks/main.yml +++ b/roles/desktop/tasks/main.yml @@ -118,6 +118,8 @@ # link telemetry. - NetworkManager - NetworkManager-libnm + - xkeyboard-config + - tzdata - glib2 - nm-connection-editor - pulseaudio-utils @@ -197,6 +199,7 @@ state: absent loop: - "{{ primary_home }}/.local/libexec/hermes-menubar-bridge" + - "{{ primary_home }}/.local/libexec/cybex_hermes" - "{{ primary_home }}/.config/systemd/user/hermes-menubar-bridge.service" - "{{ primary_home }}/.config/systemd/user/hyprland-session.target.wants/hermes-menubar-bridge.service" notify: Reload user systemd @@ -488,6 +491,7 @@ - bindings.lua - autostart.lua - displays.lua + - input_preferences.lua # This entrypoint requires every module above. Keeping it last makes an # empty live configuration valid at each observable deployment boundary. - hyprland.lua @@ -535,6 +539,20 @@ changed_when: true when: desktop_ibus_schema_override is changed +- name: Default Files SMB connections to WORKGROUP + ansible.builtin.copy: + src: 90-cybexos-smb.gschema.override + dest: /usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override + owner: root + group: root + mode: "0644" + register: desktop_smb_schema_override + +- name: Compile GSettings schemas after changing the SMB default + ansible.builtin.command: glib-compile-schemas /usr/share/glib-2.0/schemas + changed_when: true + when: desktop_smb_schema_override is changed + - name: Install the singleton session-lock user unit before its callers become: true become_user: "{{ primary_user }}" @@ -597,7 +615,7 @@ label: "{{ item.path }}" when: - not hypr_runtime_path_normalization_pending | bool - - item.path | basename not in ['features.lua', 'monitors.lua', 'input.lua', 'looknfeel.lua', 'bindings.lua', 'autostart.lua', 'displays.lua', 'hyprland.lua', 'hypridle.conf', 'hyprlock.conf'] + - item.path | basename not in ['features.lua', 'monitors.lua', 'input.lua', 'looknfeel.lua', 'bindings.lua', 'autostart.lua', 'displays.lua', 'input_preferences.lua', 'hyprland.lua', 'hypridle.conf', 'hyprlock.conf'] tags: [browser] - name: Install the guarded desktop runtime resolver diff --git a/roles/desktop/templates/input.lua.j2 b/roles/desktop/templates/input.lua.j2 index 9a12992b..918a4cdc 100644 --- a/roles/desktop/templates/input.lua.j2 +++ b/roles/desktop/templates/input.lua.j2 @@ -15,12 +15,15 @@ local function persisted_scroll_factor() return 1.0 end +-- Saved input preferences reuse the release's option defaults. +_G.__cybexos_vendor_keyboard_options = "compose:caps,lv3:ralt_switch" + hl.config({ input = { kb_layout = {{ machine_keyboard_layout | to_json }}, kb_variant = {{ machine_keyboard_variant | to_json }}, -- Keep left Alt available to applications (for example Codex Alt+Up). - kb_options = "compose:caps,lv3:ralt_switch", + kb_options = _G.__cybexos_vendor_keyboard_options, follow_mouse = 1, natural_scroll = true, repeat_rate = 40, diff --git a/roles/desktop/templates/quickshell.service.j2 b/roles/desktop/templates/quickshell.service.j2 index 17ac1a8d..2954737d 100644 --- a/roles/desktop/templates/quickshell.service.j2 +++ b/roles/desktop/templates/quickshell.service.j2 @@ -5,6 +5,7 @@ PartOf=hyprland-session.target [Service] Type=simple ExecStart={{ primary_home }}/.local/bin/cybexos-runtime exec quickshell +ExecStopPost={{ primary_home }}/.local/bin/cybexos-runtime shell record-stop Environment=PATH={{ primary_home }}/.local/bin:{{ primary_home }}/.npm-global/bin:/usr/local/bin:/usr/bin Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} diff --git a/roles/dotfiles/files/gitconfig b/roles/dotfiles/files/gitconfig new file mode 100644 index 00000000..cd6d7c0a --- /dev/null +++ b/roles/dotfiles/files/gitconfig @@ -0,0 +1,13 @@ +[core] + pager = delta +[interactive] + diffFilter = delta --color-only +[delta] + navigate = true + side-by-side = true +[credential "https://github.com"] + helper = + helper = !/usr/bin/gh auth git-credential +[credential "https://gist.github.com"] + helper = + helper = !/usr/bin/gh auth git-credential diff --git a/roles/dotfiles/files/ssh.conf b/roles/dotfiles/files/ssh.conf new file mode 100644 index 00000000..28867020 --- /dev/null +++ b/roles/dotfiles/files/ssh.conf @@ -0,0 +1,4 @@ +Host * + IdentityAgent ~/.1password/agent.sock + StrictHostKeyChecking accept-new + HashKnownHosts yes diff --git a/roles/dotfiles/tasks/personal.yml b/roles/dotfiles/tasks/personal.yml index 4782f467..3c269a3c 100644 --- a/roles/dotfiles/tasks/personal.yml +++ b/roles/dotfiles/tasks/personal.yml @@ -22,71 +22,61 @@ - name: Install the managed Kitty fragment become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: - src: kitty.conf + cybexos_managed_file: + content: "{{ lookup('file', role_path + '/files/kitty.conf', rstrip=false) }}" dest: "{{ primary_home }}/.config/kitty/cybexos.conf" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" mode: "0644" when: manage_personal_dotfiles | bool - name: Include the managed Kitty fragment without replacing user configuration become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.config/kitty/kitty.conf" - create: true - backup: true - mode: "0644" - marker: "# {mark} CYBEXOS MANAGED INCLUDE" - block: "include cybexos.conf" + kind: kitty + when: manage_personal_dotfiles | bool + +- name: Inspect personal Git credential helpers without changing them + become: true + become_user: "{{ primary_user }}" + cybexos_user_include: + path: "{{ primary_home }}/.gitconfig" + kind: git + inspect_git_credentials: true + register: dotfiles_git_credentials when: manage_personal_dotfiles | bool - name: Install optional managed Git preferences become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: + cybexos_managed_file: dest: "{{ primary_home }}/.config/cybexos/gitconfig" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" mode: "0644" - content: | - [core] - pager = delta - [interactive] - diffFilter = delta --color-only - [delta] - navigate = true - side-by-side = true - [credential "https://github.com"] - helper = - helper = !/usr/bin/gh auth git-credential - [credential "https://gist.github.com"] - helper = - helper = !/usr/bin/gh auth git-credential + baseline: "{{ lookup('file', role_path + '/files/gitconfig', rstrip=false) }}" + content: >- + {{ lookup('file', role_path + '/files/gitconfig', rstrip=false).split('[credential')[0] + if dotfiles_git_credentials.personal_credentials | bool + else lookup('file', role_path + '/files/gitconfig', rstrip=false) }} when: manage_personal_dotfiles | bool - name: Include managed Git preferences without replacing user identity become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.gitconfig" - create: true - backup: true - mode: "0644" - marker: "# {mark} CYBEXOS MANAGED INCLUDE" - block: | - [include] - path = ~/.config/cybexos/gitconfig + kind: git when: manage_personal_dotfiles | bool - name: Configure SSH to use the 1Password agent without private key references become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: - dest: "{{ primary_home }}/.ssh/config.d/cybexos.conf" + cybexos_managed_file: + dest: "{{ primary_home }}/.config/cybexos/ssh.conf" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" mode: "0600" - content: | - Host * - IdentityAgent ~/.1password/agent.sock - StrictHostKeyChecking accept-new - HashKnownHosts yes + content: "{{ lookup('file', role_path + '/files/ssh.conf', rstrip=false) }}" when: - manage_personal_dotfiles | bool - features.proprietary_apps | bool @@ -94,35 +84,46 @@ - name: Include managed SSH preferences without replacing existing hosts become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.ssh/config" - create: true - backup: true - mode: "0600" - insertbefore: BOF - marker: "# {mark} CYBEXOS MANAGED INCLUDE" - block: "Include ~/.ssh/config.d/cybexos.conf" + kind: ssh + register: dotfiles_ssh_include when: - manage_personal_dotfiles | bool - features.proprietary_apps | bool +# Retire only the byte-identical old default. Keeping vendor defaults inside +# config.d made a user's wildcard Include apply them before their own values. +- name: Retire the unedited legacy SSH fragment + become: true + become_user: "{{ primary_user }}" + cybexos_managed_file: + dest: "{{ primary_home }}/.ssh/config.d/cybexos.conf" + state: absent + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" + baseline: "{{ lookup('file', role_path + '/files/ssh.conf', rstrip=false) }}" + when: + - manage_personal_dotfiles | bool + - features.proprietary_apps | bool + - not dotfiles_ssh_include.preserved | default(true) | bool + - name: Remove the CybexOS SSH include when proprietary integration is deselected become: true become_user: "{{ primary_user }}" - ansible.builtin.blockinfile: + cybexos_user_include: path: "{{ primary_home }}/.ssh/config" - marker: "# {mark} CYBEXOS MANAGED INCLUDE" + kind: ssh state: absent - failed_when: false when: - not features.proprietary_apps | bool - apps_feature_owned.proprietary_apps | default(false) | bool -- name: Remove the scoped SSH preferences when proprietary integration is deselected +- name: Remove unedited scoped SSH preferences when proprietary integration is deselected become: true become_user: "{{ primary_user }}" - ansible.builtin.file: - path: "{{ primary_home }}/.ssh/config.d/cybexos.conf" + cybexos_managed_file: + dest: "{{ primary_home }}/.config/cybexos/ssh.conf" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" state: absent when: - not features.proprietary_apps | bool @@ -133,6 +134,7 @@ become_user: "{{ primary_user }}" ansible.builtin.copy: dest: "{{ primary_home }}/.config/user-dirs.dirs" + force: false mode: "0644" content: | XDG_DESKTOP_DIR="$HOME/" diff --git a/roles/dotfiles/tasks/shell-defaults.yml b/roles/dotfiles/tasks/shell-defaults.yml index c220b232..3c5beeb2 100644 --- a/roles/dotfiles/tasks/shell-defaults.yml +++ b/roles/dotfiles/tasks/shell-defaults.yml @@ -10,8 +10,9 @@ - name: Install Fish shell configuration become: true become_user: "{{ primary_user }}" - ansible.builtin.copy: - src: fish-config.fish + cybexos_managed_file: + content: "{{ lookup('file', role_path + '/files/fish-config.fish', rstrip=false) }}" + ledger: "{{ primary_home }}/.local/state/cybexos/defaults/ownership.json" dest: "{{ primary_home }}/.config/fish/conf.d/50-cybexos.fish" mode: "0644" tags: [shell-defaults] diff --git a/roles/dotfiles/templates/cybex.j2 b/roles/dotfiles/templates/cybex.j2 index 38f0990d..1a82a110 100644 --- a/roles/dotfiles/templates/cybex.j2 +++ b/roles/dotfiles/templates/cybex.j2 @@ -16,6 +16,9 @@ case $command_name in update) exec "$source_dir/update" "$@" ;; + upgrade-system) + exec sudo /usr/local/libexec/cybexos-major-upgrade "$@" + ;; agent) exec "$agent_command" "$@" ;; @@ -25,6 +28,9 @@ case $command_name in plugin) exec {{ (primary_home + '/.local/bin/cybexos-runtime') | quote }} plugin "$@" ;; + shell) + exec {{ (primary_home + '/.local/bin/cybexos-runtime') | quote }} shell "$@" + ;; verify|doctor) verify_scope=false for argument in "$@"; do @@ -50,9 +56,11 @@ Commands: install Install using saved choices, or start first-run setup configure Ask the installation questions again and apply the answers update Check for and apply CybexOS and system updates + upgrade-system Prepare, perform, or inspect a supported Fedora major upgrade agent Launch or choose the default AI coding agent dev Select, inspect, or disable a live development checkout plugin Install, update, clone, remove, or configure desktop plugins + shell Inspect shell health, enter safe mode, or retry the full desktop verify Verify repository and installed-system health doctor Alias for verify uninstall Remove project-managed configuration diff --git a/roles/uninstall/tasks/main.yml b/roles/uninstall/tasks/main.yml index 774b19b4..39defcb6 100644 --- a/roles/uninstall/tasks/main.yml +++ b/roles/uninstall/tasks/main.yml @@ -42,6 +42,9 @@ loop: - cybexos-btrfs-scrub.timer - cybexos-recovery-refresh.service + - cybexos-update-recover.service + - cybexos-major-upgrade-validate.timer + - cybexos-major-upgrade-validate.service - xps-haptic-touchpad.service - xps-ipu7-camera-init.service - xps-ipu7-camera.service @@ -135,6 +138,7 @@ - "{{ primary_home }}/.local/bin/dev-arch-shell" - "{{ primary_home }}/.local/bin/dev-debian-shell" - "{{ primary_home }}/.local/libexec/hermes-menubar-bridge" + - "{{ primary_home }}/.local/libexec/cybex_hermes" - "{{ primary_home }}/.local/libexec/cybexos-migrate-layering" - "{{ primary_home }}/.local/share/nautilus-python/extensions/localsend.py" - "{{ primary_home }}/.config/distrobox/distrobox.conf" @@ -169,6 +173,13 @@ - /usr/local/libexec/cybexos-hyprland-session-start - /usr/local/libexec/cybexos-session-action - /usr/local/libexec/cybexos-system-snapshot + - /usr/local/libexec/cybexos-update-transaction + - /usr/local/libexec/cybexos-update-bootstrap + - /usr/local/libexec/cybexos-update-recover + - /usr/local/libexec/cybexos-update-recover.service + - /usr/local/libexec/cybexos-update-recover-login.conf + - /usr/local/libexec/cybexos-vendor-paths.json + - /usr/local/libexec/cybexos-major-upgrade - /usr/local/sbin/cybexos-system-snapshot - /usr/local/libexec/cybexos-common.sh - /etc/sysctl.d/60-cybexos-hardening.conf @@ -179,6 +190,7 @@ - /etc/brave/policies/managed/cybexos.json - /etc/fonts/conf.d/49-cybexos-defaults.conf - /usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override + - /usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override - /usr/local/libexec/cybexos-github-release-install - /usr/local/libexec/cybexos-source-app-build - /usr/local/libexec/cybexos-android-sdk-update @@ -217,6 +229,14 @@ | selectattr('item', 'equalto', '/usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override') | selectattr('changed') | list | length > 0 +- name: Recompile GSettings schemas without the SMB workgroup override + ansible.builtin.command: glib-compile-schemas /usr/share/glib-2.0/schemas + changed_when: true + when: >- + uninstall_system_files.results + | selectattr('item', 'equalto', '/usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override') + | selectattr('changed') | list | length > 0 + # Recovery points themselves stay: they are data on the user's filesystem and # `cybexos-system-snapshot` can be reinstalled to restore from them. - name: Remove recovery booting from the boot chain @@ -227,6 +247,11 @@ - /etc/grub.d/42_cybexos_recovery - /etc/kernel/install.d/95-cybexos-recovery.install - /etc/systemd/system/cybexos-recovery-refresh.service + - /etc/systemd/system/cybexos-update-recover.service + - /etc/systemd/system/systemd-user-sessions.service.d/60-cybexos-update-recover.conf + - /etc/systemd/system/sddm.service.d/60-cybexos-update-recover.conf + - /etc/systemd/system/cybexos-major-upgrade-validate.service + - /etc/systemd/system/cybexos-major-upgrade-validate.timer - /usr/lib/dracut/modules.d/90cybexos-recovery - /etc/dracut.conf.d/90-cybexos-recovery.conf - /boot/grub2/cybexos-recovery.cfg @@ -238,12 +263,18 @@ cmd: grub2-mkconfig -o /boot/grub2/grub.cfg removes: /boot/grub2/grub.cfg changed_when: true - when: uninstall_recovery_boot.results[0] is changed + when: >- + uninstall_recovery_boot.results + | selectattr('item', 'equalto', '/etc/grub.d/42_cybexos_recovery') + | selectattr('changed') | list | length > 0 - name: Rebuild initramfs images without the recovery overlay module ansible.builtin.command: dracut --regenerate-all --force changed_when: true - when: uninstall_recovery_boot.results[3] is changed or uninstall_recovery_boot.results[4] is changed + when: >- + uninstall_recovery_boot.results + | selectattr('item', 'in', ['/usr/lib/dracut/modules.d/90cybexos-recovery', '/etc/dracut.conf.d/90-cybexos-recovery.conf']) + | selectattr('changed') | list | length > 0 - name: Remove the managed mpv defaults ansible.builtin.blockinfile: diff --git a/roles/xps-2026/defaults/main.yml b/roles/xps-2026/defaults/main.yml index f1c52377..d58cf30d 100644 --- a/roles/xps-2026/defaults/main.yml +++ b/roles/xps-2026/defaults/main.yml @@ -14,6 +14,12 @@ xps_2026_speaker_skus: xps_2026_vesa_backlight_edid_products: - "30e4" +# Panel self-refresh causes black flashes and a sink link CRC error on this +# measured XPS 14 panel. Bytes 8..11 identify LGD / product 0x07c6 (little +# endian product bytes); do not apply this to every LG panel or XPS chassis. +xps_2026_psr_disabled_panels: + - { sku: "0DB9", edid: "30e4c607" } + # Supported values are low, mid, and high. They map to the current Synaptics # HID scale used by the 2026 XPS touchpad (10, 50, and 100 respectively). xps_2026_haptic_intensity: high diff --git a/roles/xps-2026/tasks/display.yml b/roles/xps-2026/tasks/display.yml new file mode 100644 index 00000000..c653b51d --- /dev/null +++ b/roles/xps-2026/tasks/display.yml @@ -0,0 +1,74 @@ +--- +- name: Read the internal-panel manufacturer and product for the PSR quirk + ansible.builtin.command: + argv: + - /usr/bin/bash + - -c + - | + shopt -s nullglob + for edid in /sys/class/drm/card*-eDP-*/edid; do + [[ -r $edid ]] || continue + panel=$(/usr/bin/od -An -tx1 -j8 -N4 "$edid") || continue + printf '%s\n' "${panel//[[:space:]]/}" + exit 0 + done + exit 1 + register: xps_2026_psr_panel + changed_when: false + failed_when: false + check_mode: false + tags: [xps-2026, hardware, display] + +- name: Disable unreliable self-refresh only on the measured XPS panel + when: >- + {'sku': xps_2026_product_sku, + 'edid': xps_2026_psr_panel.stdout | default('') | trim} + in xps_2026_psr_disabled_panels + tags: [xps-2026, hardware, display] + block: + - name: Inspect every kernel entry for the panel self-refresh quirk + ansible.builtin.command: + argv: [grubby, --info=ALL] + register: xps_2026_psr_boot_entries + changed_when: false + check_mode: false + + - name: Disable Xe panel self-refresh in current and future boot entries + ansible.builtin.command: + argv: + - grubby + - --update-kernel=ALL + - --args=xe.enable_psr=0 + when: >- + (xps_2026_psr_boot_entries.stdout_lines + | select('match', '^args=') | list | length == 0) + or + (xps_2026_psr_boot_entries.stdout_lines + | select('match', '^args=') + | reject('search', '(^|[ "])xe[.]enable_psr=0([ "]|$)') + | list | length > 0) + changed_when: true + + - name: Verify every kernel entry has the panel self-refresh quirk + ansible.builtin.command: + argv: [grubby, --info=ALL] + register: xps_2026_psr_updated_entries + changed_when: false + check_mode: false + when: not ansible_check_mode + + - name: Refuse an incomplete panel self-refresh boot-entry update + ansible.builtin.assert: + that: + - >- + xps_2026_psr_updated_entries.stdout_lines + | select('match', '^args=') | list | length > 0 + - >- + xps_2026_psr_updated_entries.stdout_lines + | select('match', '^args=') + | reject('search', '(^|[ "])xe[.]enable_psr=0([ "]|$)') + | list | length == 0 + fail_msg: >- + The affected XPS panel needs xe.enable_psr=0 in every kernel entry. + quiet: true + when: not ansible_check_mode diff --git a/roles/xps-2026/tasks/main.yml b/roles/xps-2026/tasks/main.yml index eb7ace88..d1af677e 100644 --- a/roles/xps-2026/tasks/main.yml +++ b/roles/xps-2026/tasks/main.yml @@ -9,6 +9,10 @@ - xps_2026_is_supported | bool - xps_2026_needs_vesa_backlight | bool +- name: Select reliable refresh for the affected XPS internal panel + ansible.builtin.import_tasks: display.yml + when: xps_2026_is_supported | bool + - name: Install explicit Panther Lake media and firmware support ansible.builtin.import_tasks: packages.yml when: xps_2026_is_supported | bool diff --git a/tests/cybexos-update-run b/tests/cybexos-update-run index 9d394246..0ee133a4 100755 --- a/tests/cybexos-update-run +++ b/tests/cybexos-update-run @@ -159,13 +159,14 @@ apply_mock dnf \ apply_mock ansible-playbook \ 'set -euo pipefail' \ + 'umask >>"$MOCK_ANSIBLE_UMASK_LOG"' \ 'cat "$MOCK_RELEASE_CONFIG" >"$MOCK_ANSIBLE_CONFIG_LOG"' \ 'printf "%s\n" "$*" >"$MOCK_ANSIBLE_ARGS_LOG"' \ 'exit "${MOCK_ANSIBLE_RC:-0}"' # The snapshot helper has its own Btrfs fixture. Keep the durable-update test # on the supported non-Btrfs path so it never inspects or mutates the host FS. -apply_mock findmnt 'printf "ext4\n"' +apply_mock findmnt 'printf "%s\n" "${MOCK_ROOT_FS:-ext4}"' # A pre-rename installation has only this root-owned helper. Its compatible # contract must bridge the first run of the renamed updater. @@ -211,6 +212,7 @@ export MOCK_DNF_LOG="$fixture/dnf.log" export MOCK_DNF_UMASK_LOG="$fixture/dnf-umask.log" export MOCK_ANSIBLE_CONFIG_LOG="$fixture/ansible-config.log" export MOCK_ANSIBLE_ARGS_LOG="$fixture/ansible-args.log" +export MOCK_ANSIBLE_UMASK_LOG="$fixture/ansible-umask.log" export MOCK_LEGACY_SNAPSHOT_LOG="$fixture/legacy-snapshot.log" export MOCK_FIRMWARE_LOG="$fixture/firmware-helper.log" export MOCK_FIRMWARE_UMASK_LOG="$fixture/firmware-umask.log" @@ -593,7 +595,8 @@ recommended=$($backend status --json "$recommended_id") [[ $(stat -c %a "$XDG_STATE_HOME/cybexos/update/logs/$recommended_id/dnf.log") == 600 ]] # Release migration, snapshot, dnf, Flatpak, and firmware all run through # the umask wrapper. -[[ $(grep -Fc '"${privileged_package[@]}"' "$backend") -eq 5 ]] +# Assert effective umasks in the executed mocks; the number of protected +# phases grows as download, health and recovery steps are introduced. # A run without --firmware records that and never starts the helper. [[ $(jq -r .firmware <<<"$recommended") == false ]] [[ $(jq -r .firmwareDone <<<"$recommended") == false ]] @@ -912,6 +915,8 @@ $backend _worker "$release_id" --full --skip-tests --no-packages \ -- -e "@$release_config" [[ $(<"$release_config") == 'schema: committed' ]] [[ $(<"$MOCK_ANSIBLE_CONFIG_LOG") == 'schema: committed' ]] +[[ $(tail -n 1 "$MOCK_ANSIBLE_UMASK_LOG") =~ ^0?022$ ]] +[[ $(stat -c %a "$XDG_STATE_HOME/cybexos/update/logs/$release_id/ansible.log") == 600 ]] [[ $(readlink "$release_data/current") == releases/2.0.0 ]] [[ ! -d $release_data/releases/1.0.0 ]] assert_agent_skill_links @@ -1025,4 +1030,287 @@ assert_unrelated_skills # Only the four --firmware runs above ever started the helper. [[ $(wc -l <"$MOCK_FIRMWARE_LOG") -eq 4 ]] -printf 'Durable updater serializes starts, defers cancellation past package and firmware transactions, inhibits shutdown, owns release and agent-skill activation rollback, preserves boot-scoped reboot advice including staged firmware, and reads exact log windows\n' +# Protected Btrfs runs use the shared durable journal. These helpers model +# its public protocol; tests/update-transaction.py exercises the actual +# checkpoint, restore, retry, and personal-data boundaries on disk. +export MOCK_TRANSACTION_LOG="$fixture/transaction.log" +export MOCK_TRANSACTION_STATE="$fixture/transaction.state" +export MOCK_TRANSACTION_ARGS_LOG="$fixture/transaction-args.log" +export MOCK_BOOTSTRAP_LOG="$fixture/bootstrap-calls.log" +export MOCK_BOOTSTRAP_BUNDLE="$fixture/bootstrap-bundle" +cat >"$mock_libexec/cybexos-system-snapshot" <<'SNAPSHOT' +#!/usr/bin/env bash +printf '%s\n' "${MOCK_SNAPSHOT_OUTPUT-20260930T120000Z-123}" +exit "${MOCK_SNAPSHOT_RC:-0}" +SNAPSHOT +cat >"$mock_libexec/cybexos-update-transaction" <<'TRANSACTION' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$1" >>"$MOCK_TRANSACTION_LOG" +printf '%s %s\n' "$0" "$*" >>"$MOCK_TRANSACTION_ARGS_LOG" +printf 'transaction %s\n' "$1" >>"$MOCK_ORDER_LOG" +case $1 in + status) printf '{"state":"%s"}\n' "$(<"$MOCK_TRANSACTION_STATE")"; exit 0 ;; + begin) state=prepared ;; + applying) state=applying ;; + commit) state=validating ;; + abort) state=aborted ;; + rollback) state=rolled-back ;; + *) exit 64 ;; +esac +printf '%s\n' "$state" >"$MOCK_TRANSACTION_STATE" +[[ $1 != "${MOCK_TRANSACTION_FAILURE:-}" ]] || exit 42 +[[ $1 != commit ]] || printf 'committed\n' >"$MOCK_TRANSACTION_STATE" +TRANSACTION +cat >"$mock_libexec/cybexos-update-bootstrap" <<'BOOTSTRAP' +#!/usr/bin/python3 +# The helper's isolated suite owns filesystem installation. This mock models +# its public first-upgrade protocol, including a distinct prepared bundle. +import json +import os +import pathlib +import sys +action = sys.argv[1] +with open(os.environ['MOCK_BOOTSTRAP_LOG'], 'a') as stream: + stream.write(json.dumps(sys.argv[1:]) + '\n') +with open(os.environ['MOCK_ORDER_LOG'], 'a') as stream: + stream.write('bootstrap ' + action + '\n') +if action == 'ready': + assert sys.argv[2] == '--libexec' + assert pathlib.Path(sys.argv[3]).is_dir() + sys.exit(1 if os.environ.get('MOCK_BOOTSTRAP_REQUIRED') else 0) +elif action == 'prepare': + options = dict(zip(sys.argv[2::2], sys.argv[3::2])) + assert set(options) == {'--source', '--checkpoint', '--id'} + assert pathlib.Path(options['--source']).is_dir() + assert options['--checkpoint'] == '20260930T120000Z-123' + failure = int(os.environ.get('MOCK_BOOTSTRAP_PREPARE_RC', '0')) + if failure: + sys.exit(failure) + print(os.environ.get('MOCK_BOOTSTRAP_OUTPUT', json.dumps({ + 'snapshot': '20260930T120001Z-124', + 'transactionHelper': os.environ['MOCK_BOOTSTRAP_BUNDLE'] + '/cybexos-update-transaction'}))) +elif action == 'finalize': + assert sys.argv[2:] == ['--bundle', os.environ['MOCK_BOOTSTRAP_BUNDLE']] + assert pathlib.Path(sys.argv[0]).parent == pathlib.Path(os.environ['MOCK_BOOTSTRAP_BUNDLE']) + sys.exit(int(os.environ.get('MOCK_BOOTSTRAP_FINALIZE_RC', '0'))) +else: + sys.exit(64) +BOOTSTRAP +chmod 0755 "$mock_libexec/cybexos-system-snapshot" \ + "$mock_libexec/cybexos-update-transaction" "$mock_libexec/cybexos-update-bootstrap" +mkdir "$MOCK_BOOTSTRAP_BUNDLE" +cp "$mock_libexec/cybexos-update-transaction" "$mock_libexec/cybexos-update-bootstrap" \ + "$MOCK_BOOTSTRAP_BUNDLE/" + +protected_run() { + : >"$MOCK_TRANSACTION_LOG" + : >"$MOCK_TRANSACTION_ARGS_LOG" + : >"$MOCK_BOOTSTRAP_LOG" + local started + started=$($backend start --json "$@") + protected_id=$(jq -er .id <<<"$started") + if [[ -n ${MOCK_UNWRITABLE_RESULT:-} ]]; then + mkdir "$XDG_STATE_HOME/cybexos/update/logs/$protected_id/$MOCK_UNWRITABLE_RESULT.rc" + fi + protected_rc=0 + $backend _worker "$protected_id" "$@" || protected_rc=$? + protected_status=$($backend status --json "$protected_id") +} + +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +[[ $protected_rc == 0 ]] +[[ $(jq -r .transactionProtection <<<"$protected_status") == btrfs ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit ]] + +# A pre-feature installation obtains a second, protected checkpoint before +# any package download/application. Every transaction call uses the returned +# bundle, and that bundle is retired only after its commit succeeds. +export MOCK_BOOTSTRAP_REQUIRED=1 +: >"$MOCK_ORDER_LOG" +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +[[ $protected_rc == 0 ]] +[[ $(jq -r .snapshotId <<<"$protected_status") == 20260930T120001Z-124 ]] +[[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare,finalize ]] +[[ $(paste -sd, "$MOCK_ORDER_LOG") == 'bootstrap ready,bootstrap prepare,transaction begin,dnf upgrade,transaction applying,dnf upgrade,transaction commit,bootstrap finalize' ]] +grep -Fxq "$MOCK_BOOTSTRAP_BUNDLE/cybexos-update-transaction begin $protected_id 20260930T120001Z-124 --uid ${CYBEXOS_UPDATE_OWNER_UID:-$UID}" \ + "$MOCK_TRANSACTION_ARGS_LOG" +[[ $(awk '{print $1}' "$MOCK_TRANSACTION_ARGS_LOG" | sort -u) \ + == "$MOCK_BOOTSTRAP_BUNDLE/cybexos-update-transaction" ]] +[[ $(jq -sr '.[1][2]' "$MOCK_BOOTSTRAP_LOG") == "$mock_libexec" ]] +[[ $(jq -sr '.[1][4]' "$MOCK_BOOTSTRAP_LOG") == 20260930T120000Z-123 ]] +[[ $(jq -sr '.[1][6]' "$MOCK_BOOTSTRAP_LOG") == "$protected_id" ]] + +# Bootstrap failure or an unusable response must stop before begin/DNF. +export MOCK_BOOTSTRAP_PREPARE_RC=48 +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_BOOTSTRAP_PREPARE_RC +[[ $protected_rc == 48 ]] +[[ $(jq -r .phase <<<"$protected_status") == snapshot ]] +[[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] +[[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare ]] +same_checkpoint_response=$(jq -cn \ + --arg helper "$MOCK_BOOTSTRAP_BUNDLE/cybexos-update-transaction" \ + '{snapshot: "20260930T120000Z-123", transactionHelper: $helper}') +for bootstrap_output in '' 'invalid json' \ + "$same_checkpoint_response" \ + '{"snapshot":"20260930T120001Z-124","transactionHelper":"/missing-fixture-helper"}'; do + export MOCK_BOOTSTRAP_OUTPUT="$bootstrap_output" + : >"$MOCK_DNF_LOG" + protected_run --no-flatpak + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == snapshot ]] + [[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] + [[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare ]] +done +unset MOCK_BOOTSTRAP_OUTPUT + +# Failed health keeps the bootstrap available to boot-time recovery; it is +# never finalized after selecting the previous generation for restart. +export MOCK_TRANSACTION_FAILURE=commit +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_TRANSACTION_FAILURE +[[ $protected_rc == 1 ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit,status,rollback ]] +[[ $(jq -sr 'map(.[0]) | join(",")' "$MOCK_BOOTSTRAP_LOG") == ready,prepare ]] +unset MOCK_BOOTSTRAP_REQUIRED + +export MOCK_TRANSACTION_FAILURE=commit +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_TRANSACTION_FAILURE +[[ $protected_rc == 1 ]] +[[ $(jq -r .phase <<<"$protected_status") == health ]] +[[ $(jq -r .rollbackState <<<"$protected_status") == restart-required ]] +[[ $(jq -r .rebootRecommendation <<<"$protected_status") == recommended ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit,status,rollback ]] + +export MOCK_ANSIBLE_RC=42 +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_ANSIBLE_RC +[[ $protected_rc == 42 ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +export MOCK_DNF_UPGRADE_RC=47 +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_DNF_UPGRADE_RC +[[ $protected_rc == 47 ]] +[[ $(jq -r .phase <<<"$protected_status") == download ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,status,abort ]] +grep -q -- '--downloadonly' "$MOCK_DNF_LOG" +if grep -v -- '--downloadonly' "$MOCK_DNF_LOG" | grep -q ' upgrade '; then + exit 1 +fi + +# The durable state wins if the applying transition was written but its +# final sync/response failed before the worker could set its local flag. +export MOCK_TRANSACTION_FAILURE=applying +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_TRANSACTION_FAILURE +[[ $protected_rc == 1 ]] +[[ $(jq -r .rollbackState <<<"$protected_status") == restart-required ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +# Empty/malformed snapshot output, unsupported skip responses, and failed +# result writes never permit packages to run without a checkpoint. +for snapshot_output in '' malformed 'skipped: unsafe fixture layout'; do + export MOCK_SNAPSHOT_OUTPUT="$snapshot_output" MOCK_ROOT_FS=btrfs + : >"$MOCK_DNF_LOG" + protected_run --no-flatpak + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == snapshot ]] + [[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] +done +unset MOCK_SNAPSHOT_OUTPUT MOCK_ROOT_FS + +export MOCK_SNAPSHOT_RC=46 +protected_run --no-flatpak +unset MOCK_SNAPSHOT_RC +[[ $protected_rc == 46 ]] +[[ $(jq -r .phase <<<"$protected_status") == snapshot ]] +[[ ! -s $MOCK_TRANSACTION_LOG ]] + +export MOCK_UNWRITABLE_RESULT=snapshot +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_UNWRITABLE_RESULT +[[ $protected_rc == 125 ]] +[[ $(jq -r .phase <<<"$protected_status") == snapshot ]] +[[ ! -s $MOCK_DNF_LOG && ! -s $MOCK_TRANSACTION_LOG ]] + +# A download can succeed while its result cannot be recorded. The worker +# aborts its prepared transaction and never starts the installation phase. +export MOCK_UNWRITABLE_RESULT=download +: >"$MOCK_DNF_LOG" +protected_run --no-flatpak +unset MOCK_UNWRITABLE_RESULT +[[ $protected_rc == 125 ]] +[[ $(jq -r .phase <<<"$protected_status") == download ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,status,abort ]] +if grep -v -- '--downloadonly' "$MOCK_DNF_LOG" | grep -q ' upgrade '; then + exit 1 +fi + +# The RPM reconciler may return zero after deferring work because another +# process holds its lock. Only durable ready status may reach commit. +export MOCK_RECONCILE_LOG="$fixture/reconcile-calls.log" +export MOCK_RECONCILE_STATUS_FILE="$fixture/reconcile-status.json" +cat >"$mock_libexec/cybexos-reconcile" <<'RECONCILE' +#!/usr/bin/env bash +printf '%s\n' "$1" >>"$MOCK_RECONCILE_LOG" +case $1 in + --retry) exit "${MOCK_RECONCILE_RC:-0}" ;; + --status) cat "$MOCK_RECONCILE_STATUS_FILE"; exit "${MOCK_RECONCILE_STATUS_RC:-0}" ;; + *) exit 64 ;; +esac +RECONCILE +chmod 0755 "$mock_libexec/cybexos-reconcile" +printf '{"state":"ready","pending":false,"version":"fixture","desiredVersion":"fixture","accounts":{"fixture":{"state":"ready","version":"fixture","uid":%s}}}\n' \ + "$UID" \ + >"$MOCK_RECONCILE_STATUS_FILE" +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +[[ $protected_rc == 0 ]] +[[ $(paste -sd, "$MOCK_RECONCILE_LOG") == --retry,--status ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,commit ]] + +export MOCK_RECONCILE_STATUS_RC=44 +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_RECONCILE_STATUS_RC +[[ $protected_rc == 125 ]] +[[ $(jq -r .phase <<<"$protected_status") == reconcile ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +if ((UID >= 1000)); then + printf '%s\n' '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{"unrelated":{"state":"ready","version":"same","uid":0}}}' \ + >"$MOCK_RECONCILE_STATUS_FILE" + protected_run --full --skip-tests --no-packages --repo "$defer_repo" + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == reconcile ]] + [[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] +fi + +for reconcile_status in \ + '{"state":"pending","pending":true}' \ + '{"state":"ready","pending":true,"version":"old","desiredVersion":"new"}' \ + '{"state":"ready","pending":false,"version":"old","desiredVersion":"new"}' \ + '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{}}' \ + '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{"fixture":{"state":"error"}}}' \ + '{"state":"ready","pending":false,"version":"same","desiredVersion":"same","accounts":{"fixture":{"state":"ready","version":"old"}}}' \ + '{} invalid'; do + printf '%s\n' "$reconcile_status" >"$MOCK_RECONCILE_STATUS_FILE" + protected_run --full --skip-tests --no-packages --repo "$defer_repo" + [[ $protected_rc == 125 ]] + [[ $(jq -r .phase <<<"$protected_status") == reconcile ]] + [[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] +done + +export MOCK_RECONCILE_RC=43 +protected_run --full --skip-tests --no-packages --repo "$defer_repo" +unset MOCK_RECONCILE_RC +[[ $protected_rc == 43 ]] +[[ $(jq -r .phase <<<"$protected_status") == reconcile ]] +[[ $(paste -sd, "$MOCK_TRANSACTION_LOG") == begin,applying,status,rollback ]] + +printf 'Durable updater serializes starts, defers cancellation past package and firmware transactions, inhibits shutdown, owns release and agent-skill activation rollback, preserves boot-scoped reboot advice, requires valid checkpoints/results and current reconciled policy, and reads exact log windows\n' diff --git a/tests/hermes-bridge.py b/tests/hermes-bridge.py index b129fba1..b64c95f4 100644 --- a/tests/hermes-bridge.py +++ b/tests/hermes-bridge.py @@ -9,6 +9,8 @@ import os from pathlib import Path import queue +import shutil +import subprocess import stat import sys import tempfile @@ -20,6 +22,7 @@ ROOT = Path(__file__).resolve().parents[1] BRIDGE_PATH = ROOT / "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py" +sys.path.insert(0, str(BRIDGE_PATH.parent)) SPEC = importlib.util.spec_from_file_location("hermes_menubar_bridge", BRIDGE_PATH) assert SPEC and SPEC.loader BRIDGE = importlib.util.module_from_spec(SPEC) @@ -583,7 +586,22 @@ def unit_restart_policy() -> None: assert "network-online.target" not in unit +def packaged_entrypoint() -> None: + """The renamed installed executable resolves its sibling package alone.""" + with tempfile.TemporaryDirectory(prefix="cybexos-hermes-package.") as scratch: + directory = Path(scratch) + entry = directory / "cybexos-hermes-menubar-bridge" + shutil.copy2(BRIDGE_PATH, entry) + shutil.copytree(BRIDGE_PATH.parent / "cybex_hermes", directory / "cybex_hermes") + env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") + env.pop("PYTHONPATH", None) + result = subprocess.run([sys.executable, "-B", str(entry), "--help"], + cwd=directory, env=env, capture_output=True, text=True, check=True) + assert "--remote-only" in result.stdout + + if __name__ == "__main__": + packaged_entrypoint() unit_restart_policy() asyncio.run(scenario()) asyncio.run(delivery_scenario()) diff --git a/tests/hermes-remote-runtime.py b/tests/hermes-remote-runtime.py index 7047656b..e76cef5d 100644 --- a/tests/hermes-remote-runtime.py +++ b/tests/hermes-remote-runtime.py @@ -29,6 +29,7 @@ ROOT = Path(__file__).resolve().parents[1] BRIDGE_PATH = ROOT / "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py" +sys.path.insert(0, str(BRIDGE_PATH.parent)) SPEC = importlib.util.spec_from_file_location( "hermes_remote_runtime_fixture", BRIDGE_PATH ) diff --git a/tests/hermes-webui-auth.py b/tests/hermes-webui-auth.py index 9a4f7e69..dc70ab61 100644 --- a/tests/hermes-webui-auth.py +++ b/tests/hermes-webui-auth.py @@ -19,6 +19,7 @@ ROOT = Path(__file__).resolve().parents[1] BRIDGE_PATH = ROOT / "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py" +sys.path.insert(0, str(BRIDGE_PATH.parent)) SPEC = importlib.util.spec_from_file_location("hermes_remote_auth_fixture", BRIDGE_PATH) assert SPEC and SPEC.loader BRIDGE = importlib.util.module_from_spec(SPEC) diff --git a/tests/hyprland-features b/tests/hyprland-features index e3c4afa2..c3da2a03 100755 --- a/tests/hyprland-features +++ b/tests/hyprland-features @@ -267,7 +267,7 @@ install_feature_provider() { install_leaf_modules() { local destination=$1 scenario_root=$2 module - for module in monitors.lua input.lua looknfeel.lua bindings.lua autostart.lua displays.lua; do + for module in monitors.lua input.lua looknfeel.lua bindings.lua autostart.lua displays.lua input_preferences.lua; do install_hypr_module "$module" "$destination/$module" if [[ -f $destination/hyprland.lua ]]; then assert_hypr_tree_loadable "$scenario_root" "after $module" diff --git a/tests/hyprland-input b/tests/hyprland-input new file mode 100755 index 00000000..367401c9 --- /dev/null +++ b/tests/hyprland-input @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Runs the production data loader with a recording compositor; no live reload. +set -euo pipefail +repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +scratch=$(mktemp -d "${TMPDIR:-/tmp}/cybexos-input-lua.XXXXXX") +trap 'rm -rf -- "$scratch"' EXIT +mkdir -p "$scratch/config/cybexos" +cat > "$scratch/test.lua" <<'LUA' +local repo = assert(os.getenv("CYBEXOS_INPUT_TEST_REPO")) +package.path = repo .. "/roles/desktop/files/?.lua;" .. package.path +local calls = {} +hl = {config = function(value) calls[#calls + 1] = value end} +_G.__cybexos_vendor_keyboard_options = "compose:caps,lv3:ralt_switch,future:vendor" +local input = require("input_preferences") +local json = require("displays") +assert(#calls == 0, "missing preferences must not reset defaults") +input.apply(assert(json.decode([[{"v":1,"future":true,"keyboard":{"layouts":[{"layout":"us","variant":"intl"},{"layout":"nl"}],"shortcut":"grp:alt_shift_toggle"},"touchpad":{"tap":false,"naturalScroll":true,"sensitivity":0.25}}]]))) +local value = calls[#calls].input +assert(value.kb_layout == "us,nl" and value.kb_variant == "intl,") +assert(value.kb_options == "compose:caps,lv3:ralt_switch,future:vendor,grp:alt_shift_toggle", "shared defaults survive") +assert(value.touchpad.tap_to_click == false and value.touchpad.natural_scroll == true and value.sensitivity == 0.25) +input.apply(assert(json.decode('{"v":1,"keyboard":{"shortcut":"grp:caps_toggle"}}'))) +assert(not calls[#calls].input.kb_options:find("compose:caps", 1, true), "Caps shortcut cannot also be Compose") +local count = #calls +for _, text in ipairs({ + '{"v":1,"keyboard":false}', '{"v":1,"touchpad":[]}', '{"v":2}', + '{"v":1,"keyboard":{"layouts":[]}}', '{"v":1,"touchpad":{"tap":1}}', + '{"v":1,"touchpad":{"sensitivity":2}}', '{"v":1,"keyboard":{"shortcut":"bad"}}', + '{"v":1,"keyboard":{"layouts":[{"layout":"us\\\";os.execute(1)"}]}}' +}) do + local ok = pcall(input.apply, assert(json.decode(text))) + assert(not ok, "invalid document accepted: " .. text) + assert(#calls == count, "invalid preferences partially applied") +end +local path = assert(os.getenv("XDG_CONFIG_HOME")) .. "/cybexos/input.json" +local file = assert(io.open(path, "w")); file:write('{"v":1,"touchpad":{"tap":false}}'); file:close() +package.loaded.input_preferences = nil +require("input_preferences") +assert(calls[#calls].input.touchpad.tap_to_click == false, "saved preferences load at startup") +assert(_G.__cybexos_input_error == nil) +file = assert(io.open(path, "w")); file:write('{"v":1,"touchpad":'); file:close() +count = #calls +package.loaded.input_preferences = nil +require("input_preferences") +assert(#calls == count and type(_G.__cybexos_input_error) == "string", "corrupt preferences are contained") +print("PASS input preferences: persistence, validation, shared defaults and corruption recovery") +LUA +CYBEXOS_INPUT_TEST_REPO="$repo" XDG_CONFIG_HOME="$scratch/config" luajit "$scratch/test.lua" diff --git a/tests/installation-parity.py b/tests/installation-parity.py index af41c1ac..65ad26e9 100644 --- a/tests/installation-parity.py +++ b/tests/installation-parity.py @@ -7,4 +7,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'image')) if __name__ == '__main__': - unittest.main(module='test_installation_parity') + suite = unittest.defaultTestLoader.loadTestsFromNames([ + 'test_installation_parity', 'test_display_policy', 'test_update_recovery', + ]) + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not result.wasSuccessful()) diff --git a/tests/major-upgrade.py b/tests/major-upgrade.py new file mode 100644 index 00000000..c791526b --- /dev/null +++ b/tests/major-upgrade.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Major upgrade fault/recovery fixtures. No host packages, snapshots or services.""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import pwd +import subprocess +import tarfile +import tempfile +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +LOADER = importlib.machinery.SourceFileLoader('major_upgrade', str(ROOT / 'roles/base/files/cybexos-major-upgrade')) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +M = importlib.util.module_from_spec(SPEC) +LOADER.exec_module(M) + + +class UpgradeTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='cybexos-major-test.') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + for name, path in {'STATE': self.root/'state', 'PAYLOADS': self.root/'payloads', + 'CONFIG': self.root/'config.yml', 'OFFLINE': self.root/'offline', + 'TRIGGER': self.root/'system-update'}.items(): + patcher = patch.object(M, name, path) + patcher.start() + self.addCleanup(patcher.stop) + M.CONFIG.write_text('config_schema_version: 1\nprimary_user: fixture\n') + self.source = self.root/'source' + (self.source/'inventory/group_vars').mkdir(parents=True) + self.manifest = {'product':'cybexos', 'supportedFedora':['45'], + 'architectures':[M.platform.machine()], 'configSchema':1} + (self.source/'release-manifest.json').write_text(json.dumps(self.manifest)) + (self.source/'inventory/group_vars/all.yml').write_text('fedora_release: "45"\n') + for name in ('site.yml', 'ansible.cfg', 'inventory/hosts.yml', 'verify'): + (self.source/name).write_text('fixture\n') + self.account = pwd.getpwuid(os.getuid()) + self.calls = [] + self.transitions = [] + + def baseline(self): + return {'currentFedora':'44', 'targetFedora':'45', 'uid':self.account.pw_uid, + 'home':self.account.pw_dir, 'secureBoot':'legacy boot', 'hardwareChecks':[]} + + def args(self): + return argparse.Namespace(target='45', source=self.source, rpm=None, backup=None, uid=self.account.pw_uid) + + def command(self, command, **_kwargs): + values = list(map(str, command)) + self.calls.append(values) + if values[0] == str(M.SNAPSHOT): + return '20300101T000000-123' + if values[0] == 'dnf5' and 'download' in values: + M.OFFLINE.mkdir(exist_ok=True) + (M.OFFLINE/'transaction.json').write_text('{"target":"45"}') + return '' + + def transaction(self, action, identifier, *extra): + self.transitions.append((action, identifier, extra)) + return {'state':'awaiting-upgrade' if action == 'status' else action, 'id':identifier} + + def prepared(self): + with patch.object(M, 'preflight', return_value=self.baseline()), patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + return M.prepare(self.args()) + + def ready(self): + record = self.prepared() + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + return M.download(record['id']) + + def test_current_manifest_does_not_advertise_the_next_release(self): + with self.assertRaises(M.Failure): + M.compatible(json.loads((ROOT/'release-manifest.json').read_text()), '45', M.platform.machine(), 1) + for changed in ({'architectures':['aarch64']}, {'configSchema':2}, {'supportedFedora':['44']}): + with self.assertRaises(M.Failure): + M.compatible({**self.manifest, **changed}, '45', M.platform.machine(), 1) + + def test_source_inventory_must_agree_and_symlinks_cannot_escape(self): + (self.source/'inventory/group_vars/all.yml').write_text('fedora_release: "44"\n') + with self.assertRaisesRegex(M.Failure, 'disagree'): + M.inspect_target('45', self.source) + (self.source/'inventory/group_vars/all.yml').write_text('fedora_release: "45"\n') + (self.source/'outside').symlink_to(self.root/'config.yml') + with self.assertRaisesRegex(M.Failure, 'escapes'): + M.inspect_target('45', self.source) + + def test_signed_rpm_requires_target_capability_and_architecture(self): + rpm = self.root/'target.rpm' + rpm.write_bytes(b'fixture') + with patch.object(M, 'run', return_value='digests signatures NOT OK NOKEY'): + with self.assertRaises(M.Failure): + M.inspect_target('45', rpm=rpm) + identity = 'cybexos-desktop\n1.fc45\n' + M.platform.machine() + '\n1.0.0' + with patch.object(M, 'run', side_effect=['digests signatures OK', identity, 'cybexos-supported-fedora = 44']): + with self.assertRaisesRegex(M.Failure, 'declare support'): + M.inspect_target('45', rpm=rpm) + with patch.object(M, 'run', side_effect=['digests signatures OK', identity, 'cybexos-supported-fedora = 45']): + self.assertEqual(M.inspect_target('45', rpm=rpm)['kind'], 'rpm') + + def test_prepare_checkpoints_before_a_durable_download_and_preserves_configuration(self): + before = M.CONFIG.read_bytes() + record = self.prepared() + self.assertEqual(record['state'], 'downloading') + self.assertEqual([x[0] for x in self.transitions], ['begin']) + self.assertFalse(any('download' in command and command[0]=='dnf5' for command in self.calls)) + self.assertEqual(self.calls[-1][0], 'systemd-run') + self.assertIn('--property=KillMode=control-group', self.calls[-1]) + self.assertEqual(M.CONFIG.read_bytes(), before) + self.assertEqual(M.tree_digest(Path(record['candidate']['path'])), record['candidate']['digest']) + self.assertEqual((M.PAYLOADS/record['id']).stat().st_mode & 0o777, 0o755) + + def test_download_arms_only_after_success_and_never_reboots(self): + record = self.ready() + self.assertEqual(record['state'], 'ready') + self.assertEqual(self.transitions[-1][0], 'arm-upgrade') + command = next(x for x in self.calls if x[0]=='dnf5') + self.assertIn('--releasever=45', command) + self.assertIn('--setopt=gpgcheck=true', command) + self.assertNotIn('--allowerasing', command) + self.assertFalse(any('reboot' in x for x in self.calls)) + self.assertFalse(record['rebootRequested']) + + def test_cancel_cannot_interrupt_download_or_delete_replaced_offline_data(self): + record = self.prepared() + with self.assertRaises(M.Failure): + M.ensure_ours(record) + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + record = M.download(record['id']) + (M.OFFLINE/'transaction.json').write_text('someone else') + with self.assertRaisesRegex(M.Failure, 'changed'): + M.cancel() + self.assertTrue((M.PAYLOADS/record['id']).exists()) + + def test_cancel_only_cleans_its_completed_download(self): + record = self.ready() + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + result = M.cancel() + self.assertEqual(result['state'], 'cancelled') + self.assertEqual(self.transitions[-1][0], 'abort') + self.assertFalse((M.PAYLOADS/record['id']).exists()) + self.assertIn(['dnf5','offline','clean'], self.calls) + + def test_normal_reboot_preserves_safe_cancellation_of_owned_download(self): + record = self.ready() + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + result = M.cancel() + self.assertEqual(result['state'], 'cancelled') + self.assertIn(['dnf5', 'offline', 'clean'], self.calls) + self.assertFalse((M.PAYLOADS/record['id']).exists()) + + def test_failed_download_can_be_cleaned_after_boot_already_aborted_checkpoint(self): + record = self.ready() + record['state'] = 'download-failed' + M.write(record) + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', return_value={'state':'aborted'}) as transaction: + result = M.cancel() + self.assertEqual(result['state'], 'cancelled') + transaction.assert_called_once_with('status', record['id']) + self.assertIn(['dnf5', 'offline', 'clean'], self.calls) + self.assertFalse((M.PAYLOADS/record['id']).exists()) + + def test_offline_reboot_rebases_boot_boundary_and_still_checks_ownership(self): + record = self.ready() + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='45'): + with self.assertRaisesRegex(M.Failure, 'release changed'): + M.reboot() + with patch.object(M, 'boot_id', return_value='another-normal-boot'), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'run', side_effect=self.command): + M.TRIGGER.symlink_to('/missing/offline/trigger') + with self.assertRaisesRegex(M.Failure, 'already scheduled'): + M.reboot() + M.TRIGGER.unlink() + result = M.reboot() + self.assertEqual(result['bootId'], 'another-normal-boot') + self.assertTrue(result['rebootRequested']) + with patch.object(M, 'transaction', return_value={'state':'awaiting-upgrade'}), patch.object(M, 'converge') as converge: + M.finalize(record['id']) + converge.assert_not_called() + self.assertIn(['dnf5', 'offline', 'reboot'], self.calls) + + def test_rpm_reconciliation_requires_current_ready_accounts(self): + rpm = self.root/'target.rpm' + rpm.write_bytes(b'fixture') + record = {**self.baseline(), 'candidate':{'kind':'rpm', 'path':str(rpm), 'digest':M.digest(rpm)}} + ready = {'state':'ready', 'pending':False, 'version':'new', 'desiredVersion':'new', + 'accounts':{self.account.pw_name:{'state':'ready', 'version':'new', 'uid':self.account.pw_uid}}} + cases = [ready, {**ready, 'pending':True}, {**ready, 'state':'pending'}, + {**ready, 'version':'old'}, {**ready, 'accounts':{}}, + {**ready, 'accounts':{'other':{'state':'ready', 'version':'new', 'uid':-1}}}, + {**ready, 'accounts':{self.account.pw_name:{'state':'pending', 'version':'new', 'uid':self.account.pw_uid}}}, + {**ready, 'accounts':{self.account.pw_name:{'state':'ready', 'version':'old', 'uid':self.account.pw_uid}}}] + for value in cases: + with self.subTest(status=value), patch.object(M, 'inspect_target'), patch.object(M, 'run', side_effect=lambda args, **kw: json.dumps(value) if '--status' in args else ''): + if value == ready: + M.converge(record) + else: + with self.assertRaisesRegex(M.Failure, 'reconciliation'): + M.converge(record) + + def test_preexisting_offline_data_is_never_claimed_by_failed_worker(self): + record = self.prepared() + M.OFFLINE.mkdir() + (M.OFFLINE/'other.json').write_text('other transaction') + with patch.object(M, 'run', side_effect=self.command), patch.object(M, 'transaction', side_effect=self.transaction): + with self.assertRaises(M.Failure): + M.download(record['id']) + self.assertEqual(M.status()['state'], 'failed') + self.assertEqual(self.transitions[-1][0], 'abort') + self.assertTrue((M.OFFLINE/'other.json').exists()) + self.assertFalse(any(x[:3]==['dnf5','offline','clean'] for x in self.calls)) + + def test_offline_failure_rolls_back_before_any_convergence(self): + record = self.ready() + record.update(rebootRequested=True, bootId='previous-boot') + M.write(record) + def transition(action, identifier, *args): + return {'state':'awaiting-upgrade'} if action=='status' else self.transaction(action,identifier,*args) + with patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'fedora_release', return_value='44'), patch.object(M, 'converge') as converge: + result, code = M.finalize(record['id']) + self.assertEqual(code,75) + self.assertEqual(result['state'],'rolled-back') + converge.assert_not_called() + self.assertEqual(self.transitions[-1][0], 'rollback') + + def test_root_validation_waits_for_real_desktop_before_commit(self): + record = self.ready() + record.update(rebootRequested=True, bootId='previous-boot') + M.write(record) + def transition(action, identifier, *args): + return {'state':'awaiting-upgrade'} if action=='status' else self.transaction(action,identifier,*args) + with patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'fedora_release', return_value='45'), patch.object(M.platform, 'release', return_value='7.0.0-1.fc45.x86_64'), patch.object(M, 'converge'): + result, code = M.finalize(record['id']) + self.assertEqual(code,0) + self.assertEqual(result['state'],'awaiting-desktop') + self.assertEqual([x[0] for x in self.transitions[-2:]],['applying','await-desktop']) + self.assertNotIn('commit',[x[0] for x in self.transitions]) + + def test_desktop_validation_commits_or_selects_rollback_after_session_starts(self): + record = self.ready() + record['state'] = 'awaiting-desktop' + M.write(record) + original_exists = Path.exists + def bus_exists(path): + return str(path) == f'/run/user/{self.account.pw_uid}/bus' or original_exists(path) + for healthy in (True, False): + M.write(record) + self.transitions.clear() + def transition(action, identifier, *args): + if action == 'status': + return {'state':'awaiting-desktop'} + if action == 'commit' and not healthy: + raise M.Failure('Desktop failed its health check') + return self.transaction(action,identifier,*args) + with self.subTest(healthy=healthy), patch.object(Path, 'exists', bus_exists), patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'run', side_effect=self.command): + result, code = M.finalize(record['id']) + self.assertEqual(code, 0 if healthy else 75) + self.assertEqual(result['state'], 'committed' if healthy else 'rolled-back') + self.assertEqual(self.transitions[-1][0], 'commit' if healthy else 'rollback') + self.assertTrue((M.PAYLOADS/record['id']).exists()) + if not healthy: + self.assertTrue(result['restartRequired']) + + def test_validation_timer_skips_busy_lock_without_failing_unit(self): + record = self.ready() + record['state'] = 'awaiting-desktop' + M.write(record) + with patch.object(M.os, 'geteuid', return_value=0), patch.object(M.os, 'umask'), patch.object(M.signal, 'signal'), patch.object(M, 'operation_lock', side_effect=M.Busy('busy')): + self.assertEqual(M.main(['finalize-current']), 0) + + def test_post_commit_failures_retry_bookkeeping_without_rollback(self): + for fault in ('status-write', 'payload-delete', 'command-output'): + if M.OFFLINE.exists(): + M.shutil.rmtree(M.OFFLINE) + record = self.ready() + record['state'] = 'awaiting-desktop' + record['candidate']['kind'] = 'rpm' + M.write(record) + durable = {'state':'awaiting-desktop', 'injected':False} + original_write, original_delete, original_exists = M.write, M.shutil.rmtree, Path.exists + def transition(action, identifier, *args): + if action == 'rollback': + self.fail('A durable commit must never be rolled back after bookkeeping failure') + if action == 'commit': + durable['state'] = 'committed' + if fault == 'command-output': + raise M.Failure('Lost command output after commit') + return {'state':durable['state']} + def write(value): + if fault == 'status-write' and value['state'] == 'committed' and not durable['injected']: + durable['injected'] = True + raise OSError('Simulated status sync failure') + return original_write(value) + def delete(path, *args, **kwargs): + if fault == 'payload-delete' and path == M.PAYLOADS/record['id'] and not durable['injected']: + durable['injected'] = True + raise OSError('Simulated payload cleanup failure') + return original_delete(path, *args, **kwargs) + def exists(path): + return str(path) == f'/run/user/{self.account.pw_uid}/bus' or original_exists(path) + with self.subTest(fault=fault), patch.object(M, 'transaction', side_effect=transition), patch.object(M, 'run', side_effect=self.command), patch.object(Path, 'exists', exists), patch.object(M, 'write', side_effect=write), patch.object(M.shutil, 'rmtree', side_effect=delete): + if fault != 'command-output': + with self.assertRaises(OSError): + M.finalize(record['id']) + self.assertEqual(durable['state'], 'committed') + if fault == 'payload-delete': + self.assertTrue(M.status()['cleanupPending']) + result, code = M.finalize(record['id']) + self.assertEqual(code, 0) + self.assertEqual(result['state'], 'committed') + self.assertNotIn('cleanupPending', M.status()) + self.assertFalse((M.PAYLOADS/record['id']).exists()) + + def test_backup_verification_checks_bytes_and_required_archive_contents(self): + archive = self.root/'backup.tar' + scopes = ['/home/fixture','/etc','/var/lib/xps-hardware','/etc/pki/akmods'] + content = self.root/'backup-content' + for scope in scopes: + directory = content/scope.lstrip('/') + directory.mkdir(parents=True, exist_ok=True) + (directory/'kept').write_text('data') + with tarfile.open(archive,'w') as stream: + for child in content.iterdir(): + stream.add(child, arcname=child.name) + manifest = self.root/'backup.json' + receipt = {'v':1,'createdAt':datetime.now(timezone.utc).isoformat(), + 'archives':[{'path':archive.name,'sha256':M.digest(archive),'covers':scopes}]} + manifest.write_text(json.dumps(receipt)) + actual_run=M.run + def command(values, **kwargs): + if values[0]=='findmnt': + return 'root-device' if values[-1]=='/' else 'backup-device' + return actual_run(values,**kwargs) + with patch.object(M,'run',side_effect=command): + checked=M.verify_backup(manifest,SimpleNamespace(pw_dir='/home/fixture')) + self.assertEqual(checked['sha256'],M.digest(manifest)) + archive.write_bytes(b'corrupted') + with self.assertRaisesRegex(M.Failure,'checksum'): + M.verify_backup(manifest,SimpleNamespace(pw_dir='/home/fixture')) + + def test_timeout_stops_descendants_before_returning(self): + pidfile=self.root/'child.pid' + command=['/bin/sh','-c', 'sh -c \'trap "" TERM; exec sleep 60\' >/dev/null 2>&1 & echo $! > "$1"; wait', 'test', str(pidfile)] + with self.assertRaises(subprocess.TimeoutExpired): + M.run(command,timeout=0.1) + child=int(pidfile.read_text()) + for _ in range(30): + try: + fields=Path(f'/proc/{child}/stat').read_text().split() + if fields[2]=='Z': + break + except FileNotFoundError: + break + time.sleep(0.01) + else: + self.fail('A command descendant survived timeout cleanup') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/native-input-region.py b/tests/native-input-region.py new file mode 100755 index 00000000..b188b2f7 --- /dev/null +++ b/tests/native-input-region.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Input persistence and native region authorization with no host mutations.""" +import copy +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / 'roles/desktop/files/quickshell/scripts' +sys.path.insert(0, str(SCRIPTS)) +inputs = importlib.import_module('system_settings_input') +region = importlib.import_module('system_settings_region') +CATALOG = [{'value': 'us', 'label': 'English', 'variants': [{'value': '', 'label': 'Default'}, {'value': 'intl', 'label': 'International'}]}, + {'value': 'nl', 'label': 'Dutch', 'variants': [{'value': '', 'label': 'Default'}]}] + + +class InputPersistence(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='cybexos-input-test-') + self.addCleanup(self.temp.cleanup) + self.service = inputs.InputSettings() + self.service.path = Path(self.temp.name) / 'config/input.json' + self.service.path.parent.mkdir() + self.run = patch.object(inputs, 'run', return_value='ok').start() + self.addCleanup(patch.stopall) + patch.object(inputs, 'catalog', return_value=CATALOG).start() + + def apply(self, section='touchpad', values=None, version=None): + return self.service.dispatch({'action': 'apply', 'section': section, + 'values': {'tap': False} if values is None else values, + 'version': self.service.read()[1] if version is None else version}) + + def test_unknown_values_and_layout_metadata_survive(self): + original = {'v': 1, 'future': {'unknown': [1, 'data']}, + 'keyboard': {'other': True, 'layouts': [{'layout': 'us', 'variant': '', 'future': 7}]}, + 'touchpad': {'future': 'keep', 'naturalScroll': False}} + self.service.path.write_text(json.dumps(original)) + self.apply() + saved = self.service.read()[0] + expected = copy.deepcopy(original) + expected['touchpad']['tap'] = False + self.assertEqual(saved, expected) + self.apply('keyboard', {'layouts': [{'layout': 'us', 'variant': ''}, {'layout': 'nl', 'variant': ''}]}) + self.assertEqual(self.service.read()[0]['keyboard']['layouts'][0]['future'], 7) + self.assertEqual(self.service.path.stat().st_mode & 0o777, 0o600) + + def test_stale_version_does_not_save_or_reload(self): + version = self.service.read()[1] + self.service.path.write_text('{"v":1,"future":true}') + with self.assertRaisesRegex(ValueError, 'changed elsewhere'): + self.apply(version=version) + self.run.assert_not_called() + self.assertTrue(self.service.read()[0]['future']) + + def test_rejected_reload_restores_original_bytes(self): + old = b'{ "v": 1, "future": 2 }\n' + self.service.path.write_bytes(old) + self.run.side_effect = ['error: bad configuration', 'ok'] + with self.assertRaisesRegex(ValueError, 'restored'): + self.apply() + self.assertEqual(self.service.path.read_bytes(), old) + self.assertEqual(self.run.call_count, 2) + + def test_failed_first_save_removes_created_file(self): + self.run.side_effect = [ValueError('unavailable'), 'ok'] + with self.assertRaisesRegex(ValueError, 'restored'): + self.apply() + self.assertFalse(self.service.path.exists()) + self.assertFalse(list(self.service.path.parent.glob('.input-*'))) + + def test_contained_lua_error_also_restores_original_preferences(self): + old = b'{"v":1,"touchpad":{"tap":true}}\n' + self.service.path.write_bytes(old) + self.run.side_effect = ['ok', 'error: input loader failed', 'ok'] + with self.assertRaisesRegex(ValueError, 'restored'): + self.apply() + self.assertEqual(self.service.path.read_bytes(), old) + + def test_symlink_and_invalid_document_are_never_replaced(self): + destination = self.service.path.parent / 'personal.json' + destination.write_text('{"v":1}') + self.service.path.symlink_to(destination) + with self.assertRaisesRegex(ValueError, 'symlink'): + self.apply() + self.assertEqual(destination.read_text(), '{"v":1}') + self.service.path.unlink() + self.service.path.write_text('{"v":1,"v":1}') + with self.assertRaisesRegex(ValueError, 'invalid'): + self.apply() + self.run.assert_not_called() + + def test_invalid_changes_do_not_write(self): + for section, values in [('touchpad', {'tap': 'yes'}), ('touchpad', {'sensitivity': float('nan')}), + ('touchpad', {'sensitivity': 2}), ('touchpad', {'naturalScroll': 1}), + ('keyboard', {'layouts': []}), ('keyboard', {'layouts': [{'layout': 'bad";code()'}]}), + ('keyboard', {'layouts': [{'layout': 'us', 'variant': 'missing'}]}), + ('keyboard', {'shortcut': 'exec:bad'})]: + with self.subTest(values=values), self.assertRaises(ValueError): + self.apply(section, values) + self.assertFalse(self.service.path.exists()) + self.run.assert_not_called() + + def test_switch_uses_fixed_argument_array_without_saving(self): + self.service.dispatch({'action': 'switch', 'layout': '$(false)'}) + self.run.assert_called_once_with(['hyprctl', 'switchxkblayout', 'all', 'next']) + self.assertFalse(self.service.path.exists()) + + def test_snapshot_accepts_current_and_legacy_hyprland_boolean_responses(self): + # Current Boolean response shape captured from the Fedora 44 live + # desktop. The other documented response fields remain unchanged. + for field, tap, natural in [('bool', False, True), ('int', 0, 1)]: + with self.subTest(field=field): + replies = { + 'input:kb_layout': {'str': 'us,nl', 'set': True}, + 'input:kb_variant': {'str': 'intl,', 'set': True}, + 'input:kb_options': {'str': 'compose:caps,grp:alt_shift_toggle', 'set': True}, + 'input:touchpad:tap_to_click': {field: tap, 'set': True}, + 'input:touchpad:natural_scroll': {field: natural, 'set': True}, + 'input:sensitivity': {'float': 0.0, 'set': False}, + 'devices': {'keyboards': [{'name': 'keyboard', 'active_keymap': 'English (US)', 'main': True}]}, + } + self.run.side_effect = lambda command: json.dumps(replies[command[-1]]) + result = self.service.dispatch({'action': 'snapshot'}) + self.assertEqual(result['touchpad'], {'tap': False, 'naturalScroll': True, 'sensitivity': 0.0}) + self.assertEqual(result['keyboard']['layouts'], [ + {'layout': 'us', 'variant': 'intl'}, {'layout': 'nl', 'variant': ''}]) + self.assertEqual(result['keyboard']['shortcut'], 'grp:alt_shift_toggle') + self.assertEqual(result['keyboard']['active'][0]['layout'], 'English (US)') + self.assertFalse(self.service.path.exists(), 'reading input must not create preferences') + + def test_malformed_boolean_options_do_not_become_enabled_switches(self): + for reply in ({'bool': 'false'}, {'int': '0'}, {'int': 2}, {'int': False}, + {'bool': None, 'int': 0}, {}, []): + with self.subTest(reply=reply), self.assertRaisesRegex(ValueError, 'input configuration'): + self.run.return_value = json.dumps(reply) + self.service.option('touchpad:tap_to_click', 'bool') + + def test_catalog_reads_variants_from_xkb_data(self): + path = Path(self.temp.name) / 'evdev.xml' + path.write_text('usEnglish' + 'intlInternational' + '') + # This test intentionally bypasses the action tests' catalog mock. + patch.stopall() + result = inputs.catalog(path) + self.assertEqual(result[0]['variants'][1], {'value': 'intl', 'label': 'International'}) + + def test_real_stdin_protocol_handles_subprocess_failure(self): + binary = Path(self.temp.name) / 'hyprctl' + binary.write_text('#!/bin/sh\nexit 7\n') + binary.chmod(0o755) + result = subprocess.run([sys.executable, '-B', str(SCRIPTS / 'system-settings.py'), 'input'], + input='{"action":"switch"}\n', capture_output=True, text=True, + env={**os.environ, 'PATH': str(binary.parent), 'XDG_CONFIG_HOME': self.temp.name}, timeout=10) + self.assertEqual(result.returncode, 0) + self.assertFalse(json.loads(result.stdout)['success']) + self.assertEqual(result.stderr, '') + + +class RegionTransactions(unittest.TestCase): + def setUp(self): + self.service = object.__new__(region.RegionSettings) + self.service.call = Mock() + self.timezone = 'Europe/Amsterdam' + self.locale = ['LANG=en_US.UTF-8', 'LC_TIME=nl_NL.UTF-8', 'LC_NUMERIC=C'] + self.service.properties = Mock(side_effect=lambda service: {'Timezone': self.timezone} if service == region.TIME else {'Locale': self.locale.copy()}) + patch.object(region, 'choices', side_effect=lambda command: ['Europe/Amsterdam', 'UTC'] if command[0] == 'timedatectl' else ['en_US.UTF-8', 'nl_NL.UTF-8']).start() + self.addCleanup(patch.stopall) + + def test_locale_keeps_explicit_categories_and_requests_authorization(self): + previous = self.locale.copy() + def apply(*args, **kwargs): + self.locale = args[4][0] + self.service.call.side_effect = apply + result = self.service.dispatch({'action': 'locale', 'value': 'nl_NL.UTF-8', 'previous': previous}) + self.assertIn('LC_TIME=nl_NL.UTF-8', self.locale) + self.assertIn('LC_NUMERIC=C', self.locale) + self.assertIn('LANG=nl_NL.UTF-8', self.locale) + self.assertNotIn('LANG=en_US.UTF-8', self.locale) + self.assertEqual(self.service.call.call_args.args[3], '(asb)') + self.assertTrue(self.service.call.call_args.args[4][1]) + self.assertTrue(self.service.call.call_args.kwargs['interactive']) + self.assertIn('Sign out', result['message']) + + def test_timezone_verifies_after_authorized_write(self): + self.service.call.side_effect = lambda *args, **kwargs: setattr(self, 'timezone', args[4][0]) + self.service.dispatch({'action': 'timezone', 'value': 'UTC', 'previous': self.timezone}) + self.assertEqual(self.timezone, 'UTC') + self.assertEqual(self.service.call.call_args.args[3:], ('(sb)', ('UTC', True))) + + def test_denied_write_reports_failure_without_success(self): + self.service.call.side_effect = ValueError('Authorization was cancelled or denied') + with self.assertRaisesRegex(ValueError, 'denied'): + self.service.dispatch({'action': 'timezone', 'value': 'UTC', 'previous': self.timezone}) + self.assertEqual(self.timezone, 'Europe/Amsterdam') + + def test_stale_and_uninstalled_choices_never_mutate(self): + for request in [{'action': 'timezone', 'value': 'UTC', 'previous': 'stale'}, + {'action': 'locale', 'value': 'nl_NL.UTF-8', 'previous': ['LANG=changed']}, + {'action': 'locale', 'value': 'missing', 'previous': self.locale}, + {'action': 'timezone', 'value': '../etc/passwd', 'previous': self.timezone}]: + with self.subTest(request=request), self.assertRaises(ValueError): + self.service.dispatch(request) + self.service.call.assert_not_called() + + def test_noop_service_is_not_reported_as_success(self): + with self.assertRaisesRegex(ValueError, 'not retained'): + self.service.dispatch({'action': 'timezone', 'value': 'UTC', 'previous': self.timezone}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/qml-lifecycle/shell.qml b/tests/qml-lifecycle/shell.qml index a07239b8..cc7bd08f 100644 --- a/tests/qml-lifecycle/shell.qml +++ b/tests/qml-lifecycle/shell.qml @@ -57,6 +57,63 @@ ShellRoot { root.action.destroy(); root.status.destroy(); } + runCommandLifecycle(); + } + + property int commandStage: 0 + property int commandCompletions: 0 + property bool commandsFinished: false + property Common.CommandRequest request: null + + function runCommandLifecycle() { + request = requestComponent.createObject(harness); + check(request !== null, "CommandRequest did not construct"); + if (!request) { finishLifecycle(); return; } + request.available.connect(() => Qt.callLater(root.nextCommand)); + request.completed.connect((code, body, error) => { + commandCompletions++; + if (commandStage === 0) { + check(code === 0 && body === "ready", "successful command lost stdout"); + } else if (commandStage === 1) { + check(code === -1, "missing executable did not settle as launch failure"); + } else if (commandStage === 2) { + check(code === 124 && body === "", "timeout published partial output or success"); + } else if (commandStage === 3) { + check(code === 0 && body === "after-timeout", "request slot did not recover after timeout"); + } else if (commandStage === 4) { + check(code === 0 && body === "payload", "stdin request was not delivered"); + } + commandStage++; + Qt.callLater(root.nextCommand); + }); + nextCommand(); + } + + function nextCommand() { + if (commandsFinished || request.running) + return; + if (commandStage >= 5) { + commandsFinished = true; + check(commandCompletions === 5, "a command completed more than once"); + request.destroy(); + finishLifecycle(); + return; + } + request.timeoutMs = commandStage === 2 ? 80 : 1000; + request.killGraceMs = 80; + request.stdinEnabled = commandStage === 4; + request.inputText = "payload\n"; + request.command = commandStage === 0 ? ["/usr/bin/printf", "ready"] + : commandStage === 1 ? ["/cybexos-test-executable-does-not-exist"] + : commandStage === 2 ? ["/bin/sh", "-c", "trap '' TERM; printf partial; exec sleep 10"] + : commandStage === 3 ? ["/usr/bin/printf", "after-timeout"] + : ["/bin/sh", "-c", "IFS= read -r value; printf '%s' \"$value\""]; + request.running = true; + } + + Component { id: requestComponent; Common.CommandRequest {} } + + function finishLifecycle() { // Warnings are mirrored to stderr by qs even without detailed-log // decoding, which makes the result observable to the shell driver. console.warn(root.failed ? "LIFECYCLE_RESULT fail" : "LIFECYCLE_RESULT pass"); diff --git a/tests/qml/tst_input_draft.qml b/tests/qml/tst_input_draft.qml new file mode 100644 index 00000000..f6b78158 --- /dev/null +++ b/tests/qml/tst_input_draft.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtTest +import "../../roles/desktop/files/quickshell/Settings/InputDraft.js" as Draft + +TestCase { + name: "NativeInputDraft" + function test_edit_is_isolated_and_only_changed_fields_are_sent() { + const original = {layouts: [{layout: "us", variant: "intl"}], shortcut: "custom:future"}; + const current = Draft.clone(original); + current.layouts = Draft.changeLayout(current.layouts, 0, "nl", ""); + compare(original.layouts[0].layout, "us"); + const patch = Draft.patch(current, original, ["layouts", "shortcut"]); + verify(!("shortcut" in patch)); + compare(patch.layouts[0].layout, "nl"); + compare(patch.layouts[0].variant, ""); + patch.layouts[0].layout = "de"; + compare(current.layouts[0].layout, "nl"); + } + function test_reordering_and_boundaries() { + const layouts = [{layout: "us"}, {layout: "nl"}]; + compare(Draft.move(layouts, 1, -1)[0].layout, "nl"); + compare(layouts[0].layout, "us"); + compare(Draft.move(layouts, 0, -1)[0].layout, "us"); + compare(Draft.move(layouts, 1, 1)[1].layout, "nl"); + } + function test_filter_preserves_selected_option_and_matches_codes() { + const choices = [{value: "us", label: "English"}, {value: "nl", label: "Dutch"}, {value: "de", label: "German"}]; + const result = Draft.filtered(choices, " NL ", "us"); + compare(result.length, 2); + compare(result[0].value, "us"); + compare(result[1].value, "nl"); + compare(Draft.filtered(choices, "german", "")[0].value, "de"); + compare(Draft.filtered(choices, "german", "custom")[0].value, "custom"); + compare(choices.length, 3); + } + function test_touchpad_changes_preserve_false_and_zero() { + const original = {tap: true, naturalScroll: true, sensitivity: 0.5}; + const result = Draft.patch({tap: false, naturalScroll: true, sensitivity: 0}, original, Object.keys(original)); + compare(result.tap, false); + compare(result.sensitivity, 0); + verify(!("naturalScroll" in result)); + } +} diff --git a/tests/qml/tst_settings_document.qml b/tests/qml/tst_settings_document.qml new file mode 100644 index 00000000..64ca2727 --- /dev/null +++ b/tests/qml/tst_settings_document.qml @@ -0,0 +1,149 @@ +import QtQuick +import QtTest +import "../../roles/desktop/files/quickshell/Common/Persistence" as Common +import "../../roles/desktop/files/quickshell/Common/SettingsHelpers.js" as Helpers + +Item { + Component { id: factory; Common.SettingsDocument {} } + Timer { + id: asynchronousCommit + property var callback: null + interval: 5 + onTriggered: if (callback) callback() + } + + TestCase { + name: "SettingsDocumentLifecycle" + property var document: null + + function init() { + document = factory.createObject(parent, { values: Helpers.defaults() }); + verify(document !== null); + } + + function cleanup() { + asynchronousCommit.stop(); + asynchronousCommit.callback = null; + document.destroy(); + } + + function test_default_choice_is_explicit_and_reset_releases_it() { + compare(JSON.parse(document.text()), { v: Helpers.VERSION }); + document.explicitKeys = ["barHeight"]; + compare(JSON.parse(document.text()).barHeight, Helpers.defaults().barHeight); + document.explicitKeys = []; + verify(!("barHeight" in JSON.parse(document.text()))); + } + + function test_unknown_fields_survive_a_real_qml_save_cycle() { + document.source = { v: 26, future: { opaque: [1, "keep"] }, + modOpts: { weather: { place: "Home", futureOption: true } } }; + document.explicitKeys = ["modOpts"]; + document.values = Helpers.merge(document.source); + verify(document.begin()); + const saved = document.submitted; + compare(JSON.parse(saved).future.opaque, [1, "keep"]); + verify(JSON.parse(saved).modOpts.weather.futureOption); + const complete = document.complete(saved); + verify(!complete.pending); + verify(!document.busy); + compare(document.baseline, saved); + } + + function test_edit_during_write_rebases_on_external_changes() { + document.source = { v: Helpers.VERSION, barHeight: 40 }; + document.explicitKeys = ["barHeight"]; + document.values = Helpers.merge(document.source); + document.baseline = JSON.stringify(document.source); + verify(document.begin()); + verify(!document.begin(), "a second writer must not overlap"); + const next = Helpers.clone(document.values); + next.unit = "f"; + document.values = next; + document.explicitKeys = ["barHeight", "unit"]; + // The atomic writer merged an independent edit from another process. + const saved = JSON.stringify({ v: Helpers.VERSION, barHeight: 40, + themeMode: "light", future: "external" }); + const result = document.complete(saved); + verify(result.pending); + compare(result.values.unit, "f"); + compare(result.values.themeMode, "light"); + document.values = result.values; + const second = JSON.parse(document.text()); + compare(second.unit, "f"); + compare(second.future, "external"); + verify(document.begin()); + verify(!document.complete(document.submitted).pending); + } + + function test_failed_write_keeps_pending_values_for_retry() { + document.explicitKeys = ["themeMode"]; + const next = Helpers.clone(document.values); + next.themeMode = "light"; + document.values = next; + verify(document.begin()); + const candidate = document.submitted; + document.abandon(); + verify(!document.busy); + verify(document.begin()); + compare(document.submitted, candidate); + } + + function test_reset_retains_unknown_nested_fields() { + document.source = { v: Helpers.VERSION, + modOpts: { weather: { place: "Chosen", futureOption: "keep" } }, + futureRoot: [1, 2] }; + document.values = Helpers.merge(document.source); + document.explicitKeys = []; + const reset = JSON.parse(document.text()); + compare(reset.modOpts.weather, { futureOption: "keep" }); + compare(reset.futureRoot, [1, 2]); + compare(Helpers.overrideKeys(reset), []); + } + + function test_event_loop_edit_survives_delayed_completion_and_reload() { + document.source = { v: Helpers.VERSION, unit: "c" }; + document.explicitKeys = ["unit"]; + document.values = Helpers.merge(document.source); + verify(document.begin()); + const first = document.submitted; + let completed = false; + asynchronousCommit.callback = function() { + const result = document.complete(first); + verify(result.pending); + compare(result.values.unit, "f"); + document.values = result.values; + completed = true; + }; + asynchronousCommit.start(); + Qt.callLater(function() { + const edited = Helpers.clone(document.values); + edited.unit = "f"; + document.values = edited; + }); + tryVerify(() => completed); + verify(document.begin()); + const second = document.complete(document.submitted); + verify(!second.pending); + compare(Helpers.merge(JSON.parse(document.baseline)).unit, "f"); + } + + function test_concurrent_same_key_is_rejected() { + let failed = false; + try { + Helpers.rebaseDocuments({ unit: "c" }, { unit: "f" }, { unit: "other" }); + } catch (_) { failed = true; } + verify(failed); + } + + function test_legacy_default_equality_never_resets_a_choice() { + const chosen = Helpers.merge({ v: 3, font: "oppo", barHeight: 30, + barRadius: 9, gap: 8, accent: "#9ecbeb" }); + compare(chosen.font, "oppo"); + compare(chosen.barHeight, 30); + compare(chosen.gap, 8); + compare(chosen.accent, "#9ecbeb"); + compare(Helpers.merge({ v: 6, font: "urbanist" }).font, "urbanist"); + } + } +} diff --git a/tests/quickshell/deployment-convergence.test.cjs b/tests/quickshell/deployment-convergence.test.cjs index f6f7b97d..e49ee65a 100644 --- a/tests/quickshell/deployment-convergence.test.cjs +++ b/tests/quickshell/deployment-convergence.test.cjs @@ -80,7 +80,7 @@ test("Podman inventory controls packages, helpers, keybindings, and desktop entr assert.match(desktopTasks.slice(featureInstallAt, luaConsumersAt), /- features\.lua\s+- monitors\.lua\s+- input\.lua\s+- looknfeel\.lua/, "all imported leaf modules must precede the activating entrypoint"); - assert.match(hyprland, /\{ "features", "monitors", "input", "bindings"/, + assert.match(hyprland, /\{ "features", "monitors", "input", "input_preferences", "bindings"/, "a config reload must evict the rendered feature module before reloading bindings"); assert.match(bindings, /local features = require\("features"\)/); const optionalBinds = bindings.slice(bindings.indexOf("if features.podman then"), diff --git a/tests/quickshell/display-settings.test.cjs b/tests/quickshell/display-settings.test.cjs index b02b3285..664236be 100644 --- a/tests/quickshell/display-settings.test.cjs +++ b/tests/quickshell/display-settings.test.cjs @@ -40,7 +40,8 @@ test("Hyprland loads saved displays after vendor monitors and before user.lua, c assert.ok(install.indexOf("- autostart.lua") < install.indexOf("- displays.lua") && install.indexOf("- displays.lua") < install.indexOf("- hyprland.lua"), "the module lands before the entrypoint that requires it"); - assert.match(tasks, /'displays\.lua', 'hyprland\.lua'/, "stale-entry pruning keeps the module"); + const prune = tasks.split('\n').find(line => line.includes('item.path | basename not in')); + assert.ok(prune && prune.includes("'displays.lua'"), "stale-entry pruning keeps the module"); assert.match(fs.readFileSync(path.join(repo, "image/package"), "utf8"), /"autostart\.lua", "displays\.lua"/); }); diff --git a/tests/quickshell/fileview-writes.test.cjs b/tests/quickshell/fileview-writes.test.cjs index f96e3636..d743f91f 100644 --- a/tests/quickshell/fileview-writes.test.cjs +++ b/tests/quickshell/fileview-writes.test.cjs @@ -1,4 +1,4 @@ -// Settings and Notes persist through Quickshell's FileView, whose write path +// Notes persist through Quickshell's FileView, whose write path // has two properties that are easy to wedge on: setText() compares against // the last bytes the view read *or tried to write* and silently skips a // match, and a failed write still leaves its bytes in that cache. A save @@ -13,7 +13,6 @@ const path = require("node:path"); const vm = require("node:vm"); const { shellDir, load } = require("./shell.cjs"); -const SettingsHelpers = load("SettingsHelpers.js"); const NotesHelpers = load("NotesHelpers.js"); const FileViewError = { Success: 0, Unknown: 1, FileNotFound: 2, toString: String }; @@ -123,184 +122,10 @@ function timer(context, trigger) { }; } -function settingsHarness(disk, blockWrites) { - const source = read("Common/Settings.qml"); - const store = fileView(disk, blockWrites); - const context = { - SettingsHelpers, FileViewError, Quickshell: { env: () => "" }, - console: { warn() {} }, - filePath: "/home/test/.config/cybexos/shell.json", - loaded: false, ready: false, firstRun: false, migrationPending: false, - loadError: false, loadErrorText: "", newerSchema: false, recheckPending: false, - initialLoadHandled: false, lastPersistedText: "", storeText: "", - savePending: false, saveError: false, announcement: "", revision: 0, - corruptBackupPending: false, writeInFlight: false, writeSnapshot: "", - reloadAfterWrite: false, lastSavedAt: 0, applied: 0, - store, - clearUndo() {}, backUpCorruptFile() {}, applyScrollFactor() {}, - applyGlassEffect() { context.applied++; } - }; - context.root = context; - context.defaults = SettingsHelpers.defaults(); - Object.assign(context, SettingsHelpers.defaults()); - context.saveTimer = timer(context, c => c.saveNow()); - context.reloadTimer = timer(context, c => c.reloadStore ? c.reloadStore() : c.store.reload()); - vm.createContext(context); - for (const name of ["snapshot", "seedWeatherFromEnv", "protectNewerFile", "assignChanged", - "applyLoaded", "handleLoadFailure", "sameContent", "handleSaveSucceeded", - "handleSaveFailure", "saveNow", "scheduleSave", "retrySave", "set", "reloadStore", - "releaseWriteGuard"] - .filter(name => hasFunction(source, name))) - vm.runInContext(functionSource(source, name), context); - store.on = { - loaded: text => context.applyLoaded(text), - loadFailed: error => context.handleLoadFailure(error), - saved: () => context.handleSaveSucceeded(), - saveFailed: error => context.handleSaveFailure(error) - }; - if (disk === null) - context.handleLoadFailure(FileViewError.FileNotFound); - else - context.applyLoaded(disk); - // A property write runs its onChanged handler, which schedules a save. - context.change = (key, value) => { - context.set(key, value); - context.scheduleSave(); - }; - // Lets the debounced save run and the event loop deliver what it owes. - context.flush = () => { - context.saveTimer.fire(); - store.settle(); - }; - return context; -} - -function settingsText(changes) { - return SettingsHelpers.serialize(Object.assign(SettingsHelpers.defaults(), changes || {})); -} - -for (const blockWrites of [true, false]) { - const mode = blockWrites ? "blocking" : "async"; - - test(`settings: Retry after a failed save writes the same content (${mode})`, () => { - const settings = settingsHarness(settingsText({ barHeight: 40 }), blockWrites); - const store = settings.store; - - store.failWrites = 1; - settings.change("barHeight", 44); - settings.flush(); - assert.equal(settings.saveError, true); - assert.equal(settings.writeInFlight, false); - assert.equal(JSON.parse(store.disk).barHeight, 40, "the failed write left the file alone"); - - // Nothing changed since: FileView still holds the failed attempt and - // would skip the identical bytes without a signal. - settings.retrySave(); - store.settle(); - assert.equal(store.skipped, 0, "Retry must never hand FileView the bytes it would skip"); - assert.equal(settings.writeInFlight, false, "the write guard must not outlive the save"); - assert.equal(settings.saveError, false); - assert.equal(settings.savePending, false, - "a newline-only difference is not a change made while saving"); - assert.equal(JSON.parse(store.disk).barHeight, 44); - - // Its own echo, extra newline and all, is not an external edit. - const applied = settings.applied; - store.reload(); - store.settle(); - assert.equal(settings.applied, applied); - - // And the session keeps saving afterwards. - settings.change("barHeight", 48); - settings.flush(); - assert.equal(settings.writeInFlight, false); - assert.equal(settings.savePending, false); - assert.equal(store.disk, settingsText({ barHeight: 48 })); - }); - - test(`settings: repeated failures alternate until a write lands (${mode})`, () => { - const settings = settingsHarness(settingsText(), blockWrites); - const store = settings.store; - - store.failWrites = 3; - settings.change("gap", 6); - settings.flush(); - for (let attempt = 0; attempt < 2; attempt++) { - settings.retrySave(); - store.settle(); - assert.equal(settings.saveError, true); - assert.equal(settings.writeInFlight, false); - } - settings.retrySave(); - store.settle(); - assert.equal(store.skipped, 0); - assert.equal(settings.saveError, false); - assert.equal(JSON.parse(store.disk).gap, 6); - }); - - test(`settings: a failed change undone before Retry settles without writing (${mode})`, () => { - const initial = settingsText({ barHeight: 40 }); - const settings = settingsHarness(initial, blockWrites); - const store = settings.store; - - store.failWrites = 1; - settings.change("barHeight", 44); - settings.flush(); - assert.equal(settings.saveError, true); - settings.change("barHeight", 40); - settings.flush(); - assert.equal(settings.saveError, false, "the file already holds these settings"); - assert.equal(settings.writeInFlight, false); - assert.equal(settings.savePending, false); - assert.equal(store.writes, 0); - assert.equal(store.disk, initial); - }); - - test(`settings: first run failure and retry reach the missing file (${mode})`, () => { - const settings = settingsHarness(null, blockWrites); - const store = settings.store; - assert.equal(settings.firstRun, true); - - store.failWrites = 1; - settings.change("barHeight", 44); - settings.flush(); - assert.equal(settings.saveError, true); - assert.equal(store.disk, null); - settings.retrySave(); - store.settle(); - assert.equal(settings.saveError, false); - assert.equal(settings.writeInFlight, false); - assert.equal(JSON.parse(store.disk).barHeight, 44); - }); -} - -test("settings: a reload that comes due under an async write runs after it", () => { - const settings = settingsHarness(settingsText({ barHeight: 40 }), false); - const store = settings.store; - - settings.change("barHeight", 44); - settings.saveTimer.fire(); - assert.equal(settings.writeInFlight, true); - // The watcher reports a change while our write is still in flight. - settings.reloadTimer.restart(); - settings.reloadTimer.fire(); - // A change made meanwhile waits for the write instead of joining it. - settings.change("gap", 6); - settings.saveTimer.fire(); - store.settle(); - assert.equal(store.swallowedReloads, 0, "FileView drops a reload issued under its write"); - assert.equal(settings.reloadTimer.running, true, "the deferred reload is re-armed"); - assert.equal(settings.saveTimer.running, true, "the change made while saving is queued"); - - // Someone edits the file after our write landed. - store.disk = settingsText({ barHeight: 44, gap: 6, clock24: false }); - settings.reloadTimer.fire(); - store.settle(); - assert.equal(settings.clock24, false, "the external edit must still be read"); - settings.flush(); - assert.equal(settings.writeInFlight, false); - assert.equal(settings.savePending, false); -}); +// Shell settings now use the atomic settings-store transaction helper rather +// than FileView.setText. Their production Qt queue is exercised directly by +// tests/qml/tst_settings_document.qml; tests/settings-ownership.py verifies +// concurrent filesystem edits, migration backup, failure and future schemas. function notesHarness(disk, blockWrites) { const source = read("Common/Notes.qml"); @@ -494,7 +319,7 @@ test("the persistence comments describe what FileView actually reports", () => { const settings = read("Common/Settings.qml"); assert.doesNotMatch(settings, /a failed atomic rename is still a failed save/, "Quickshell logs a failed atomic commit and still emits saved"); - for (const file of ["Common/Settings.qml", "Common/Notes.qml"]) { + for (const file of ["Common/Notes.qml"]) { const source = read(file); assert.match(functionSource(source, "handleSaveFailure"), /storeText = writeSnapshot;/, `${file} must remember that FileView kept the failed attempt`); diff --git a/tests/quickshell/github-inbox-structure.test.cjs b/tests/quickshell/github-inbox-structure.test.cjs index 7ae54b13..01c87baf 100644 --- a/tests/quickshell/github-inbox-structure.test.cjs +++ b/tests/quickshell/github-inbox-structure.test.cjs @@ -96,34 +96,21 @@ test("Inbox reads use conditional HTTP polling and preserve partial caches", () assert.match(active, /rateLimited\(\)/); }); -test("a stalled gh read is bounded by a watchdog that releases the queue", () => { +test("gh requests use the bounded transport and keep interactive deadlines", () => { const source = read("Common/GitHub.qml"); - const pump = source.match(/function pump\(\)[\s\S]*?\n \}/)?.[0] ?? ""; - assert.match(pump, - /ghWatchdog\.interval = Helpers\.ghTimeoutMs\(job\);\s*ghWatchdog\.restart\(\);\s*ghProc\.running = true;/, - "the watchdog is armed before launch so a synchronous failed start disarms it"); - const fired = source.match(/function ghWatchdogFired\(\)[\s\S]*?\n \}/)?.[0] ?? ""; - assert.match(fired, /ghProc\.timedOut = true[\s\S]*ghProc\.running = false/, - "first firing terminates through the normal falling edge"); - assert.match(fired, /ghProc\.signal\(9\)[\s\S]*ghProc\.abandoned = true;\s*settle\(Helpers\.GH_TIMEOUT_EXIT/, - "a process that ignores SIGTERM is killed and its job settled directly"); - const proc = source.match(/Process \{\s*id: ghProc[\s\S]*?\n \}/)?.[0] ?? ""; - assert.match(proc, /ghWatchdog\.stop\(\)/); - assert.match(proc, /if \(abandoned\) \{[\s\S]*root\.pump\(\);\s*return;/, - "an abandoned job must not settle twice"); - assert.match(proc, /if \(timedOut\)\s*root\.settle\(Helpers\.GH_TIMEOUT_EXIT, "", timeoutText\)/); - assert.match(proc, /ProcHelpers\.NOT_STARTED/, - "a never-started gh still settles through the falling edge"); - assert.match(source, /Timer \{\s*id: ghWatchdog[\s\S]*?onTriggered: root\.ghWatchdogFired\(\)/); + assert.match(source, /ghProc\.timeoutMs = Helpers\.ghTimeoutMs\(job\)/); + assert.match(source, /ghProc\.timeoutMessage = Helpers\.ghTimeoutMessage\(job\)/); + assert.match(source, /CommandRequest \{\s*id: ghProc/); + assert.match(source, /onCompleted: \(code, body, error\) => root\.settle\(code, body, error\)/); + assert.match(source, /onAvailable: root\.pump\(\)/); const helpers = load("GitHubHelpers.js"); assert.equal(helpers.globalInboxFailure(helpers.GH_TIMEOUT_EXIT, - helpers.ghTimeoutMessage({ interactive: false })), true, - "a timed-out Inbox read pauses the sweep with backoff"); + helpers.ghTimeoutMessage({ interactive: false })), true); }); test("interactive reads outrank polling and stale Inbox scopes are rejected", () => { const source = read("Common/GitHub.qml"); - assert.match(source, /firstBackground = queue\.findIndex\(queued => !queued\.interactive\)/); + assert.match(source, /Queue\.enqueue\(queue, active, job\)/); assert.match(source, /kind: "commits"[\s\S]{0,180}?interactive: true/); assert.match(source, /kind: "stats"[\s\S]{0,100}?interactive: true/); assert.match(source, /job\.generation !== scopeGeneration \|\| inboxSweep === null/); diff --git a/tests/quickshell/github-queue.test.cjs b/tests/quickshell/github-queue.test.cjs new file mode 100644 index 00000000..dcac636b --- /dev/null +++ b/tests/quickshell/github-queue.test.cjs @@ -0,0 +1,28 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { load } = require("./shell.cjs"); +const Q = load("GitHubQueue.js"); + +test("interactive gh reads preserve FIFO priority and promote richer queued jobs", () => { + let queue = []; + const add = job => { const result = Q.enqueue(queue, null, job); queue = result.queue; return result.added; }; + add({ kind: "repos" }); + add({ kind: "commits", slug: "a/b", toast: true, since: "yesterday" }); + add({ kind: "stats", sha: "first", interactive: true }); + assert.equal(add({ kind: "commits", slug: "a/b", interactive: true }), false); + assert.deepEqual(queue.map(Q.jobKey), ["stats:first", "commits:a/b", "repos"]); + assert.equal(queue[1].toast, true); + assert.equal(queue[1].since, "yesterday"); + assert.equal(queue[1].interactive, true); +}); + +test("deduplication separates stale generations and never duplicates an active request", () => { + const active = { kind: "events", slug: "a/b", generation: 1 }; + const original = []; + assert.strictEqual(Q.enqueue(original, active, { ...active, interactive: true }).queue, original); + const result = Q.enqueue(original, active, { ...active, generation: 2 }); + assert.equal(result.added, true); + assert.equal(result.queue.length, 1); + assert.equal(result.queue[0].generation, 2); + assert.equal(original.length, 0, "queue policy must not mutate prior published state"); +}); diff --git a/tests/quickshell/hermes-ui.test.cjs b/tests/quickshell/hermes-ui.test.cjs index 93db402c..549082b8 100644 --- a/tests/quickshell/hermes-ui.test.cjs +++ b/tests/quickshell/hermes-ui.test.cjs @@ -533,7 +533,9 @@ test("Hermes exposes capability-gated attachments, branches, editing, and regene const inbox = read("Popovers/HermesInboxPage.qml"); const helpers = read("Common/HermesHelpers.js"); const bridge = readRepo( - "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py"); + "roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py") + + readRepo("roles/desktop/files/hermes-menubar-bridge/cybex_hermes/auth.py") + + readRepo("roles/desktop/files/hermes-menubar-bridge/cybex_hermes/protocol.py"); assert.match(composer, /Hermes\.capabilities\.attachments === true/); assert.match(facade, /"zenity", "--file-selection", "--multiple"/); diff --git a/tests/quickshell/notes-widget.test.cjs b/tests/quickshell/notes-widget.test.cjs index 52f14b36..9c88cd9f 100644 --- a/tests/quickshell/notes-widget.test.cjs +++ b/tests/quickshell/notes-widget.test.cjs @@ -13,7 +13,7 @@ function read(relative) { } test("defaults enable Notes immediately after Weather with opt-in title settings", () => { - assert.equal(Settings.VERSION, 26); + assert.equal(Settings.VERSION, 27); const center = Settings.defaultMods().center; const weather = center.findIndex(entry => entry.id === "weather"); assert.equal(center[weather + 1].id, "notes"); diff --git a/tests/quickshell/settings-helpers.test.cjs b/tests/quickshell/settings-helpers.test.cjs index 9f577ed4..71173d8b 100644 --- a/tests/quickshell/settings-helpers.test.cjs +++ b/tests/quickshell/settings-helpers.test.cjs @@ -6,7 +6,7 @@ const H = load("SettingsHelpers.js"); test("defaults carry the design values", () => { const d = H.defaults(); - assert.equal(H.VERSION, 26); + assert.equal(H.VERSION, 27); assert.deepEqual(d.drawerTabs.map(t => t.id), ["overview", "sound", "network", "bluetooth", "power", "notifications"]); assert.ok(d.drawerTabs.every(t => t.on === true)); @@ -514,7 +514,7 @@ test("merge falls back on invalid enums, colors and names", () => { test("schema-5 bar modes migrate without losing customized geometry", () => { assert.equal(H.merge({ v: 5, floating: true, barHeight: 46, barRadius: 23, gap: 10 - }).barStyle, "hug", "pristine floating geometry adopts the new default"); + }).barStyle, "floating", "an explicit old floating mode remains floating"); assert.equal(H.merge({ v: 5, floating: true, barHeight: 44, barRadius: 23, gap: 10 }).barStyle, "floating", "a customized height remains floating"); @@ -530,7 +530,7 @@ test("schema-5 colors select wallpaper or fixed mode and remain stored", () => { const pristine = H.merge({ v: 5, accent: "#5e9bff", barColorMode: "default" }); - assert.equal(pristine.paletteMode, "wallpaper"); + assert.equal(pristine.paletteMode, "fixed"); const oldWallpaperAccent = H.merge({ v: 5, accentWall: true, accent: "#a992e0", barColorMode: "black" @@ -578,9 +578,9 @@ test("missing font preferences use the current default across first-run and lega "an explicitly saved font remains selectable"); }); -test("schema-7 adopts Google Sans only from the previous default", () => { - assert.equal(H.merge({ v: 6, font: "urbanist" }).font, "google", - "the old untouched default follows the softer typography pass"); +test("legacy fonts remain explicit even when equal to an old default", () => { + assert.equal(H.merge({ v: 6, font: "urbanist" }).font, "urbanist", + "a saved former default is still the user choice"); assert.equal(H.merge({ v: 6, font: "plex" }).font, "plex", "an explicit previous-schema choice survives"); assert.equal(H.merge({ v: H.VERSION, font: "urbanist" }).font, "urbanist", @@ -619,7 +619,7 @@ test("normalizeMods falls back to the default flag for a non-boolean", () => { assert.equal(next.left[0].on, true); }); -test("a schema-3 file adopts the redesign only where it was left untouched", () => { +test("a schema-3 file retains every saved choice across visual redesigns", () => { // The glass menubar changed the bar's proportions, and a settings file // written by the previous schema carries the old ones for every key. A // value the user never moved takes the new default; one they did is theirs. @@ -628,14 +628,14 @@ test("a schema-3 file adopts the redesign only where it was left untouched", () font: "oppo", osd: "top", modOpts: { ws: { style: "numbers" }, media: { maxWidth: 220 } } }); - assert.equal(untouched.barHeight, 36); - assert.equal(untouched.barRadius, 11); + assert.equal(untouched.barHeight, 30); + assert.equal(untouched.barRadius, 9); assert.equal(untouched.gap, 8); - assert.equal(untouched.accent, "#d3d283"); - assert.equal(untouched.font, "mono"); - assert.equal(untouched.osd, "bottom"); + assert.equal(untouched.accent, "#9ecbeb"); + assert.equal(untouched.font, "oppo"); + assert.equal(untouched.osd, "top"); assert.equal(untouched.modOpts.ws.style, "numbers"); - assert.equal(untouched.modOpts.media.maxWidth, 180); + assert.equal(untouched.modOpts.media.maxWidth, 220); const chosen = H.merge({ v: 3, barHeight: 36, accent: "#a992e0", font: "mono", @@ -684,7 +684,7 @@ test("schema-4 appearance choices survive later schema upgrades", () => { previous.barCustomLightness), H.BAR_COLOR_PRESETS.default.light); }); -test("schema-9 adopts the classic bar only from untouched design values", () => { +test("schema-9 retains explicit former defaults across the classic redesign", () => { const untouched = H.merge({ v: 9, glassEnabled: true, @@ -701,18 +701,18 @@ test("schema-9 adopts the classic bar only from untouched design values", () => clock: { dateFormat: "ddd d MMM" } } }); - assert.equal(untouched.glassEnabled, false); - assert.equal(untouched.barHeight, 36); - assert.equal(untouched.barRadius, 11); - assert.equal(untouched.gap, 8); - assert.equal(untouched.accent, "#d3d283"); + assert.equal(untouched.glassEnabled, true); + assert.equal(untouched.barHeight, 46); + assert.equal(untouched.barRadius, 23); + assert.equal(untouched.gap, 10); + assert.equal(untouched.accent, "#5e9bff"); assert.deepEqual([ untouched.barCustomHue, untouched.barCustomSaturation, untouched.barCustomLightness - ], [230, 14, 9]); - assert.equal(untouched.modOpts.ws.style, "numbers"); - assert.equal(untouched.modOpts.clock.dateFormat, "ddd dd"); + ], [247, 29, 11]); + assert.equal(untouched.modOpts.ws.style, "dots"); + assert.equal(untouched.modOpts.clock.dateFormat, "ddd d MMM"); const chosen = H.merge({ v: 9, @@ -961,9 +961,8 @@ test("pre-OCR action lists place OCR beside recording without overriding visibil assert.deepEqual(untouched.order, [ "dictation", "recording", "ocr", "reminder", "night-light", "dnd", "stay-awake" ]); - assert.deepEqual(untouched.enabled, [ - "dictation", "recording", "ocr", "reminder", "night-light", "dnd", "stay-awake" - ]); + assert.deepEqual(untouched.enabled, priorIds, + "a stored enabled list never implies permission to enable a new action"); const customized = H.merge({ v: H.VERSION, diff --git a/tests/quickshell/settings-persistence.test.cjs b/tests/quickshell/settings-persistence.test.cjs index 7383e5f9..3b1559d5 100644 --- a/tests/quickshell/settings-persistence.test.cjs +++ b/tests/quickshell/settings-persistence.test.cjs @@ -56,7 +56,8 @@ function settingsHarness() { loadError: false, loadErrorText: "", newerSchema: false, recheckPending: false, initialLoadHandled: false, lastPersistedText: "", savePending: false, announcement: "", corruptBackupPending: false, writeInFlight: false, - saveError: false, writeSnapshot: "", + saveError: false, writeSnapshot: "", sourceDocument: {}, explicitOverrides: [], + revision: 0, saveTimer: { stop() {}, restart() {} }, reloadTimer: { restart() { calls.rechecks++; } }, store: { setText() { calls.writes++; } }, @@ -73,7 +74,7 @@ function settingsHarness() { Object.assign(context, H.defaults()); vm.createContext(context); for (const name of ["snapshot", "seedWeatherFromEnv", "protectNewerFile", "assignChanged", - "applyLoaded", "handleLoadFailure", "saveNow", "set"]) + "applyLoaded", "handleLoadFailure", "saveNow", "set", "markExplicit", "scheduleSave"]) vm.runInContext(functionSource(source, name), context); return { context, calls }; } @@ -92,6 +93,7 @@ test("our own save echoes back as a no-op, byte for byte", () => { // Writers normalize, so a drifted slider value never reaches memory. context.set("scrollFactor", 7 * 0.1); assert.equal(context.scrollFactor, 0.7); + context.savePending = false; // the previous UI edit completed before this editor reload const external = H.serialize(Object.assign(H.defaults(), { scrollFactor: 1.2 })); context.applyLoaded(external); assert.equal(calls.applied, 2); diff --git a/tests/quickshell/settings-rows.test.cjs b/tests/quickshell/settings-rows.test.cjs index e0ef8263..cbbbcf8b 100644 --- a/tests/quickshell/settings-rows.test.cjs +++ b/tests/quickshell/settings-rows.test.cjs @@ -116,7 +116,7 @@ test("rows that cannot use settingKey still wire themselves completely", () => { // Their enable/remove controls preserve state, rather than inventing a reset. // System-owned values have no shell schema default. Their pages own // live writes or an explicit Apply/Cancel draft, never shell reset. - if (["ModulesPage", "PluginWidgetSettings", "SoundPage", "NetworkPage", "DisplaysPage", "AccountsPage", "IpSettings"] + if (["ModulesPage", "PluginWidgetSettings", "SoundPage", "NetworkPage", "DisplaysPage", "AccountsPage", "IpSettings", "KeyboardPage", "TouchpadPage", "RegionPage"] .some(name => block.at.startsWith(`Settings/${name}.qml:`))) { assert.match(text, /on(?:Toggled|Picked|Moved|Committed):/, `${block.at}: missing write handler`); continue; diff --git a/tests/quickshell/settings-schema.test.cjs b/tests/quickshell/settings-schema.test.cjs index 3b288c75..f86a28b0 100644 --- a/tests/quickshell/settings-schema.test.cjs +++ b/tests/quickshell/settings-schema.test.cjs @@ -70,7 +70,7 @@ test("every schema key has a property that defaults to it", () => { test("saving and loading enumerate the schema rather than restating it", () => { const snapshot = SETTINGS.slice(SETTINGS.indexOf("function snapshot()"), - SETTINGS.indexOf("function seedWeatherFromEnv")); + SETTINGS.indexOf(" SettingsDocument {")); assert.match(snapshot, /for \(const key of Object\.keys\(root\.defaults\)\)/, "snapshot() must loop the schema"); assert.ok(snapshot.split("\n").length < 15, diff --git a/tests/quickshell/settings.test.cjs b/tests/quickshell/settings.test.cjs index 2335dd70..d121e62b 100644 --- a/tests/quickshell/settings.test.cjs +++ b/tests/quickshell/settings.test.cjs @@ -380,7 +380,7 @@ test("regression fixes keep asynchronous state identity-safe", () => { test("schema twenty-three keeps safe defaults and exposes accessibility preferences", () => { const helpers = read("Common/SettingsHelpers.js"); - assert.match(helpers, /var VERSION = 26/); + assert.match(helpers, /var VERSION = 27/); // Schema 17: the drawer becomes configurable (turn-3 settings design). assert.match(helpers, /drawerHover: "open"/); assert.match(helpers, /drawerWidth: 400/); @@ -413,17 +413,15 @@ test("schema twenty-three keeps safe defaults and exposes accessibility preferen assert.match(helpers, /barStyle:\s*"hug"/); assert.match(helpers, /function migrateBarStyle\(parsed, defaultsValue\)/); assert.match(helpers, /function migratePaletteMode\(parsed, defaultsValue\)/); - assert.match(helpers, /function adoptSofterTypography\(parsed\)/); + assert.doesNotMatch(helpers, /function adoptSofterTypography\(parsed\)/); assert.match(helpers, /mod\("media", true\)/); assert.match(helpers, /mod\("bt", true\)/); assert.match(helpers, /wallDir:\s*"~\/Pictures\/Wallpapers"/); assert.match(helpers, /DETAIL_POLICIES/); - // A settings file written by the previous schema must adopt the redesign - // wherever the user never chose otherwise, or the redesign never appears. - assert.match(helpers, /function adoptRedesign\(parsed\)/); - assert.match(helpers, /V3_DEFAULTS = \{[\s\S]*?barHeight: 30/); - assert.match(helpers, /function adoptClassicMenubar\(parsed\)/); - assert.match(helpers, /V9_CLASSIC_DEFAULTS = \{[\s\S]*?barHeight: 46/); + // Stored legacy defaults are indistinguishable from explicit choices. + assert.doesNotMatch(helpers, /function adoptRedesign|function adoptClassicMenubar/); + assert.match(helpers, /function serializeDocument/); + }); test("Connected enables integration widgets including auto-hiding Bluetooth", () => { @@ -562,7 +560,7 @@ test("the grouped rail keeps labeled sections, the save state, and the nav searc const schemaKeys = Object.keys(load("SettingsHelpers.js").defaults()); const validPages = [...settings.matchAll(/\{ id: "([a-z]+)", group: "/g)].map(m => m[1]); assert.deepEqual(validPages, ["appearance", "wallpaper", "bar", "notifications", "displays", - "sound", "network", "touchpad", "power", "region", "accounts", "plugins", "about"]); + "sound", "network", "touchpad", "keyboard", "power", "region", "accounts", "plugins", "about"]); const rows = load("SettingsSearchData.js").ROWS; assert.ok(rows.length >= 30, "the search index must cover the workspace"); for (const row of rows) { @@ -969,53 +967,23 @@ test("FileView failures cannot become empty settings or false save success", () /function handleLoadFailure\(error\)[\s\S]*?ready = false;[\s\S]*?loadError = true;/, "other read errors must retain memory state and disable writes"); assert.doesNotMatch(settings, /onLoadFailed:\s*root\.applyLoaded\(""\)/); - assert.match(settings, /onSaved: root\.handleSaveSucceeded\(\)/); - assert.match(settings, - /onSaveFailed: error => root\.handleSaveFailure\(error\)/); + assert.match(settings, /root\.handleSaveSucceeded\(result\.text\)/); + assert.match(settings, /root\.handleSaveFailure\(FileViewError\.Unknown\)/); + assert.match(settings, /CommandRequest \{\s*id: settingsWriter/); const saveNow = settings.slice(settings.indexOf("function saveNow()"), settings.indexOf("function scheduleSave()")); assert.doesNotMatch(saveNow, /lastSavedAt\s*=/, "starting a write is not evidence that it succeeded"); assert.match(settings, - /function handleSaveSucceeded\(\)[\s\S]*lastSavedAt = Date\.now\(\)/); + /function handleSaveSucceeded\(committed\)[\s\S]*lastSavedAt = Date\.now\(\)/); }); -test("an unchanged save cannot block subsequent widget changes", () => { - const vm = require("node:vm"); +test("settings use the verified atomic writer and production document queue", () => { const source = read("Common/Settings.qml"); - let value = { mods: { left: [{ id: "ws", on: true }] } }; - let disk = JSON.stringify(value); - let writes = 0; - const context = vm.createContext({ - ready: true, migrationPending: false, corruptBackupPending: false, loadError: false, - writeInFlight: false, writeSnapshot: "", lastPersistedText: disk, storeText: disk, - reloadAfterWrite: false, - saveError: false, savePending: true, lastSavedAt: 0, - SettingsHelpers: { serialize: JSON.stringify }, snapshot: () => value, - saveTimer: { restart() {} }, FileViewError: { Unknown: 1 }, - store: { setText(text) { - if (text === disk) return; // FileView emits no saved signal for a no-op. - writes++; disk = text; context.handleSaveSucceeded(); - } } - }); - for (const name of ["sameContent", "releaseWriteGuard", "saveNow", "handleSaveSucceeded", - "handleSaveFailure"]) { - const body = source.match(new RegExp(" function " + name + "\\([^]*?^ }", "m"))[0]; - vm.runInContext(body, context); - } - context.saveNow(); - assert.equal(context.writeInFlight, false); - assert.equal(context.savePending, false); - assert.equal(writes, 0); - for (const on of [false, true]) { - value = { mods: { left: [{ id: "ws", on }] } }; - context.savePending = true; - context.saveNow(); - assert.equal(context.writeInFlight, false); - assert.equal(context.savePending, false); - assert.equal(JSON.parse(disk).mods.left[0].on, on); - context.saveNow(); // Opening a form can schedule the same value again. - assert.equal(context.writeInFlight, false); - } - assert.equal(writes, 2); + assert.match(source, /if \(!document\.begin\(\)\)/); + assert.match(source, /candidate: writeSnapshot, version: SettingsHelpers\.VERSION/); + assert.match(source, /sameContent\(next, lastPersistedText\)/); + assert.doesNotMatch(source, /store\.setText/); + // Real QML asynchronous transitions are exercised in tst_settings_document, + // and settings-ownership.py exercises the actual atomic filesystem writer. }); diff --git a/tests/quickshell/system-theme-kitty.test.cjs b/tests/quickshell/system-theme-kitty.test.cjs index a74882e1..ec84eee1 100644 --- a/tests/quickshell/system-theme-kitty.test.cjs +++ b/tests/quickshell/system-theme-kitty.test.cjs @@ -66,8 +66,8 @@ test("the vendor fragment includes the generated file after its fallback", () => assert.equal(lines.at(-1), INCLUDE, "the include is the last setting, so it wins"); assert.equal(lines.filter(line => /^(glob|env|gen)?include /.test(line)).length, 1); const tasks = fs.readFileSync(path.join(repoRoot, "roles/dotfiles/tasks/personal.yml"), "utf8"); - assert.match(tasks, /src: kitty\.conf\n\s+dest: "\{\{ primary_home \}\}\/\.config\/kitty\/cybexos\.conf"/); - assert.match(tasks, /block: "include cybexos\.conf"/); + assert.match(tasks, /content:.*files\/kitty\.conf[^\n]*\n\s+dest: "\{\{ primary_home \}\}\/\.config\/kitty\/cybexos\.conf"/); + assert.match(tasks, /cybexos_user_include:[\s\S]*?kind: kitty/); }); test("kitty is signalled only when its file changed, or on --force", t => { diff --git a/tests/quickshell/t3-actions.test.cjs b/tests/quickshell/t3-actions.test.cjs new file mode 100644 index 00000000..13d1c5cf --- /dev/null +++ b/tests/quickshell/t3-actions.test.cjs @@ -0,0 +1,67 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const { shellDir, load } = require("./shell.cjs"); + +// Evaluate the production domain methods with a controlled wire boundary. +// The Qt lifecycle harness separately verifies process/event delivery. +function harness() { + const sent = []; + const context = { actionStates: {}, actionTimeoutMs: 15000, + supportsSettlement: true, supportsSnooze: true, + supportsTitleRegeneration: true, supportsPinning: true, + Helpers: load("T3CodeHelpers.js"), + T3Connection: { canOperate: true, state: "connected" }, + T3Threads: { threadMap: {} }, + T3Rpc: { genId: () => "command", requestOnce: (tag, payload, success, failure) => { + sent.push({ tag, payload, success, failure }); return String(sent.length); + } }, + console: { warn() {} }, Date, + }; + context.root = context; + vm.createContext(context); + const source = fs.readFileSync(path.join(shellDir, "Common/T3Actions.qml"), "utf8"); + for (const match of source.matchAll(/^ function \w+\([^]*?^ }/gm)) + vm.runInContext(match[0], context); + return { context, sent }; +} + +test("a partially accepted batch stops on rejection and is never replayed", () => { + const { context: c, sent } = harness(); + const commands = [{type:"one"}, {type:"two"}, {type:"three"}]; + c.dispatchBatch(commands, "batch", {}); + assert.equal(sent.length, 1); + assert.equal(c.actionStates.batch.pending, true); + sent[0].success(); + assert.equal(sent.length, 2); + sent[1].failure("Disconnected before confirmation"); + assert.equal(sent.length, 2); + assert.equal(c.actionStates.batch.pending, false); + assert.equal(c.actionStates.batch.error, "Disconnected before confirmation"); +}); + +test("duplicate pending actions and read-only connections cannot dispatch", () => { + const { context: c, sent } = harness(); + c.dispatch({type:"one"}, "same", true); + assert.equal(c.dispatch({type:"one"}, "same", true), ""); + assert.equal(sent.length, 1); + sent[0].success(); + assert.equal(c.actionStates.same.pending, true, "RPC acceptance alone cannot resolve an approval"); + c.T3Connection.canOperate = false; + c.dispatch({type:"two"}, "other", false); + assert.equal(sent.length, 1); + assert.match(c.actionStates.other.error, /read-only/); +}); + +test("action expiry affects pending feedback and retains earlier errors", () => { + const { context: c } = harness(); + c.beginAction("expired", "1", false, 20); + c.beginAction("running", "2", false, 50000); + c.rejectAction("failed", "Rejected", false); + c.expire(Date.now() + 30); + assert.equal(c.actionStates.expired.pending, false); + assert.equal(c.actionStates.running.pending, true); + assert.equal(c.actionStates.failed.error, "Rejected"); +}); diff --git a/tests/quickshell/widget-editor.test.cjs b/tests/quickshell/widget-editor.test.cjs index 097be3f3..8dc057db 100644 --- a/tests/quickshell/widget-editor.test.cjs +++ b/tests/quickshell/widget-editor.test.cjs @@ -81,7 +81,7 @@ function membershipHarness() { const settings = fs.readFileSync(path.join(shellDir,'Common/Settings.qml'),'utf8'); const timer = { restart() {}, stop() {} }; const context = vm.createContext({ - mods: structuredClone(mods), clearUndo() {}, migrationPending: false, + mods: structuredClone(mods), clearUndo() {}, markExplicit() {}, migrationPending: false, LayoutHelpers: L, membershipBusy: false, subPage: '', search: { text: '' }, detailPage: { contentY: 0 }, pendingMembership: null, undoRemoved: null, notice: '', announcement: '', membershipTimeout: timer, noticeTimer: timer, membershipFocus: { ...timer }, diff --git a/tests/quickshell/widget-options.test.cjs b/tests/quickshell/widget-options.test.cjs index 6315698e..21d6bdf5 100644 --- a/tests/quickshell/widget-options.test.cjs +++ b/tests/quickshell/widget-options.test.cjs @@ -11,6 +11,7 @@ function settingsHarness() { const context = vm.createContext({ ...H.defaults(), defaults: H.defaults(), SettingsHelpers: H, resetSnapshot: null, resetLabel: '', migrationPending: false, + explicitOverrides: [], resetOverrides: [], scheduleSave() {}, markExplicit() {}, resetTimer: { restart() {}, stop() {} }, sectionKeys: { drawer: ['drawerTabs', 'drawerOverview', 'drawerHover', 'drawerWidth'] } }); diff --git a/tests/repository-policy.py b/tests/repository-policy.py index 0b01f191..8930febe 100644 --- a/tests/repository-policy.py +++ b/tests/repository-policy.py @@ -217,9 +217,9 @@ def verify_dependency_policy(values: dict) -> None: assert "git" in required_commands.group(1).split() runner = (ROOT / "tests/run").read_text() - assert "rg -l '^#!.*(bash|sh)' -g '!*.j2' ." in runner + assert r"rg -Ul '\A#![^\n]*(bash|sh)' -g '!*.j2' ." in runner assert "rg --files . -g '*.py'" in runner - assert "rg -l '^#!.*python' -g '!*.j2' ." in runner + assert r"rg -Ul '\A#![^\n]*python' -g '!*.j2' ." in runner verifier = (ROOT / "tests/verify-system").read_text() assert not re.search( diff --git a/tests/run b/tests/run index 17040c70..09f0788c 100755 --- a/tests/run +++ b/tests/run @@ -221,9 +221,11 @@ stage_source_syntax() { # Give content searches an explicit root. Some ripgrep builds treat a # non-interactive empty stdin as the search input when no path is supplied, # which made CI discover zero shell sources despite a complete checkout. - mapfile -t bash_files < <(rg -l '^#!.*(bash|sh)' -g '!*.j2' . | LC_ALL=C sort) + # Only the first line declares a file's language; fixtures contain script + # heredocs whose embedded shebang must not classify the enclosing file. + mapfile -t bash_files < <(rg -Ul '\A#![^\n]*(bash|sh)' -g '!*.j2' . | LC_ALL=C sort) mapfile -t python_files < <( - { rg --files . -g '*.py'; rg -l '^#!.*python' -g '!*.j2' .; } | LC_ALL=C sort -u + { rg --files . -g '*.py'; rg -Ul '\A#![^\n]*python' -g '!*.j2' .; } | LC_ALL=C sort -u ) # Model Usage's vendored UsageLogic.js is a QML `.pragma library`, which # node cannot parse; tests/model-usage.py runs upstream's own test on it. @@ -309,6 +311,7 @@ stage_hyprland_workspaces() { r=0 bash tests/hyprland-workspaces || r=1 bash tests/hyprland-features || r=1 + bash tests/hyprland-input || r=1 exit "$r" } 2>&1) || rc=$? elapsed=$(( $(now_ms) - t0 )) @@ -467,13 +470,19 @@ python_fixtures=( bluetooth-tool firmware-update shell-health + shell-recovery repository-policy application-defaults installation-parity login-policy session-launcher + update-transaction + update-bootstrap + major-upgrade + native-input-region omawrite ownership-layering + settings-ownership plugin-packages model-usage installer-convergence diff --git a/tests/session-launcher.py b/tests/session-launcher.py index a7e57496..6a28971a 100644 --- a/tests/session-launcher.py +++ b/tests/session-launcher.py @@ -4,6 +4,7 @@ import os from pathlib import Path import shlex +import shutil import signal import subprocess import tempfile @@ -14,6 +15,25 @@ ROOT = Path(__file__).resolve().parents[1] +AUTOSTART_DRIVER = ''' +local start +local calls = 0 +hl = { + on = function(event, callback) + assert(event == "hyprland.start" and start == nil) + start = callback + end, + exec_cmd = function(command) + calls = calls + 1 + io.write(command) + end, +} +dofile(arg[1]) +assert(calls == 0, "services must wait for the compositor's startup event") +assert(start, "the session startup callback was not registered") +start() +assert(calls == 1, "session publication must stay in one ordered process") +''' COMPOSITOR = '''#!/usr/bin/python3 import json, os, signal, sys from pathlib import Path @@ -28,6 +48,89 @@ ''' +class SessionAutostartTests(unittest.TestCase): + """Execute the startup event against each installation's helper layout. + + Development mode on an ISO reads the unmodified checkout Lua, bypassing + the image packager's /usr/local/libexec -> /usr/libexec rewriting. + """ + + def exercise(self, layout, *, missing=False, nonexecutable_local=False): + with tempfile.TemporaryDirectory(prefix='cybex-session-start.') as directory: + root = Path(directory) / 'fixture root' + binaries = root / 'bin' + binaries.mkdir(parents=True) + log = root / 'calls.log' + local = root / 'usr/local/libexec/cybexos-hyprland-session-start' + packaged = root / 'usr/libexec/cybexos-hyprland-session-start' + starter = local if layout == 'checkout' else packaged + if not missing: + starter.parent.mkdir(parents=True) + shutil.copyfile(ROOT / 'roles/desktop/files/hyprland-session-start', starter) + starter.chmod(0o755) + if nonexecutable_local: + local.parent.mkdir(parents=True) + local.write_text('not executable\n') + local.chmod(0o644) + source = (ROOT / 'roles/desktop/files/autostart.lua').read_text() + if layout == 'image': + # Keep the packaging rule tied to the actual image builder; + # the development case deliberately omits this transform. + rule = 'content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")' + self.assertIn(rule, (ROOT / 'image/package').read_text()) + source = source.replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-') + autostart = root / 'autostart.lua' + autostart.write_text(source) + selected = subprocess.run(['luajit', '-', str(autostart)], input=AUTOSTART_DRIVER, + text=True, capture_output=True, timeout=5) + self.assertEqual(selected.returncode, 0, selected.stderr) + command = selected.stdout + # Redirect only absolute helper paths into this disposable tree; + # execute the emitted shell logic and real ordered starter. + for prefix in ('/usr/local/libexec', '/usr/libexec'): + command = command.replace(prefix, shlex.quote(str(root / prefix.lstrip('/')))) + for name in ('systemctl', 'dbus-update-activation-environment', 'sleep'): + executable = binaries / name + executable.write_text('#!/bin/sh\n' + f'printf "%s %s\\n" {shlex.quote(name)} "$*" >>"$SESSION_TEST_LOG"\n') + executable.chmod(0o755) + environment = dict(os.environ, PATH=f'{binaries}:/usr/bin:/bin', + XDG_RUNTIME_DIR=str(root / 'run'), SESSION_TEST_LOG=str(log), + WAYLAND_DISPLAY='wayland-fixture', XDG_CURRENT_DESKTOP='Hyprland', + HYPRLAND_INSTANCE_SIGNATURE='fixture') + result = subprocess.run(['sh', '-c', command], env=environment, + text=True, capture_output=True, timeout=5) + if missing: + self.assertNotEqual(result.returncode, 0) + self.assertIn('cybexos-hyprland-session-start', result.stderr) + self.assertFalse(log.exists()) + return [] + self.assertEqual(result.returncode, 0, result.stderr) + calls = log.read_text().splitlines() + self.assertEqual(calls, [ + 'systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE', + 'dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE', + 'systemctl --user start hyprland-session.target', + 'sleep 1', + 'systemctl --user restart xdg-desktop-portal-hyprland.service xdg-desktop-portal.service', + ]) + return calls + + def test_checkout_image_and_image_development_start_the_same_session(self): + expected = self.exercise('checkout') + for layout in ('image', 'image-development'): + with self.subTest(layout=layout): + self.assertEqual(self.exercise(layout), expected) + + def test_image_development_ignores_nonexecutable_local_helper(self): + self.exercise('image-development', nonexecutable_local=True) + + def test_missing_helper_fails_without_starting_session_services(self): + for layout in ('checkout', 'image', 'image-development'): + with self.subTest(layout=layout): + self.exercise(layout, missing=True) + + class SessionLauncherTests(unittest.TestCase): def exercise(self, image, behavior): with tempfile.TemporaryDirectory(prefix='cybex-session-launcher.') as directory: diff --git a/tests/settings-ownership.py b/tests/settings-ownership.py new file mode 100644 index 00000000..73c72ac9 --- /dev/null +++ b/tests/settings-ownership.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Lossless settings writes and actual application include precedence.""" +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +import yaml + +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 + + +STORE = load('settings_store', 'roles/desktop/files/quickshell/scripts/settings-store') +MANAGED = load('managed_file', 'image/library/cybexos_managed_file.py') +INCLUDE = load('user_include', 'image/library/cybexos_user_include.py') + + +class SettingsOwnership(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='cybexos-settings-ownership-') + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.path = self.home / 'shell.json' + + def test_independent_concurrent_edit_survives(self): + base = {'v': 27, 'unit': 'c', 'themeMode': 'dark', 'future': {'data': [1]}} + local = {**base, 'unit': 'f'} + other = {**base, 'themeMode': 'light', 'future': {'data': [2]}} + self.path.write_text(json.dumps(other)) + saved = json.loads(STORE.commit(self.path, json.dumps(base), json.dumps(local), 27)) + self.assertEqual(saved, {**other, 'unit': 'f'}) + self.assertEqual(json.loads(self.path.read_text()), saved) + + def test_conflict_preserves_disk_and_pending_candidate(self): + base = {'v': 27, 'barHeight': 36} + desired = {'v': 27, 'barHeight': 40} + current = json.dumps({'v': 27, 'barHeight': 44}) + self.path.write_text(current) + with self.assertRaisesRegex(ValueError, '/barHeight'): + STORE.commit(self.path, json.dumps(base), json.dumps(desired), 27) + self.assertEqual(self.path.read_text(), current) + copies = list(self.home.glob('shell.json.conflict-*')) + self.assertEqual(len(copies), 1) + self.assertEqual(json.loads(copies[0].read_text()), desired) + + def test_newer_schema_blocks_writes_even_if_loaded_file_was_older(self): + future = '{"v":28,"future":{"opaque":true}}' + self.path.write_text(future) + with self.assertRaisesRegex(ValueError, 'newer shell'): + STORE.commit(self.path, '{"v":27}', '{"v":27,"unit":"f"}', 27) + self.assertEqual(self.path.read_text(), future) + + def test_migration_keeps_exact_original_and_unknown_fields(self): + original = '{ "v": 3, "barHeight":30, "unknown":{"unparsed":true} }\n' + self.path.write_text(original) + candidate = json.loads(original) + candidate.update(v=27, unit='f') + STORE.commit(self.path, original, json.dumps(candidate), 27) + backups = list(self.home.glob('shell.json.before-migration-*')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), original) + self.assertEqual(json.loads(self.path.read_text())['unknown'], {'unparsed': True}) + + def test_nested_merge_and_explicit_reset_preserve_other_edits(self): + base = {'v': 27, 'unit': 'c', 'modOpts': {'weather': {'place': 'Home', 'pollMins': 10}}} + local = {'v': 27, 'modOpts': {'weather': {'place': 'Home', 'pollMins': 20}}} + other = {'v': 27, 'unit': 'c', 'modOpts': {'weather': {'place': 'Away', 'pollMins': 10}}} + merged = STORE.merge(base, local, other) + self.assertNotIn('unit', merged) + self.assertEqual(merged['modOpts']['weather'], {'place': 'Away', 'pollMins': 20}) + + def test_owned_fragments_update_but_customization_and_deletion_survive(self): + ledger = self.home / 'state/ownership.json' + target = self.home / 'kitty/cybexos.conf' + self.assertTrue(MANAGED.manage(target, b'first\n', ledger)['changed']) + self.assertTrue(MANAGED.manage(target, b'second\n', ledger)['changed']) + target.write_bytes(b'custom\n') + self.assertTrue(MANAGED.manage(target, b'third\n', ledger)['preserved']) + self.assertEqual(target.read_bytes(), b'custom\n') + target.unlink() + self.assertTrue(MANAGED.manage(target, b'third\n', ledger)['preserved']) + self.assertFalse(target.exists()) + self.assertEqual(len(list((ledger.parent / 'backups').rglob('*/*'))), 1) + + def test_user_edited_include_block_is_not_replaced_or_removed(self): + original = INCLUDE.BEGIN + '\ninclude personal.conf\n' + INCLUDE.END + '\n' + self.path.write_text(original) + self.assertTrue(INCLUDE.update(self.path, 'kitty')['preserved']) + self.assertTrue(INCLUDE.update(self.path, 'kitty', absent=True)['preserved']) + self.assertEqual(self.path.read_text(), original) + + def test_relocation_is_idempotent_and_preserves_the_original(self): + old = 'font_size 17\n' + INCLUDE.BEGIN + '\ninclude cybexos.conf\n' + INCLUDE.END + '\n' + self.path.write_text(old) + self.assertTrue(INCLUDE.update(self.path, 'kitty')['changed']) + self.assertFalse(INCLUDE.update(self.path, 'kitty')['changed']) + self.assertTrue(self.path.read_text().startswith(INCLUDE.BEGIN)) + self.assertEqual(next(self.home.glob('shell.json.cybexos-before-*')).read_text(), old) + + def test_git_user_values_win_in_the_actual_parser(self): + vendor = self.home / '.config/cybexos/gitconfig' + vendor.parent.mkdir(parents=True) + vendor.write_text('[core]\n pager = delta\n') + target = self.home / '.gitconfig' + target.write_text('[core]\n pager = personal-pager\n[user]\n name = Personal\n') + INCLUDE.update(target, 'git') + target.write_text(target.read_text().replace('~/.config', str(self.home / '.config'))) + self.assertEqual(subprocess.check_output(['git', 'config', '--file', str(target), '--includes', + '--get', 'core.pager'], text=True).strip(), 'personal-pager') + + def test_personal_git_helper_chain_is_detected_through_nested_includes(self): + vendor = self.home / '.config/cybexos/gitconfig' + vendor.parent.mkdir(parents=True) + vendor.write_text('[credential "https://github.com"]\n helper = vendor-helper\n') + target = self.home / '.gitconfig' + target.write_text('[include]\n path = ' + str(vendor) + '\n') + self.assertFalse(INCLUDE.personal_git_credentials(target)) + personal = self.home / 'personal.gitconfig' + personal.write_text('[credential "https://github.com"]\n helper = personal-helper\n') + target.write_text(target.read_text() + '[include]\n path = ' + str(personal) + '\n') + self.assertTrue(INCLUDE.personal_git_credentials(target)) + + def test_ssh_user_first_value_and_final_host_scope(self): + vendor = self.home / '.config/cybexos/ssh.conf' + vendor.parent.mkdir(parents=True) + vendor.write_text('Host *\n IdentityAgent /vendor/agent.sock\n ServerAliveInterval 17\n') + target = self.home / '.ssh/config' + target.parent.mkdir() + target.write_text('Host example\n IdentityAgent /personal/agent.sock\nHost different\n User custom\n') + INCLUDE.update(target, 'ssh') + target.write_text(target.read_text().replace('~/.config', str(self.home / '.config'))) + actual = subprocess.check_output(['ssh', '-G', '-F', str(target), 'example'], text=True, + stderr=subprocess.DEVNULL) + self.assertIn('identityagent /personal/agent.sock\n', actual) + self.assertIn('serveraliveinterval 17\n', actual) + + @unittest.skipUnless(shutil.which('kitty'), 'Kitty config parser is not installed') + def test_kitty_user_last_value_in_the_actual_parser(self): + target = self.home / 'kitty.conf' + (self.home / 'cybexos.conf').write_text('font_size 12\n') + target.write_text('font_size 17\n') + INCLUDE.update(target, 'kitty') + code = 'from kitty.config import load_config; print(load_config(' + repr(str(target)) + ').font_size)' + self.assertEqual(float(subprocess.check_output(['kitty', '+runpy', code], text=True).strip()), 17) + + def test_checkout_and_image_share_the_same_personal_policy(self): + personal = yaml.safe_load((ROOT / 'roles/dotfiles/tasks/personal.yml').read_text()) + by_name = {task['name']: task for task in personal} + xdg = by_name['Configure XDG user directories']['ansible.builtin.copy'] + self.assertFalse(xdg['force']) + for task in personal: + if 'cybexos_managed_file' in task: + self.assertEqual(task['become_user'], '{{ primary_user }}') + self.assertIn('/.local/state/cybexos/defaults/', task['cybexos_managed_file']['ledger']) + provision = (ROOT / 'image/provision.yml').read_text() + self.assertIn('tasks_from: personal', provision) + self.assertIn('library = image/library', (ROOT / 'ansible.cfg').read_text()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/shell-recovery.py b/tests/shell-recovery.py new file mode 100644 index 00000000..22852a6c --- /dev/null +++ b/tests/shell-recovery.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Exercise crash accounting and recovery with isolated state; never start qs.""" +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "roles/desktop/files/quickshell/scripts/shell-recovery.py" +SPEC = importlib.util.spec_from_file_location("shell_recovery", SCRIPT) +assert SPEC and SPEC.loader +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) + + +class RecoveryTest(unittest.TestCase): + def test_failures_select_safe_mode_on_fourth_launch(self): + state = {"version": 1, "safe": False, "failures": []} + for count in range(3): + state = M.prepare(state, 10 * count, "boot", str(count)) + state = M.record_stop(state, 10 * count + 1, "boot", str(count), "signal", "killed", "SEGV") + self.assertEqual(bool(state.get("safe")), count == 2) + state = M.prepare(state, 31, "boot", "safe") + self.assertTrue(state["safe"]) + + def test_normal_restart_long_run_and_boot_do_not_form_crash_loop(self): + state = {"version": 1, "safe": False, "failures": []} + for count in range(6): + state = M.prepare(state, count * 5, "boot", str(count)) + state = M.record_stop(state, count * 5 + 1, "boot", str(count), "success", "exited", "0") + self.assertFalse(state["safe"]) + state = M.prepare(state, 100, "boot", "fail") + state = M.record_stop(state, 101, "boot", "fail", "exit-code", "exited", "1") + state = M.prepare(state, 105, "boot", "long") + state = M.record_stop(state, 300, "boot", "long", "signal", "killed", "SEGV") + self.assertEqual(state["failures"], []) + state["failures"] = [300, 305] + self.assertEqual(M.prepare(state, 1, "new-boot", "next")["failures"], []) + + def test_stale_or_duplicate_stop_cannot_count_twice(self): + state = M.prepare({"version": 1, "safe": False, "failures": []}, 0, "boot", "new") + self.assertEqual(M.record_stop(state, 1, "boot", "old", "signal", "killed", "SEGV"), state) + stopped = M.record_stop(state, 1, "boot", "new", "signal", "killed", "SEGV") + self.assertEqual(M.record_stop(stopped, 2, "boot", "new", "signal", "killed", "SEGV"), stopped) + + def test_launcher_execs_selected_shell_without_an_extra_supervisor(self): + with tempfile.TemporaryDirectory(prefix="cybexos-shell-exec.") as scratch: + runtime = ROOT / "roles/desktop/files/quickshell" + with patch.dict(os.environ, {"XDG_STATE_HOME": scratch, "INVOCATION_ID": "test"}): + M.main(["safe", str(runtime)]) + with patch.object(M.os, "execv") as execute: + M.main(["run", str(runtime)]) + execute.assert_called_once_with("/usr/bin/qs", ["qs", "-p", str(runtime / "safe-mode")]) + self.assertEqual(M.read_state(M.state_path())["invocation"], "test") + + def test_commands_preserve_config_and_roundtrip_state(self): + with tempfile.TemporaryDirectory(prefix="cybexos-shell-recovery.") as scratch: + root = Path(scratch) + config = root / "config/cybexos" + config.mkdir(parents=True) + personal = {"shell.json": '{"v":26,"font":"mine"}', "plugins.json": '{"enabled":["mine"]}'} + for name, content in personal.items(): + (config / name).write_text(content) + env = dict(os.environ, XDG_STATE_HOME=str(root / "state"), XDG_CONFIG_HOME=str(root / "config"), HOME=str(root)) + runtime = ROOT / "roles/desktop/files/quickshell" + def call(action): + return subprocess.check_output(["python3", "-B", str(SCRIPT), action, str(runtime)], env=env, text=True) + call("safe") + status = json.loads(call("status")) + self.assertTrue(status["safe"]) + self.assertEqual(call("path").strip(), str(runtime / "safe-mode")) + self.assertEqual(Path(status["path"]).stat().st_mode & 0o777, 0o600) + call("recover") + self.assertFalse(json.loads(call("status"))["safe"]) + self.assertEqual(call("path").strip(), str(runtime)) + for name, content in personal.items(): + self.assertEqual((config / name).read_text(), content) + Path(status["path"]).write_text("{broken") + self.assertTrue(json.loads(call("status"))["safe"]) + call("recover") + self.assertFalse(json.loads(call("status"))["safe"]) + self.assertEqual(list((root / "state/cybexos").glob(".shell-recovery-*")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system-settings-live b/tests/system-settings-live index a70e3f37..78ff6f45 100755 --- a/tests/system-settings-live +++ b/tests/system-settings-live @@ -19,17 +19,18 @@ cleanup() { exit "$status" } trap cleanup EXIT -for page in sound network accounts network; do +for page in sound network accounts keyboard touchpad region network; do qs_live_wait_ipc 10 settings open "$page" >/dev/null ready=false - for _ in {1..40}; do + for _ in {1..100}; do state=$(qs_live_wait_ipc 5 settings status) if python3 - "$state" "$page" <<'PY' import json, sys state, page = json.loads(sys.argv[1]), sys.argv[2] -service = state['services'][page] +domain = 'input' if page in ('keyboard', 'touchpad') else page +service = state['services'][domain] sys.exit(0 if state['open'] and state['page'] == page and service['loaded'] - and service['watchers'] == 1 and service['watching'] and not service['failed'] + and service['watchers'] == 1 and service['watching'] == (domain not in ('input', 'region')) and not service['failed'] and not service['busy'] and not service['loading'] else 1) PY then ready=true; break; fi diff --git a/tests/update-bootstrap.py b/tests/update-bootstrap.py new file mode 100644 index 00000000..b8a0df2e --- /dev/null +++ b/tests/update-bootstrap.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Exercise first-upgrade recovery deployment without touching the host.""" +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import tempfile +import types +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +loader = importlib.machinery.SourceFileLoader('bootstrap', str( + ROOT / 'roles/base/files/cybexos-update-bootstrap')) +spec = importlib.util.spec_from_loader(loader.name, loader) +bootstrap = importlib.util.module_from_spec(spec) +loader.exec_module(bootstrap) + + +class Bootstrap(unittest.TestCase): + def setUp(self): + temporary = self.enterContext(tempfile.TemporaryDirectory(prefix='cybexos-bootstrap-test.')) + self.fixture = Path(temporary) + self.host = self.fixture / 'system' + self.host.mkdir() + self.source = self.fixture / 'source' + self.source.mkdir() + for name in bootstrap.FILES: + shutil.copyfile(ROOT / 'roles/base/files' / name, self.source / name) + self.point = '20260930T140000Z-100' + self.protected = '20260930T140001Z-101' + self.store = types.SimpleNamespace(path=self.fixture / 'store', roots=self.fixture / 'snapshots') + self.store.path.mkdir() + self.store.roots.mkdir() + (self.store.roots / self.point).mkdir() + self.layout = types.SimpleNamespace(usable=True, pending_reboot=False, kind='btrfs') + self.snapshot = types.SimpleNamespace(detect_layout=lambda: self.layout, + opened_store=self.opened_store, + command_create=self.create_snapshot) + self.commands = [] + self.enabled = False + for key, value in ( + ('HOST', self.host), ('ROOT_UID', os.getuid()), + ('BUNDLES', self.host / 'usr/local/libexec/cybexos-update-bootstrap.d'), + ('UNIT', self.host / 'etc/systemd/system/cybexos-update-recover.service'), + ('VENDOR_UNIT', self.host / 'usr/lib/systemd/system/cybexos-update-recover.service'), + ('snapshot_module', lambda _contents: self.snapshot), ('execute', self.execute), + ): + self.enterContext(patch.object(bootstrap, key, value)) + + @contextlib.contextmanager + def opened_store(self, _layout, create=False): + yield self.store + + def execute(self, command): + self.commands.append(command) + if command[:2] == ['systemctl', 'enable']: + self.enabled = True + if command[:2] == ['systemctl', 'is-enabled']: + return 'enabled' if self.enabled else 'disabled' + if command[:2] == ['systemctl', 'show']: + unit = command[2] + property_name = command[4] + if property_name == 'ExecStart': + file = bootstrap.UNIT if bootstrap.UNIT.exists() else bootstrap.VENDOR_UNIT + text = file.read_text() + start = next(line.split('=', 1)[1] for line in text.splitlines() if line.startswith('ExecStart=')) + return '{ path=' + start + ' ; argv[]=' + start + ' ; }' + barrier = bootstrap.UNIT.parent / f'{unit}.d/60-cybexos-update-recover.conf' + if barrier.exists() and f'{property_name}=cybexos-update-recover.service' in barrier.read_text(): + return 'cybexos-update-recover.service' + return '' + return '' + + def create_snapshot(self, _arguments): + # The actual point captures installed code and login barriers before + # any journal can permit package/home mutation. Root restoration must + # recover this hook, not the pre-feature root without one. + shutil.copytree(self.host, self.store.roots / self.protected) + print(self.protected) + + def prepare(self): + return bootstrap.prepare(self.source, self.point, 'update-fixture') + + def test_first_upgrade_captures_a_self_contained_root_owned_recovery_hook(self): + result = self.prepare() + self.assertEqual(result['snapshot'], self.protected) + self.assertEqual(result['previousSnapshot'], self.point) + bundle = Path(result['transactionHelper']).parent + bootstrap.ready(bundle) + preserved_root = self.store.roots / self.protected + preserved_unit = preserved_root / bootstrap.UNIT.relative_to(self.host) + self.assertIn(f'ExecStart={bundle}/cybexos-update-recover', preserved_unit.read_text()) + for name in bootstrap.FILES: + preserved = preserved_root / bundle.relative_to(self.host) / name + self.assertEqual(preserved.read_bytes(), (self.source / name).read_bytes()) + self.assertEqual(preserved.stat().st_mode & 0o022, 0) + for unit in ('systemd-user-sessions.service', 'sddm.service'): + barrier = preserved_unit.parent / f'{unit}.d/60-cybexos-update-recover.conf' + self.assertIn('Requires=cybexos-update-recover.service', barrier.read_text()) + self.assertEqual(list((self.store.roots / self.point).iterdir()), [], 'untouched original point') + (self.source / 'cybexos-update-transaction').write_text('changed user checkout') + self.assertNotEqual((bundle / 'cybexos-update-transaction').read_text(), 'changed user checkout') + + def test_missing_checkpoint_refuses_before_any_installed_write(self): + (self.store.roots / self.point).rmdir() + with self.assertRaisesRegex(bootstrap.Failure, 'missing'): + self.prepare() + self.assertEqual(list(self.host.iterdir()), []) + self.assertEqual(self.commands, []) + + def test_unfinished_journal_blocks_bootstrap_and_cleanup(self): + result = self.prepare() + journal = self.store.path / 'transactions/earlier/transaction.json' + journal.parent.mkdir(parents=True) + journal.write_text(json.dumps({'state': 'rolling-back'})) + with self.assertRaisesRegex(bootstrap.Failure, 'earlier update'): + self.prepare() + bundle = Path(result['transactionHelper']).parent + with self.assertRaisesRegex(bootstrap.Failure, 'unfinished update'): + bootstrap.finalize(bundle) + self.assertTrue(bundle.is_dir()) + + def test_login_barrier_and_enablement_are_required(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + self.enabled = False + with self.assertRaisesRegex(bootstrap.Failure, 'not enabled'): + bootstrap.ready(bundle) + self.enabled = True + barrier = bootstrap.UNIT.parent / 'sddm.service.d/60-cybexos-update-recover.conf' + barrier.unlink() + with self.assertRaisesRegex(bootstrap.Failure, 'barrier'): + bootstrap.ready(bundle) + + def test_snapshot_failure_leaves_boot_recovery_but_no_package_mutation(self): + with patch.object(self.snapshot, 'command_create', side_effect=OSError('snapshot failed')): + with self.assertRaisesRegex(OSError, 'snapshot failed'): + self.prepare() + self.assertTrue(bootstrap.UNIT.is_file()) + self.assertFalse((self.store.roots / self.protected).exists()) + self.assertTrue(all(command[0] in {'sync', 'systemctl', 'restorecon'} for command in self.commands)) + + def test_source_and_destination_symlinks_fail_closed(self): + target = self.source / 'cybexos-update-transaction' + target.unlink() + target.symlink_to(ROOT / 'roles/base/files/cybexos-update-transaction') + with self.assertRaises(OSError): + self.prepare() + self.assertEqual(list(self.host.iterdir()), []) + target.unlink() + shutil.copyfile(ROOT / 'roles/base/files/cybexos-update-transaction', target) + (self.host / 'usr').symlink_to(self.fixture / 'escape') + with self.assertRaises((OSError, bootstrap.Failure)): + self.prepare() + self.assertFalse((self.fixture / 'escape').exists()) + + def test_bundle_corruption_is_not_reused(self): + result = self.prepare() + helper = Path(result['transactionHelper']) + helper.write_text('unexpected change') + with self.assertRaisesRegex(bootstrap.Failure, 'changed'): + self.prepare() + + def install_normal(self, rpm=False): + libexec = self.host / ('usr/libexec' if rpm else 'usr/local/libexec') + bootstrap.directory(libexec) + for name in bootstrap.FILES[:6]: + bootstrap.atomic_write(libexec / name, (self.source / name).read_bytes(), + 0o644 if name.endswith('.json') else 0o755) + unit = (self.source / 'cybexos-update-recover.service').read_text() + unit = unit.replace('/usr/local/libexec/', str(libexec) + '/') + bootstrap.atomic_write(bootstrap.VENDOR_UNIT if rpm else bootstrap.UNIT, unit.encode()) + return libexec + + def test_source_convergence_retires_only_the_bootstrap_bundle(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + normal = self.install_normal() + bootstrap.finalize(bundle) + self.assertFalse(bundle.exists()) + bootstrap.ready(normal) + self.assertTrue((self.store.roots / self.protected / bundle.relative_to(self.host)).is_dir()) + + def test_rpm_convergence_removes_only_the_bootstrap_unit_override(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + normal = self.install_normal(rpm=True) + bootstrap.finalize(bundle) + self.assertFalse(bundle.exists()) + self.assertFalse(bootstrap.UNIT.exists()) + bootstrap.ready(normal) + + def test_package_only_update_keeps_its_required_recovery_implementation(self): + result = self.prepare() + bundle = Path(result['transactionHelper']).parent + with contextlib.redirect_stderr(io.StringIO()) as warning: + bootstrap.finalize(bundle) + self.assertIn('retained', warning.getvalue()) + bootstrap.ready(bundle) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/update-transaction.py b/tests/update-transaction.py new file mode 100644 index 00000000..606921e9 --- /dev/null +++ b/tests/update-transaction.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""Exercise the durable recovery journal against disposable filesystem state.""" +import contextlib +import importlib.machinery +import importlib.util +import io +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().parents[1] +loader = importlib.machinery.SourceFileLoader('transaction', str( + ROOT / 'roles/base/files/cybexos-update-transaction')) +spec = importlib.util.spec_from_loader(loader.name, loader) +transaction = importlib.util.module_from_spec(spec) +loader.exec_module(transaction) + + +class Recovery(unittest.TestCase): + def setUp(self): + self.enterContext(contextlib.redirect_stdout(io.StringIO())) + self.temp = tempfile.TemporaryDirectory(prefix='cybexos-transaction-test.') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.home = self.root / 'home' + self.home.mkdir() + self.store = transaction.snapshot.Store(self.root) + for directory in (self.store.roots, self.store.boot, self.store.metadata, self.store.replaced): + directory.mkdir(parents=True, exist_ok=True) + self.point = '20260930T100000Z-123' + (self.store.roots / self.point).mkdir() + self.layout = types.SimpleNamespace(usable=True, kind='btrfs', pending_reboot=False, + uuid='fixture') + self.vendor = ['.local/share/cybexos/runtime', '.local/share/cybexos/current', '.local/bin/helper'] + (self.home / self.vendor[0]).mkdir(parents=True) + (self.home / self.vendor[0] / 'shell.qml').write_text('old desktop') + (self.home / self.vendor[1]).symlink_to('releases/1.0.0') + self.personal = self.home / '.config/cybexos/shell.json' + self.personal.parent.mkdir(parents=True) + self.personal.write_text('{"personal": true}') + for name, value in ( + ('vendor_paths', lambda: self.vendor), + ('failed_units', lambda: []), + ('desktop_active', lambda _uid: False), + ('boot_id', lambda: 'old-boot'), + ('health', lambda _record, **_options: None), + ): + mocked = patch.object(transaction, name, value) + mocked.start() + self.addCleanup(mocked.stop) + account = types.SimpleNamespace(pw_dir=str(self.home), pw_name='fixture', pw_gid=os.getgid()) + for target, name, value in ( + (transaction.pwd, 'getpwuid', lambda _uid: account), + (transaction.snapshot, 'detect_layout', lambda: self.layout), + (transaction.snapshot, 'opened_store', self.opened_store), + (transaction.snapshot, 'subvolume_uuid', lambda _path: 'original-root'), + (transaction.snapshot, 'complete_interrupted', lambda _store: None), + (transaction.snapshot, 'command_restore', self.restore), + ): + mocked = patch.object(target, name, value) + mocked.start() + self.addCleanup(mocked.stop) + self.restores = 0 + + @contextlib.contextmanager + def opened_store(self, _layout, create=False): + yield self.store + + def restore(self, _args): + self.restores += 1 + self.layout.pending_reboot = True + path = self.store.replaced / 'root.replaced-20260930T100100Z.json' + path.write_text(json.dumps({'point': self.point, 'state': 'complete'})) + + def begin(self): + transaction.begin('update-fixture', self.point, os.getuid()) + + def record(self): + return transaction.read(self.store.path / 'transactions/update-fixture') + + def test_boot_home_operations_drop_identity_without_opening_a_pam_session(self): + command = ['/usr/bin/tar', '--list', '--file=-'] + with patch.object(transaction.os, 'geteuid', return_value=0): + dropped = transaction.home_command(1201, command) + self.assertEqual(dropped, ['/usr/bin/setpriv', '--reuid', '1201', '--regid', str(os.getgid()), + '--init-groups', '--', '/usr/bin/env', f'HOME={self.home}', + 'USER=fixture', 'LOGNAME=fixture', *command]) + with patch.object(transaction.os, 'geteuid', return_value=1201): + self.assertEqual(transaction.home_command(1201, command), command) + + def test_commit_validates_and_releases_checkpoint_and_pin(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'health') as health: + transaction.transition('update-fixture', 'commit') + health.assert_called_once() + self.assertEqual(self.record()['state'], 'committed') + self.assertFalse((self.store.metadata / f'{self.point}.pin').exists()) + self.assertFalse((self.store.path / 'transactions/update-fixture/vendor').exists()) + + def test_health_failure_keeps_checkpoint_until_automatic_rollback(self): + self.begin() + transaction.transition('update-fixture', 'applying') + (self.home / self.vendor[0] / 'shell.qml').write_text('broken new desktop') + self.personal.write_text('{"personal": "edited while updating"}') + (self.home / '.local/bin').mkdir(parents=True) + (self.home / '.local/bin/helper').write_text('new helper') + with patch.object(transaction, 'health', side_effect=transaction.snapshot.Failure('bad QML')): + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'commit') + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1) + self.assertEqual(self.record()['state'], 'rolled-back') + self.assertTrue(self.record()['restartRequired']) + self.assertEqual((self.home / self.vendor[0] / 'shell.qml').read_text(), 'old desktop') + self.assertFalse((self.home / '.local/bin/helper').exists()) + self.assertEqual(self.personal.read_text(), '{"personal": "edited while updating"}') + self.assertEqual(os.readlink(self.home / self.vendor[1]), 'releases/1.0.0') + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1, 'retry must not exchange roots twice') + + def test_terminal_cleanup_failure_cannot_reopen_a_completed_transaction(self): + for action, terminal in (('commit', 'committed'), ('rollback', 'rolled-back')): + with self.subTest(action=action): + identifier = 'cleanup-' + action + transaction.begin(identifier, self.point, os.getuid()) + transaction.transition(identifier, 'applying') + with patch.object(transaction, 'remove', side_effect=OSError('cleanup interrupted')): + with contextlib.redirect_stderr(io.StringIO()) as errors: + if action == 'rollback': + transaction.rollback(identifier) + else: + transaction.transition(identifier, action) + self.assertIn('retained cleanup artifact', errors.getvalue()) + record = transaction.read(self.store.path / 'transactions' / identifier) + self.assertEqual(record['state'], terminal) + with patch.object(transaction, 'rollback') as retry: + transaction.recover() + retry.assert_not_called() + + def test_interrupted_vendor_restore_retries_without_second_root_exchange(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'restore_vendor', side_effect=OSError('interrupted')): + with self.assertRaises(OSError): + transaction.rollback('update-fixture') + self.assertEqual(self.record()['state'], 'rollback-failed') + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1) + self.assertEqual(self.record()['state'], 'rolled-back') + + def test_interrupted_restore_completed_after_reboot_never_exchanges_root_again(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'restore_vendor', side_effect=OSError('power lost')): + with self.assertRaises(OSError): + transaction.rollback('update-fixture') + # The exchange completed before power failed, and the next boot uses + # its restored root; only the user-owned vendor archive needs replay. + self.layout.pending_reboot = False + self.personal.write_text('{"edited after restart": true}') + with patch.object(transaction, 'boot_id', return_value='new-boot'): + transaction.rollback('update-fixture') + self.assertEqual(self.restores, 1) + self.assertFalse(self.record()['restartRequired']) + self.assertEqual(self.personal.read_text(), '{"edited after restart": true}') + + def test_recovery_from_a_failed_boot_still_requires_restart_after_root_exchange(self): + self.begin() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'boot_id', return_value='failed-new-boot'): + with self.assertRaises(SystemExit) as error: + transaction.recover() + self.assertEqual(error.exception.code, 75) + self.assertTrue(self.record()['restartRequired']) + self.assertEqual(self.restores, 1) + + def test_a_failed_sync_leaves_the_durable_applying_record_recoverable(self): + self.begin() + original_execute = transaction.execute + + def fail_journal_sync(command, **options): + if command[:2] == ['sync', '-f']: + raise transaction.snapshot.Failure('simulated filesystem sync failure') + return original_execute(command, **options) + + with patch.object(transaction, 'execute', side_effect=fail_journal_sync): + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'applying') + self.assertEqual(self.record()['state'], 'applying') + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'abort') + transaction.rollback('update-fixture') + self.assertEqual(self.record()['state'], 'rolled-back') + self.assertEqual(self.restores, 1) + + def test_major_upgrade_waits_for_new_boot_then_explicit_desktop_validation(self): + self.begin() + original = self.personal.read_bytes() + metadata = {'targetFedora': '45', 'source': 'fixture-reviewed-release'} + transaction.transition('update-fixture', 'arm-upgrade', metadata) + self.assertEqual(self.record()['state'], 'awaiting-upgrade') + self.assertEqual(self.record()['upgrade'], metadata) + with patch.object(transaction.subprocess, 'run') as finalize: + transaction.recover() + finalize.assert_not_called() + transaction.transition('update-fixture', 'applying') + with patch.object(transaction, 'health') as check: + transaction.transition('update-fixture', 'await-desktop') + check.assert_called_once() + self.assertEqual(check.call_args.kwargs, {'desktop': False}) + self.assertEqual(self.record()['state'], 'awaiting-desktop') + self.assertTrue(self.record()['desktopActive']) + self.assertTrue((self.store.metadata / f'{self.point}.pin').exists()) + self.assertTrue((self.store.path / 'transactions/update-fixture/vendor').is_dir()) + with patch.object(transaction.subprocess, 'run') as finalize: + with patch.object(transaction, 'boot_id', return_value='another-boot'): + transaction.recover() + finalize.assert_not_called() + self.assertEqual(self.personal.read_bytes(), original) + transaction.transition('update-fixture', 'commit') + self.assertEqual(self.record()['state'], 'committed') + self.assertFalse((self.store.metadata / f'{self.point}.pin').exists()) + self.assertEqual(self.personal.read_bytes(), original) + + def test_failed_major_upgrade_finalization_rolls_back_before_logins(self): + self.begin() + transaction.transition('update-fixture', 'arm-upgrade', {'targetFedora': '45'}) + original_run = subprocess.run + finalizations = [] + + def execute_fixture(command, **options): + if command[0].endswith('cybexos-major-upgrade'): + finalizations.append(command) + return types.SimpleNamespace(returncode=1) + return original_run(command, **options) + + with patch.object(transaction.subprocess, 'run', side_effect=execute_fixture): + with patch.object(transaction, 'boot_id', return_value='new-major-boot'): + with self.assertRaises(SystemExit) as error: + transaction.recover() + self.assertEqual(error.exception.code, 75) + self.assertEqual(finalizations[0][1:], ['finalize', 'update-fixture']) + self.assertEqual(self.record()['state'], 'rolled-back') + self.assertTrue(self.record()['restartRequired']) + self.assertEqual(self.personal.read_text(), '{"personal": true}') + + def test_invalid_major_upgrade_transition_keeps_checkpoint_and_user_state(self): + self.begin() + before = self.record() + for metadata in (None, {}, {'targetFedora': 45}, {'targetFedora': '../../root'}): + with self.subTest(metadata=metadata): + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'arm-upgrade', metadata) + self.assertEqual(self.record(), before) + self.assertEqual(self.personal.read_text(), '{"personal": true}') + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'await-desktop') + self.assertTrue((self.store.metadata / f'{self.point}.pin').exists()) + + def test_snapshot_retention_never_prunes_an_active_transaction_point(self): + self.begin() + old_points = [self.point] + for index in range(1, 7): + point = f'20260930T1000{index:02d}Z-123' + old_points.append(point) + (self.store.roots / point).mkdir() + (self.store.boot / f'{point}.tar').write_bytes(b'fixture boot') + (self.store.metadata / f'{point}.meta').write_text('fixture\n') + deleted = [] + + def filesystem_fixture(command): + if command[0] == 'tar': + Path(command[command.index('--file') + 1]).write_bytes(b'new fixture boot') + elif command[:3] == ['btrfs', 'subvolume', 'snapshot']: + Path(command[-1]).mkdir() + elif command[:3] == ['btrfs', 'subvolume', 'delete']: + deleted.append(Path(command[-1]).name) + Path(command[-1]).rmdir() + elif command[:2] != ['sync', '-f']: + self.fail('Unexpected command escaped the filesystem fixture: ' + repr(command)) + + with patch.object(transaction.snapshot, 'run', side_effect=filesystem_fixture), \ + patch.object(transaction.snapshot.shutil, 'which', return_value='/fixture/tool'), \ + patch.object(transaction.snapshot, 'current_kernel', return_value='fixture-kernel'), \ + patch.object(transaction.snapshot, 'regenerate_quietly'), \ + patch.dict(os.environ, {'CYBEXOS_SNAPSHOT_ID': '20260930T100100Z-123'}): + transaction.snapshot.command_create(['fixture update']) + self.assertNotIn(self.point, deleted) + self.assertEqual(deleted, old_points[1:4]) + self.assertTrue((self.store.roots / self.point).is_dir()) + self.assertEqual(len(list(self.store.roots.iterdir())), transaction.snapshot.KEEP) + + def test_boot_recovery_aborts_unapplied_update_and_rolls_back_applied_one(self): + self.begin() + with patch.object(transaction, 'boot_id', return_value='next-boot'): + transaction.recover() + self.assertEqual(self.record()['state'], 'aborted') + self.assertEqual(self.restores, 0) + transaction.begin('second-update', self.point, os.getuid()) + transaction.transition('second-update', 'applying') + with patch.object(transaction, 'boot_id', return_value='next-boot'): + with self.assertRaises(SystemExit) as error: + transaction.recover() + self.assertEqual(error.exception.code, 75) + self.assertEqual(self.restores, 1) + + def test_login_dependency_never_recovers_an_update_from_the_current_boot(self): + self.begin() + for action, state in ((None, 'prepared'), ('applying', 'applying')): + if action: + transaction.transition('update-fixture', action) + transaction.recover() + self.assertEqual(self.record()['state'], state) + self.assertEqual(self.restores, 0) + + def test_refuses_competing_updates_and_illegal_transitions(self): + self.begin() + with self.assertRaises(transaction.snapshot.Failure): + transaction.begin('competing', self.point, os.getuid()) + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'commit') + transaction.transition('update-fixture', 'applying') + with self.assertRaises(transaction.snapshot.Failure): + transaction.transition('update-fixture', 'abort') + + def test_refuses_symlink_parent_and_preserves_checkpoint(self): + self.begin() + (self.home / '.local/bin').symlink_to(self.root / 'outside') + with self.assertRaises(transaction.snapshot.Failure): + transaction.rollback('update-fixture') + self.assertEqual(self.record()['state'], 'rollback-failed') + self.assertFalse((self.root / 'outside').exists()) + + def test_manifest_never_contains_personal_settings_or_registry(self): + paths = json.loads((ROOT / 'roles/base/files/cybexos-vendor-paths.json').read_text()) + for value in paths: + self.assertNotIn('.config/cybexos', value) + self.assertNotIn('.local/share/cybexos/plugins', value) + self.assertNotIn('.local/share/cybexos/themes', value) + self.assertNotIn('.service.d', value) + + def test_installed_transaction_payloads_share_the_source(self): + source = (ROOT / 'image/package').read_text() + tasks = (ROOT / 'roles/base/tasks/main.yml').read_text() + for name in ('cybexos-update-transaction', 'cybexos-update-recover', 'cybexos-vendor-paths.json'): + self.assertIn(name, source) + self.assertIn(name, tasks) + subprocess.run(['bash', '-n', str(ROOT / 'roles/base/files/cybexos-update-recover')], check=True) + + +class DesktopHealth(unittest.TestCase): + """Actual health state machine, with no host service or process operations.""" + + def setUp(self): + self.clock = 0.0 + self.calls = [] + self.ipc_calls = [] + self.ready_after = 0.0 + self.safe_mode = False + self.bad_journal = False + self.extra_process = False + self.restart_every = None + self.restart_at = None + self.ipc_gap = None + self.record = {'uid': 1000, 'desktopActive': True, 'failedUnits': []} + self.enterContext(patch.object(transaction, 'as_user', side_effect=lambda _uid, command: command)) + self.enterContext(patch.object(transaction, 'desktop_active', return_value=True)) + self.enterContext(patch.object(transaction, 'failed_units', return_value=[])) + self.enterContext(patch.object(transaction.pwd, 'getpwuid', return_value=types.SimpleNamespace( + pw_dir='/nonexistent-cybexos-health-fixture', pw_name='fixture'))) + self.enterContext(patch.object(transaction.time, 'monotonic', side_effect=lambda: self.clock)) + self.enterContext(patch.object(transaction.time, 'sleep', side_effect=self.advance)) + self.enterContext(patch.object(transaction, 'execute', side_effect=self.execute)) + + def advance(self, seconds): + self.clock += seconds + + def generation(self): + if self.restart_every: + return int(self.clock // self.restart_every) + return int(self.restart_at is not None and self.clock >= self.restart_at) + + def execute(self, command, **_options): + self.calls.append(command) + if command[:2] == ['rpm', '--verifydb']: + return '' + if command[:3] in (['systemctl', '--user', 'daemon-reload'], + ['systemctl', '--user', 'restart']): + return '' + if command[:3] == ['systemctl', '--user', 'show']: + if command[-2] == 'InvocationID': + return f'{self.generation() + 1:032x}' + if command[-2] == 'MainPID': + return str(3000 + self.generation()) + if command[0] == 'pgrep': + value = str(3000 + self.generation()) + return value + '\n9999' if self.extra_process else value + if command[-2:] == ['shell', 'status']: + return json.dumps({'safe': self.safe_mode}) + if command[-3:] == ['ipc', 'settings', 'status']: + self.ipc_calls.append(self.clock) + if self.clock < self.ready_after: + raise transaction.snapshot.Failure('IPC is not ready yet') + if self.ipc_gap and self.ipc_gap[0] <= self.clock < self.ipc_gap[1]: + raise transaction.snapshot.Failure('IPC disappeared during startup') + return '{"services":{}}' + if command[0] == 'journalctl': + return 'ReferenceError: delayed QML startup failed' if self.bad_journal else 'ready' + self.fail('Unexpected host operation in health fixture: ' + repr(command)) + + def test_delayed_qml_readiness_requires_two_stable_seconds_after_ipc(self): + self.ready_after = 1.5 + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 3.5) + self.assertLess(self.clock, 4) + self.assertGreater(len(self.ipc_calls), 2) + self.assertEqual(sum(command[:3] == ['systemctl', '--user', 'restart'] + for command in self.calls), 1) + + def test_restart_during_validation_resets_pid_and_invocation_stability(self): + self.restart_at = 1.5 + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 3.5) + journal = next(command for command in self.calls if command[0] == 'journalctl') + self.assertIn('_SYSTEMD_INVOCATION_ID=' + f'{2:032x}', journal) + + def test_lost_ipc_resets_the_stability_interval(self): + self.ipc_gap = (1, 1.5) + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 3.5) + + def test_a_restart_loop_never_becomes_healthy(self): + self.restart_every = 1 + with self.assertRaisesRegex(transaction.snapshot.Failure, 'IPC-ready'): + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 45) + self.assertFalse(any(command[0] == 'journalctl' for command in self.calls)) + + def test_fallback_shell_is_not_a_successful_update(self): + self.safe_mode = True + with self.assertRaisesRegex(transaction.snapshot.Failure, 'IPC-ready'): + transaction.health(self.record) + self.assertEqual(self.ipc_calls, []) + self.assertGreaterEqual(self.clock, 45) + + def test_a_second_qs_process_is_rejected(self): + self.extra_process = True + with self.assertRaisesRegex(transaction.snapshot.Failure, 'IPC-ready'): + transaction.health(self.record) + self.assertEqual(self.ipc_calls, []) + + def test_journal_failure_after_ready_ipc_prevents_commit(self): + self.bad_journal = True + with self.assertRaisesRegex(transaction.snapshot.Failure, 'QML errors'): + transaction.health(self.record) + self.assertGreaterEqual(self.clock, 2) + + def test_new_failed_units_stop_before_restarting_desktop(self): + self.record['failedUnits'] = ['existing-failure.service'] + with patch.object(transaction, 'failed_units', return_value=[ + 'existing-failure.service', 'new-failure.service']): + with self.assertRaisesRegex(transaction.snapshot.Failure, 'new-failure.service'): + transaction.health(self.record) + self.assertEqual(self.calls, [['rpm', '--verifydb']]) + + def test_prelogin_health_never_starts_or_queries_a_desktop(self): + transaction.health(self.record, desktop=False) + self.assertEqual(self.calls, [['rpm', '--verifydb']]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/verify-system b/tests/verify-system index dfa451ce..73c9f189 100755 --- a/tests/verify-system +++ b/tests/verify-system @@ -312,11 +312,10 @@ if command_exists firewall-cmd && unit_active firewalld.service; then ports=$(firewall-cmd --permanent --zone="$firewall_zone" --list-ports 2>/dev/null | tr ' ' '\n' | sort | tr '\n' ' ') expected=$(jq -r ' . as $root - | if $root.features.local_network_services then - $root.firewall_ports[] - | select((.feature == null) or ($root.features[.feature] == true)) - | "\(.port)/\(.protocol)" - else empty end + | $root.firewall_ports[] + | select(.always == true or $root.features.local_network_services) + | select((.feature == null) or ($root.features[.feature] == true)) + | "\(.port)/\(.protocol)" ' <<<"$inventory_json" | sort | tr '\n' ' ') [[ $ports == "$expected" ]] && pass 'firewall exposes only declared ports' || fail "firewall ports differ: $ports" services=$(firewall-cmd --permanent --zone="$firewall_zone" --list-services 2>/dev/null || true) @@ -351,11 +350,18 @@ else fi cmdline=$(/dev/null; then + warn 'Xe panel self-refresh workaround is configured and awaits reboot' +fi if command_exists mokutil; then mokutil --sb-state >/dev/null 2>&1 && pass 'Secure Boot state is readable' || warn 'Secure Boot state could not be read' fi From df1c2e14827ddbaa5386350675d283b5deb6521b Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:27:25 +0200 Subject: [PATCH 2/2] Trust the explicit read-only checkout in the image test container The image fixtures enumerate Git-tracked source files. CI mounts a host-owned checkout at /source while tests run as root, so Git rejects it without an explicit safe directory. Limit that trust to /source inside the disposable test image. --- image/Containerfile.tests | 3 +++ 1 file changed, 3 insertions(+) diff --git a/image/Containerfile.tests b/image/Containerfile.tests index c9189fed..f6bfbfa2 100644 --- a/image/Containerfile.tests +++ b/image/Containerfile.tests @@ -3,6 +3,9 @@ RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject 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